Compare commits

..

17 Commits

Author SHA1 Message Date
MHSanaei e08456269b fix(traffic): count local traffic for clients whose shared row is node-owned (#4921)
client_traffics is keyed by email (one shared row per client across every
inbound it is attached to). addClientTraffic filtered with
`inbound_id NOT IN (node inbounds)`, so when a client was attached to both a
node inbound and the mother inbound and the node inbound was attached first,
the shared row carried the node inbound's id (AddClientStat uses OnConflict
DoNothing and never refreshes it) and the local xray's traffic for that client
was dropped entirely. The client showed online but its usage stayed at zero
unless the mother inbound happened to be attached first.

Match purely by email instead. The reported emails come only from the local
xray, which only knows local-attached clients, so the query is still correctly
scoped, and this also repairs already-broken rows that a per-row AddClientStat
fix alone could not.
2026-06-05 00:24:01 +02:00
MHSanaei f8e902a7b6 fix(sub): include ECH config in TLS share links and JSON subscription
echConfigList was stored under tlsSettings.settings but the share-link
and JSON-subscription generators only read fingerprint and
pinnedPeerCertSha256 from that bag, silently dropping ECH from VLESS,
Trojan and VMess links. Read echConfigList alongside them and flatten it
into tlsSettings.echConfigList for the JSON subscription.

Closes #4933
2026-06-05 00:20:29 +02:00
Hamed d6d2085d60 fix: restart remote xray after disabling a client to kill active sessions (#4918)
* fix(node-traffic): restart remote xray after disabling clients to kill active sessions

When a client's traffic limit is reached on a remote node, the panel pushes
enable=false to that node via UpdateInbound. The node calls RemoveUser on its
local xray, which blocks new connections but leaves any already-established TCP
session alive. The user could continue browsing/downloading until they
disconnected voluntarily.

Fix: after successfully pushing a client disable to a remote node, call
RestartXray on that node. This mirrors what already happens for the local node
when the "Restart Xray on client disable" setting is enabled (default: on),
and ensures active sessions are terminated immediately on all nodes where the
client was disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(node): restart remote xray after tx commit, not inside it

Move the remote RestartXray calls out of the addTraffic write
transaction. disableInvalidClients now returns the affected remote
node IDs instead of restarting their xray while the SQLite write lock
is held; AddTraffic performs the restart after the transaction commits
via restartRemoteNodesOnDisable. Avoids holding the serialized write
lock across slow per-node restart RPCs.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-05 00:02:52 +02:00
Hamed 12d84c2a46 fix(node-traffic): prevent stale node snapshot from re-enabling disabled client (#4917)
When a remote node syncs traffic back to the panel, the UPDATE in
setRemoteTrafficLocked wrote cs.Enable directly into client_traffics.enable.
If a snapshot carrying enable=true arrived after the central panel had already
set enable=false (due to the client reaching their traffic limit), it silently
re-enabled the client — letting them consume 2-3x their allotted quota before
the next disable cycle caught up.

Fix: replace the unconditional SET enable = ? with a CASE expression that only
allows a disable (0->0), never a re-enable (0->1). The central panel remains
the sole authority for turning a client back on.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-04 23:54:29 +02:00
biohazardous-man 97f88fb1a9 feat(sub): modern xray JSON format with unified finalmask editor (#4912)
* feat(sub): add finalmask support to JSON subscriptions

* feat(sub): modern xray JSON format with unified finalmask editor

Drop the legacy JSON subscription format entirely and always emit the
modern xray shape:

- Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/
  shadowsocks; hysteria was already flat.
- Express fragment/noise via streamSettings.finalmask instead of the
  legacy direct_out freedom dialer + dialerProxy sockopt.

The global finalmask (tcp/udp masks + quicParams) is stored as a single
setting (subJsonFinalMask) and merged into every generated stream,
replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams
settings.

Reuse the existing FinalMaskForm (used by inbound/outbound) for the
settings UI via a small bridge component; add a showAll prop so all
TCP/UDP/QUIC sections render for the global case. This supersedes the
hand-rolled Fragment/Noises/quicParams tabs with the full mask editor
(all mask types).

Note: this is a breaking change — JSON subscriptions now require a
recent xray client on the consumer side.

* fix

---------

Co-authored-by: biohazardous-man <biohazardous-man@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-04 23:51:48 +02:00
Misfit-s f947fbd6c6 feat(Clash): Add routing rules and enable routing option for Clash subscriptions (#4904)
* feat(clash): add routing rules and enable routing option for Clash/Mihomo subscriptions

Allows adding custom YAML blocks and placeholders to Clash exports.

Why: Shifting routing to the client prevents server IP exposure for
DIRECT traffic and reduces unnecessary server bandwidth/CPU usage.

* fix

---------

Co-authored-by: Misfit-s <>
2026-06-04 21:55:51 +02:00
dependabot[bot] ba63fa8569 chore(deps): bump i18next from 26.3.0 to 26.3.1 in /frontend (#4901)
Bumps [i18next](https://github.com/i18next/i18next) from 26.3.0 to 26.3.1.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.1)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 21:46:11 +02:00
康厚超 73ce11508e fix(tgbot): ignore commands for other bots (#4894)
Telegram group chats can contain multiple bots. Commands addressed to another bot, such as /status@other_bot, should not be handled by the 3x-ui bot.

Closes #4893
2026-06-04 21:45:44 +02:00
lim-kim930 a4b3e999a1 fix(i18n): add 1-year expiration to language cookie (#4890) 2026-06-04 21:38:15 +02:00
MHSanaei d3db828b46 perf(clients): scale-audit remaining client/inbound endpoints to 200k
Drive every client/inbound/group endpoint at 100k-200k clients on PostgreSQL and fix the latent issues found in previously-unbenchmarked paths:

- enrichClientStats: chunk the email IN lookup (was an unchunked bind that crashed past 65535 clients without traffic rows, taking down GetInbounds/GetInboundDetail/GetAllInbounds)

- GetOnlineClients: add the missing nil-process guard its siblings already have, so ListPaged no longer panics before xray starts

- GetClientTrafficByEmail: read UUID/subId from the indexed clients table instead of parsing the inbound's full settings JSON (439ms to ~1.5ms, flat in N)

- BulkResetTraffic: replace the per-email serialized loop with one chunked bulk UPDATE in a single transaction

- DelDepleted: delegate to the already-batched BulkDelete instead of deleting each depleted client one by one

Adds a postgres-gated full endpoint sweep plus an A/B benchmark, and SQLite correctness tests for the changed methods.
2026-06-04 21:32:15 +02:00
MHSanaei d1e733b9e9 perf(clients): chunk IN queries and de-quadratic bulk delete/group/list
Bulk client operations bound their entire working set in a single
WHERE x IN (...) clause, which exceeds PostgreSQL's 65535-parameter limit
(and SQLite's 32766) and gives the planner a pathological query, so they
failed outright on inbounds/selections larger than the limit. Every such
query is now chunked at 400 items:

- BulkDelete / delete-all-clients: six IN queries chunked, and the
  per-row delete tombstone (which swept the whole in-memory map on every
  call, O(N^2)) replaced with a single bulk sweep.
- BulkAdjust: record and inbound-mapping lookups chunked.
- AddToGroup / RemoveFromGroup (bulk add/remove to group): three IN
  queries chunked.
- replaceGroupValue (rename/delete group): inbound-mapping lookup chunked.
- List (all-clients listing): link and traffic lookups chunked.

Measured on PostgreSQL 16: delete-all-clients on a 100k-client inbound
now completes in ~7s (previously crashed at the parameter limit); bulk
add/remove to group ~6s and full client list ~1s at 100k.

sync_scale_postgres_test.go adds skip-gated benchmarks for delete-all,
group add/remove, and list.
2026-06-04 20:35:30 +02:00
MHSanaei f185d3315c perf(clients): scale add/delete and bulk client operations
Follow-up to the SyncInbound bulk rewrite, fixing the remaining O(M*N)
and O(M)-round-trip behaviour in the add/delete and bulk paths that made
them time out on large inbounds (worst case minutes), especially on
PostgreSQL.

- compactOrphans: chunk the "email IN (...)" lookup (400/batch) instead
  of binding every email at once. A single huge IN exceeded PostgreSQL's
  65535-parameter limit (and SQLite's) and made the planner pathological,
  so add/delete failed outright past ~100k clients.

- emailsUsedByOtherInbounds: new batched form used by delInboundClients
  (BulkDetach) and bulkDelInboundClients (BulkDelete), replacing a
  per-email global JSON scan (O(M*N)) with one scan, and skipped entirely
  when keepTraffic is set.

- BulkCreate: rewritten to validate/dedup in one pass, then group clients
  by inbound and add them in a single addInboundClient call per inbound
  (one getAllEmailSubIDs, one settings rewrite, one SyncInbound) instead
  of running the full single-create pipeline per client.

- Bulk delete/adjust: batch DelClientStat/DelClientIPs with IN deletes
  and wrap the settings Save + SyncInbound in one transaction, so the
  per-row writes share a single fsync instead of one per row.

Measured on PostgreSQL 16 (one inbound, M=2000 affected clients):
  - create: 8m35s (M=500) -> ~1-5s
  - detach: 52s -> ~4s (flat in N)
  - delete: ~16s -> ~1-4s
  - adjust: ~20s -> ~7-10s
add/delete of a single client on a 200k-client inbound stays in seconds.

sync_scale_postgres_test.go adds skip-gated benchmarks (XUI_DB_TYPE=
postgres) for the single add/delete and the five bulk operations.
2026-06-04 19:41:00 +02:00
MHSanaei 756746dbca perf(clients): make SyncInbound bulk to fix large-inbound timeouts (#4885)
Every client mutation funnels through SyncInbound, which ran O(n) DB
round-trips per call: one SELECT per client, a Save+UpdateColumn per
client, and a per-row junction INSERT. Toggling a single client on a
large inbound issued thousands of queries and timed out, badly so on
PostgreSQL where each round-trip pays TCP latency.

SyncInbound now:
- loads existing records with a single chunked SELECT ... email IN (...)
  instead of one query per client
- writes only the records that actually changed (skips no-op Saves), so
  toggling/editing one client writes one row, not all of them
- batch-creates new records and batch-inserts the junction rows

Merge and sticky-field semantics are unchanged. Measured on PostgreSQL
16: a single-client toggle on a 50k-client inbound drops from ~8m54s to
~0.9s, and seeding 50k clients from ~2m48s to ~1.6s; 200k clients sync
in seconds.

A skip-gated benchmark (web/service/sync_scale_postgres_test.go, run
with XUI_DB_TYPE=postgres) reproduces and verifies the scaling.
2026-06-04 18:14:25 +02:00
MHSanaei 44291de989 fix(ssl): clean ECC state, guard cert reuse, register renew hook (#4875)
- Cleanup on issuance/install failure now also removes the acme.sh
  ${domain}_ecc (and ${ip}_ecc) directory, not just ${domain}, so a
  failed run no longer leaves partial state behind.
- The 'existing certificate' check only reuses a cert when its
  fullchain.cer and key files are actually present and non-empty;
  otherwise the broken state is removed and issuance proceeds. This
  fixes the 0-byte fullchain.pem produced by reusing failed state.
- Menu option 5 (set cert paths) now registers the acme.sh --installcert
  hook with --reloadcmd 'x-ui restart' when acme.sh knows the domain, so
  auto-renewal copies the renewed cert and reloads the panel.
2026-06-04 17:15:33 +02:00
MHSanaei b1d079fc24 fix(fail2ban): exempt SSH and panel ports from IP-limit ban (#4896)
The 3x-ipl action used iptables-allports, so a banned IP lost all TCP
access including SSH and the panel, locking admins out (especially with
dynamic-IP clients). The ban now blocks every TCP port except the SSH
and panel ports via a multiport negation, derived at jail-creation time
in both x-ui.sh and DockerEntrypoint.sh. This keeps IP-limit working for
all current and future inbounds without per-port config.
2026-06-04 17:05:27 +02:00
MHSanaei 14e2d4954a fix(migrate-db): drop legacy client_traffics FK before Postgres copy (#4882)
AutoMigrate re-creates the client_traffics -> inbounds foreign key, but
the running panel drops it and tolerates client_traffics rows whose
inbound was deleted. Migrating a DB with such orphaned rows failed with
an fk_inbounds_client_stats violation. Drop the constraint on the
destination right after AutoMigrate so the copy matches runtime behavior.
2026-06-04 16:57:09 +02:00
MHSanaei db86007ab8 fix(multi-node): scope remote client update/delete to one inbound (#4892)
UpdateUser and DeleteUser hit the node's email-based full-client endpoints, which fanned out to every inbound the client had on the node: editing a client wiped flow on the node's other inbounds, and detaching one node inbound deleted the client from all of them.

Make both inbound-scoped, mirroring AddClient. DeleteUser now detaches the resolved remote inbound id; UpdateUser passes an inboundIds scope so the node updates only that inbound.
2026-06-04 16:45:40 +02:00
52 changed files with 2387 additions and 636 deletions
+13 -2
View File
@@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex =
EOF
# Ports to exempt from the ban so an over-limit proxy client can never lock
# the administrator out of SSH or the panel. The ban still covers every other
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
# added later without regenerating these files.
SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -)
[ -z "$SSH_PORTS" ] && SSH_PORTS="22"
PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
EXEMPT_PORTS="$SSH_PORTS"
[ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT"
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
[INCLUDES]
before = iptables-allports.conf
@@ -42,16 +52,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init]
name = default
protocol = tcp
chain = INPUT
exemptports = $EXEMPT_PORTS
EOF
fail2ban-client -x start
+8
View File
@@ -86,6 +86,14 @@ func MigrateData(srcPath, dstDSN string) error {
}
}
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
// client_traffics rows whose inbound was deleted. Drop it here too so copying
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
return fmt.Errorf("drop legacy foreign key: %w", err)
}
// Empty the destination tables so the migration is idempotent: a fresh
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
// panel start, and a partially-failed earlier run leaves rows behind. Either
+4 -34
View File
@@ -17,7 +17,7 @@
"axios": "^1.17.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.0",
"i18next": "^26.3.1",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2",
@@ -1934,9 +1934,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1954,9 +1951,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1974,9 +1968,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1994,9 +1985,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2014,9 +2002,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2034,9 +2019,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5087,9 +5069,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
@@ -5615,9 +5597,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5639,9 +5618,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5663,9 +5639,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5687,9 +5660,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+1 -1
View File
@@ -29,7 +29,7 @@
"axios": "^1.17.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.0",
"i18next": "^26.3.1",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2",
+31 -1
View File
@@ -1495,6 +1495,36 @@
}
}
},
"/panel/api/server/getMigration": {
"get": {
"tags": [
"Server"
],
"summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.",
"operationId": "get_panel_api_server_getMigration",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/getNewUUID": {
"get": {
"tags": [
@@ -5761,7 +5791,7 @@
"tags": [
"Subscription Server"
],
"summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
"summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
"operationId": "get_clashPath_subid",
"parameters": [
{
+6 -4
View File
@@ -34,7 +34,9 @@ export interface AllSetting {
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
@@ -42,9 +44,8 @@ export interface AllSetting {
subEnableRouting: boolean;
subEncrypt: boolean;
subJsonEnable: boolean;
subJsonFragment: string;
subJsonFinalMask: string;
subJsonMux: string;
subJsonNoises: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
@@ -121,7 +122,9 @@ export interface AllSettingView {
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
@@ -129,9 +132,8 @@ export interface AllSettingView {
subEnableRouting: boolean;
subEncrypt: boolean;
subJsonEnable: boolean;
subJsonFragment: string;
subJsonFinalMask: string;
subJsonMux: string;
subJsonNoises: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
+6 -4
View File
@@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
@@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFragment: z.string(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
@@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
@@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFragment: z.string(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
@@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives';
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
// paths under `name`; the parent modal owns the Form instance.
//
// Naming convention inside Form.List: AntD prefixes Form.Item `name`
// with the Form.List's own `name`. So Form.Items inside the render
// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
// Form.Lists also use relative names. Using absolute paths here would
// double up the prefix and silently route reads/writes to the wrong
// storage path.
export interface FinalMaskFormProps {
name: NamePath;
network: string;
protocol: string;
form: FormInstance;
// When true, all sections (TCP / UDP / QUIC) are shown regardless of
// network/protocol. Used by the global sub-JSON finalmask editor where
// the masks apply to every stream rather than one specific transport.
showAll?: boolean;
}
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
@@ -99,12 +93,12 @@ function defaultUdpHop(): Record<string, unknown> {
return { ports: '20000-50000', interval: '5-10' };
}
export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name);
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
const showTcp = TCP_NETWORKS.includes(network);
const showUdp = isHysteria || network === 'kcp';
const showQuic = isHysteria || network === 'xhttp';
const showTcp = showAll || TCP_NETWORKS.includes(network);
const showUdp = showAll || isHysteria || network === 'kcp';
const showQuic = showAll || isHysteria || network === 'xhttp';
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
const hasQuicParams = quicParams != null;
@@ -392,13 +386,13 @@ function UdpMaskItem({
const options = isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
: [
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
{ value: 'xdns', label: 'xDNS' },
{ value: 'xicmp', label: 'xICMP' },
{ value: 'realm', label: 'Realm' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' },
];
{ value: 'mkcp-legacy', label: 'mKCP Legacy' },
{ value: 'xdns', label: 'xDNS' },
{ value: 'xicmp', label: 'xICMP' },
{ value: 'realm', label: 'Realm' },
{ value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' },
];
return (
<div>
+1
View File
@@ -232,6 +232,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
}
+3 -2
View File
@@ -55,10 +55,11 @@ export class AllSetting {
subURI = '';
subJsonURI = '';
subClashURI = '';
subJsonFragment = '';
subJsonNoises = '';
subClashEnableRouting = false;
subClashRules = '';
subJsonMux = '';
subJsonRules = '';
subJsonFinalMask = '';
timeLocation = 'Local';
+1 -1
View File
@@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/{clashPath}:subid',
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
@@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
)}
{form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}>
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
<InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
</Form.Item>
)}
+2 -4
View File
@@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
function UngroupIcon() {
return (
@@ -276,10 +277,7 @@ export default function ClientsPage() {
const activeCount = activeFilterCount(filters);
useEffect(() => {
if (pageSize > 0) {
setTablePageSize(pageSize);
}
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
}, [pageSize]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
interface SubJsonFinalMaskFormProps {
value: string;
onChange: (next: string) => void;
}
function hasValue(v: unknown): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.some(hasValue);
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
if (typeof v === 'string') return v.length > 0;
return true;
}
function parseFinalMask(raw: string): FinalMaskStreamSettings {
try {
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
} catch {
return { tcp: [], udp: [] };
}
return { tcp: [], udp: [] };
}
export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
const [form] = Form.useForm();
const [initial] = useState(() => parseFinalMask(value));
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
useEffect(() => {
if (finalmask === undefined) return;
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
if (next !== value) onChangeRef.current(next);
}, [finalmask, value]);
return (
<Form
form={form}
layout="horizontal"
labelCol={{ flex: '160px' }}
wrapperCol={{ flex: 'auto' }}
colon={false}
initialValues={{ finalmask: initial }}
>
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
</Form>
);
}
@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Input,
InputNumber,
Select,
@@ -10,19 +8,17 @@ import {
Tabs,
} from 'antd';
import {
DeleteOutlined,
PartitionOutlined,
PlusOutlined,
ScissorOutlined,
RocketOutlined,
SendOutlined,
SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
import './SubscriptionFormatsTab.css';
interface SubscriptionFormatsTabProps {
@@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
updateSetting: (patch: Partial<AllSetting>) => void;
}
const DEFAULT_FRAGMENT = {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
};
const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
];
const DEFAULT_MUX = {
enabled: true,
concurrency: 8,
@@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const fragment = allSetting.subJsonFragment !== '';
const noisesEnabled = allSetting.subJsonNoises !== '';
const muxEnabled = allSetting.subJsonMux !== '';
const directEnabled = allSetting.subJsonRules !== '';
const fragmentObj = useMemo(
() => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
[allSetting.subJsonFragment, fragment],
);
function setFragmentEnabled(v: boolean) {
updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
}
function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
if (value === '') return;
const next = { ...fragmentObj, [key]: value };
updateSetting({ subJsonFragment: JSON.stringify(next) });
}
const noisesArray = useMemo(
() => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
[allSetting.subJsonNoises, noisesEnabled],
);
function setNoisesEnabled(v: boolean) {
updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
}
function setNoisesArray(next: typeof DEFAULT_NOISES) {
if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
}
function addNoise() {
setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
}
function removeNoise(index: number) {
const next = [...noisesArray];
next.splice(index, 1);
setNoisesArray(next);
}
function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
const next = [...noisesArray];
next[index] = { ...next[index], [field]: value };
setNoisesArray(next);
}
const muxObj = useMemo(
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
[allSetting.subJsonMux, muxEnabled],
@@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
},
{
key: '2',
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
<Switch checked={fragment} onChange={setFragmentEnabled} />
</SettingListItem>
{fragment && (
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
onChange={(e) => setFragmentField('packets', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
<Input value={fragmentObj.length} placeholder="100-200"
onChange={(e) => setFragmentField('length', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
<Input value={fragmentObj.interval} placeholder="10-20"
onChange={(e) => setFragmentField('interval', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
<Input value={fragmentObj.maxSplit} placeholder="300-400"
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
</SettingListItem>
</div>
)}
<SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
<SubJsonFinalMaskForm
value={allSetting.subJsonFinalMask}
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
</SettingListItem>
{noisesEnabled && (
<div className="format-settings-list">
{noisesArray.map((noise, index) => (
<Card
key={index}
size="small"
className="noise-card"
title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
extra={noisesArray.length > 1 ? (
<Button
size="small"
danger
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => removeNoise(index)}
/>
) : null}
styles={{ body: { padding: 0 } }}
>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
<Select
value={noise.type}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'type', v)}
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
<Input value={noise.packet} placeholder="5-10"
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
<Input value={noise.delay} placeholder="10-20"
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
<Select
value={noise.applyTo}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</Card>
))}
<Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
{t('pages.settings.subFormats.addNoise')}
</Button>
</div>
)}
</>
),
},
{
key: '4',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: (
<>
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
),
},
{
key: '5',
key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: (
<>
@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem>
<Divider>Clash / Mihomo</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
</>
),
},
+1 -1
View File
@@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
lastNum: z.number().int().min(1),
emailPrefix: z.string(),
emailPostfix: z.string(),
quantity: z.number().int().min(1).max(100),
quantity: z.number().int().min(1).max(1000),
subId: z.string(),
group: z.string(),
comment: z.string(),
+3 -2
View File
@@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
subURI: z.string().optional(),
subJsonURI: z.string().optional(),
subClashURI: z.string().optional(),
subJsonFragment: z.string().optional(),
subJsonNoises: z.string().optional(),
subClashEnableRouting: z.boolean().optional(),
subClashRules: z.string().optional(),
subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(),
subJsonFinalMask: z.string().optional(),
timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(),
ldapHost: z.string().optional(),
+4 -4
View File
@@ -858,13 +858,13 @@ export class LanguageManager {
});
if (LanguageManager.isSupportLanguage(lang)) {
CookieManager.setCookie('lang', lang);
CookieManager.setCookie('lang', lang, 365);
} else {
CookieManager.setCookie('lang', 'en-US');
CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
} else {
CookieManager.setCookie('lang', 'en-US');
CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload();
}
@@ -875,7 +875,7 @@ export class LanguageManager {
if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US';
}
CookieManager.setCookie('lang', language);
CookieManager.setCookie('lang', language, 365);
window.location.reload();
}
+29 -13
View File
@@ -297,7 +297,7 @@ setup_ssl_certificate() {
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
rm -rf ~/.acme.sh/${domain} 2> /dev/null
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
rm -rf "$certPath" 2> /dev/null
return 1
fi
@@ -431,8 +431,8 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -451,8 +451,8 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -524,14 +524,30 @@ ssl_cert_issue() {
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}"
# detect existing certificate and reuse it if present
# detect existing certificate and reuse it only if its files are actually
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
# certs under ${domain}; a failed issuance can leave a domain entry in --list
# with no usable cert files, which must not be reused (it produces a 0-byte
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
local acmeCertDir=""
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
acmeCertDir=~/.acme.sh/${domain}_ecc
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
acmeCertDir=~/.acme.sh/${domain}
fi
if [[ -n "${acmeCertDir}" ]]; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
fi
if [[ ${cert_exists} -eq 0 ]]; then
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi
@@ -563,7 +579,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
else
@@ -617,7 +633,7 @@ ssl_cert_issue() {
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
+16 -11
View File
@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubUpdates = "10"
}
SubJsonFragment, err := s.settingService.GetSubJsonFragment()
if err != nil {
SubJsonFragment = ""
}
SubJsonNoises, err := s.settingService.GetSubJsonNoises()
if err != nil {
SubJsonNoises = ""
}
SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil {
SubJsonMux = ""
@@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonRules = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
}
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
}
SubClashRules, err := s.settingService.GetSubClashRules()
if err != nil {
SubClashRules = ""
}
SubTitle, err := s.settingService.GetSubTitle()
if err != nil {
SubTitle = ""
@@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil
+107 -12
View File
@@ -15,17 +15,13 @@ import (
type SubClashService struct {
inboundService service.InboundService
enableRouting bool
clashRules string
SubService *SubService
}
type ClashConfig struct {
Proxies []map[string]any `yaml:"proxies"`
ProxyGroups []map[string]any `yaml:"proxy-groups"`
Rules []string `yaml:"rules"`
}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
proxyNames = append(proxyNames, "DIRECT")
config := ClashConfig{
Proxies: proxies,
ProxyGroups: []map[string]any{{
config := map[string]any{
"proxies": proxies,
"proxy-groups": []map[string]any{{
"name": "PROXY",
"type": "select",
"proxies": proxyNames,
}},
Rules: []string{"MATCH,PROXY"},
"rules": []string{"MATCH,PROXY"},
}
if s.enableRouting {
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
return "", "", err
}
}
finalYAML, err := yaml.Marshal(config)
@@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src)
return dst
}
func mergeClashRulesYAML(base map[string]any, raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var custom any
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
mergeClashRules(base, linesToClashRules(raw))
return nil
}
switch typed := custom.(type) {
case []any:
mergeClashRules(base, typed)
case map[string]any:
if rules, ok := typed["rules"]; ok {
if ruleList, ok := asAnySlice(rules); ok {
mergeClashRules(base, ruleList)
}
}
default:
mergeClashRules(base, linesToClashRules(raw))
}
return nil
}
func mergeClashRules(base map[string]any, customRules []any) {
if len(customRules) == 0 {
return
}
baseRules, _ := asAnySlice(base["rules"])
if hasClashMatchRule(customRules) {
base["rules"] = customRules
return
}
merged := make([]any, 0, len(customRules)+len(baseRules))
merged = append(merged, customRules...)
merged = append(merged, baseRules...)
base["rules"] = merged
}
func asAnySlice(value any) ([]any, bool) {
switch typed := value.(type) {
case []any:
return typed, true
case []string:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
case []map[string]any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
default:
return nil, false
}
}
func hasClashMatchRule(rules []any) bool {
for _, rule := range rules {
ruleText, ok := rule.(string)
if !ok {
continue
}
parts := strings.SplitN(ruleText, ",", 2)
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
return true
}
}
return false
}
func linesToClashRules(raw string) []any {
lines := strings.Split(raw, "\n")
rules := make([]any, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rules = append(rules, line)
}
return rules
}
+5 -4
View File
@@ -62,10 +62,11 @@ func NewSUBController(
showInfo bool,
rModel string,
update string,
jsonFragment string,
jsonNoise string,
jsonMux string,
jsonRules string,
jsonFinalMask string,
clashEnableRouting bool,
clashRules string,
subTitle string,
subSupportUrl string,
subProfileUrl string,
@@ -90,8 +91,8 @@ func NewSUBController(
updateInterval: update,
subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
subClashService: NewSubClashService(sub),
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
}
a.initRouter(g)
return a
+81 -70
View File
@@ -21,7 +21,7 @@ var defaultJson string
type SubJsonService struct {
configJson map[string]any
defaultOutbounds []json_util.RawMessage
fragmentOrNoises bool
finalMask string
mux string
inboundService service.InboundService
@@ -29,7 +29,7 @@ type SubJsonService struct {
}
// NewSubJsonService creates a new JSON subscription service with the given configuration.
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any
var defaultOutbounds []json_util.RawMessage
json.Unmarshal([]byte(defaultJson), &configJson)
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
}
}
fragmentOrNoises := false
if fragment != "" || noises != "" {
fragmentOrNoises = true
defaultOutboundsSettings := map[string]any{
"domainStrategy": "UseIP",
"redirect": "",
}
if fragment != "" {
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
}
if noises != "" {
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
}
defaultDirectOutbound := map[string]any{
"protocol": "freedom",
"settings": defaultOutboundsSettings,
"tag": "direct_out",
}
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
defaultOutbounds = append(defaultOutbounds, jsonBytes)
}
if rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
return &SubJsonService{
configJson: configJson,
defaultOutbounds: defaultOutbounds,
fragmentOrNoises: fragmentOrNoises,
finalMask: finalMask,
mux: mux,
SubService: subService,
}
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
}
delete(streamSettings, "sockopt")
if s.fragmentOrNoises {
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
if s.finalMask != "" {
s.applyGlobalFinalMask(streamSettings)
}
// remove proxy protocol
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
return streamSettings
}
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
var fm map[string]any
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
return
}
merged := mergeFinalMask(streamSettings["finalmask"], fm)
if len(merged) > 0 {
streamSettings["finalmask"] = merged
}
}
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
netSettings, ok := setting.(map[string]any)
if ok {
@@ -272,6 +258,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
tlsData["fingerprint"] = fingerprint
}
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
tlsData["echConfigList"] = ech
}
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
tlsData["pinnedPeerCertSha256"] = pins
}
@@ -307,17 +296,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
outbound := Outbound{}
usersData := make([]UserVnext, 1)
usersData[0].ID = client.ID
usersData[0].Email = client.Email
usersData[0].Security = client.Security
vnextData := make([]VnextSetting, 1)
vnextData[0] = VnextSetting{
Address: inbound.Listen,
Port: inbound.Port,
Users: usersData,
}
outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy"
@@ -325,8 +303,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
security := client.Security
if security == "" {
security = "auto"
}
outbound.Settings = map[string]any{
"vnext": vnextData,
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"security": security,
"level": 8,
}
result, _ := json.MarshalIndent(outbound, "", " ")
@@ -347,24 +334,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
encryption, _ := inboundSettings["encryption"].(string)
user := map[string]any{
settings := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"level": 8,
"encryption": encryption,
"level": 8,
}
if client.Flow != "" {
user["flow"] = client.Flow
}
vnext := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"users": []any{user},
}
outbound.Settings = map[string]any{
"vnext": []any{vnext},
settings["flow"] = client.Flow
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
@@ -400,9 +380,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
outbound.Settings = map[string]any{
"servers": serverData,
settings := map[string]any{
"address": serverData[0].Address,
"port": serverData[0].Port,
"password": serverData[0].Password,
"level": 8,
}
if inbound.Protocol == model.Shadowsocks {
settings["method"] = serverData[0].Method
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
@@ -442,7 +430,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
newStream["finalmask"] = finalmask
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
}
newStream["network"] = "hysteria"
@@ -454,6 +442,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
return result
}
func mergeFinalMask(base any, extra map[string]any) map[string]any {
merged := map[string]any{}
if baseMap, ok := base.(map[string]any); ok {
for key, value := range baseMap {
switch key {
case "tcp", "udp":
if masks, ok := value.([]any); ok {
merged[key] = append([]any(nil), masks...)
}
default:
merged[key] = value
}
}
}
for key, value := range extra {
switch key {
case "tcp", "udp":
baseMasks, _ := merged[key].([]any)
extraMasks, _ := value.([]any)
if len(extraMasks) > 0 {
merged[key] = append(baseMasks, extraMasks...)
}
case "quicParams":
if _, exists := merged[key]; !exists {
merged[key] = value
}
default:
merged[key] = value
}
}
return merged
}
type Outbound struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
@@ -462,18 +485,6 @@ type Outbound struct {
Settings map[string]any `json:"settings,omitempty"`
}
type VnextSetting struct {
Address string `json:"address"`
Port int `json:"port"`
Users []UserVnext `json:"users"`
}
type UserVnext struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Security string `json:"security,omitempty"`
}
type ServerSetting struct {
Password string `json:"password"`
Level int `json:"level"`
+148
View File
@@ -0,0 +1,148 @@
package sub
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func hasDirectOutOutbound(svc *SubJsonService) bool {
for _, raw := range svc.defaultOutbounds {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
continue
}
if outbound["tag"] == "direct_out" {
return true
}
}
return false
}
func outboundSettings(t *testing.T, raw []byte) map[string]any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal outbound: %v", err)
}
settings, _ := parsed["settings"].(map[string]any)
if settings == nil {
t.Fatal("outbound has no settings")
}
return settings
}
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
}
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
}
finalmask, _ := stream["finalmask"].(map[string]any)
if finalmask == nil {
t.Fatal("streamSettings is missing finalmask")
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
}
udp, _ := finalmask["udp"].([]any)
if len(udp) != 1 {
t.Fatalf("udp masks len = %d, want 1", len(udp))
}
quic, _ := finalmask["quicParams"].(map[string]any)
if quic == nil || quic["congestion"] != "bbr" {
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
}
}
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, nil)
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]}
}`)
finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 2 {
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
}
a, _ := tcp[0].(map[string]any)
b, _ := tcp[1].(map[string]any)
if a["type"] != "sudoku" || b["type"] != "fragment" {
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
}
}
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
}
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out sockopt must never be set")
}
}
func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext")
}
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
t.Fatalf("flat vless settings wrong: %#v", settings)
}
}
func TestSubJsonServiceVmessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
client := model.Client{ID: "uuid-2"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext")
}
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
t.Fatalf("flat vmess settings wrong: %#v", settings)
}
}
func TestSubJsonServiceServerFlattened(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
if _, ok := settings["servers"]; ok {
t.Fatal("trojan outbound must not use servers array")
}
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
t.Fatalf("flat trojan settings wrong: %#v", settings)
}
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
if ssSettings["method"] != "aes-256-gcm" {
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
}
}
+10
View File
@@ -894,6 +894,11 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string)
}
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
if ech, _ := echValue.(string); ech != "" {
params["ech"] = ech
}
}
if pins, ok := pinnedSha256List(tlsSettings); ok {
params["pcs"] = strings.Join(pins, ",")
}
@@ -919,6 +924,11 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
obj["fp"], _ = fpValue.(string)
}
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
if ech, _ := echValue.(string); ech != "" {
obj["ech"] = ech
}
}
if pins, ok := pinnedSha256List(tlsSettings); ok {
obj["pcs"] = strings.Join(pins, ",")
}
+19 -1
View File
@@ -3,6 +3,8 @@ package controller
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
func parseInboundIdsQuery(raw string) []int {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
ids = append(ids, id)
}
}
return ids
}
type ClientController struct {
clientService service.ClientService
inboundService service.InboundService
@@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
+3 -2
View File
@@ -83,10 +83,11 @@ type AllSetting struct {
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
+16 -9
View File
@@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
return nil
}
// DeleteUser is idempotent: master's per-inbound Delete loop may call it
// multiple times for the same node, and "not found" on the follow-ups is
// the expected success path.
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
_, err := r.do(ctx, http.MethodPost,
"panel/api/clients/del/"+url.PathEscape(email), nil)
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return nil
}
body := map[string]any{"inboundIds": []int{id}}
_, err = r.do(ctx, http.MethodPost,
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
if err == nil {
return nil
}
@@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
return err
}
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" {
oldEmail = payload.Email
}
if _, err := r.do(ctx, http.MethodPost,
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return err
}
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
"?inboundIds=" + strconv.Itoa(id)
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
return err
}
return nil
+216
View File
@@ -0,0 +1,216 @@
package service
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray"
"github.com/op/go-logging"
)
func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
t.Helper()
db := database.GetDB()
rows := make([]xray.ClientTraffic, len(clients))
for i := range clients {
rows[i] = xray.ClientTraffic{
InboundId: inboundId,
Email: clients[i].Email,
Enable: true,
Total: clients[i].TotalGB,
ExpiryTime: clients[i].ExpiryTime,
}
}
if err := db.CreateInBatches(rows, 1000).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
}
// TestAllAPIsPostgresScale exercises every client/inbound/group service method
// reachable from the REST API at 100k/200k clients, asserting none crash on the
// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
func TestAllAPIsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
settingSvc := &SettingService{}
const userId = 1
const m = 2000
sizes := []int{50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(ib2).Error; err != nil {
t.Fatalf("create inbound2: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
run := func(name string, fn func() error) {
start := time.Now()
if err := fn(); err != nil {
t.Fatalf("%s: %v", name, err)
}
t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
}
run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
emails := make([]string, n)
for i := 0; i < n; i++ {
emails[i] = clients[i].Email
}
emailsM := emails[:m]
run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
run("ListPaged+search", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
return err
})
run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
run("UpdateByEmail", func() error {
upd := clients[n/3]
upd.Comment = "touched"
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
return err
})
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
depEmails := emails[:1000]
for _, batch := range chunkStrings(depEmails, sqlInChunk) {
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
t.Fatalf("mark depleted: %v", err)
}
}
run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
})
}
}
// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
// find one client) vs new path (UUID/subId read from the indexed clients table).
func TestGetClientTrafficByEmailABScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
const reps = 10
sizes := []int{50000, 100000, 200000}
oldImpl := func(email string) error {
tr, client, err := inboundSvc.GetClientByEmail(email)
if err != nil {
return err
}
if tr != nil && client != nil {
tr.UUID = client.ID
tr.SubId = client.SubID
}
return nil
}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
start := time.Now()
for i := 0; i < reps; i++ {
if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
t.Fatalf("new GetClientTrafficByEmail: %v", err)
}
}
newDur := time.Since(start) / reps
start = time.Now()
for i := 0; i < reps; i++ {
if err := oldImpl(targets[i%len(targets)]); err != nil {
t.Fatalf("old GetClientTrafficByEmail: %v", err)
}
}
oldDur := time.Since(start) / reps
t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
})
}
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
)
func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
t.Helper()
row := xray.ClientTraffic{
InboundId: inboundId,
Email: email,
Up: up,
Down: down,
Total: total,
ExpiryTime: expiry,
Enable: enable,
}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("create traffic %s: %v", email, err)
}
}
func trafficOf(t *testing.T, email string) xray.ClientTraffic {
t.Helper()
var row xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("load traffic %s: %v", email, err)
}
return row
}
func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
if err != nil {
t.Fatalf("BulkResetTraffic: %v", err)
}
if affected != 2 {
t.Fatalf("expected 2 affected, got %d", affected)
}
for _, e := range []string{"alice@x", "bob@x"} {
tr := trafficOf(t, e)
if tr.Up != 0 || tr.Down != 0 {
t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
}
if !tr.Enable {
t.Fatalf("%s: expected re-enabled", e)
}
}
carol := trafficOf(t, "carol@x")
if carol.Up != 7 {
t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
}
}
func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
past := time.Now().Add(-time.Hour).UnixMilli()
mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
deleted, _, err := svc.DelDepleted(inboundSvc)
if err != nil {
t.Fatalf("DelDepleted: %v", err)
}
if deleted != 2 {
t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
}
if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
t.Fatalf("bob should survive: %v", err)
}
for _, e := range []string{"alice@x", "carol@x"} {
if _, err := svc.GetRecordByEmail(nil, e); err == nil {
t.Fatalf("%s should be deleted", e)
}
}
reloaded, _ := inboundSvc.GetInbound(ib.Id)
jsonClients, _ := inboundSvc.GetClients(reloaded)
if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
}
}
func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
}
ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
if err != nil {
t.Fatalf("GetClientTrafficByEmail: %v", err)
}
if tr == nil {
t.Fatalf("expected traffic, got nil")
}
if tr.UUID != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
}
if tr.SubId != "sa" {
t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
}
}
+593 -212
View File
File diff suppressed because it is too large Load Diff
+161 -23
View File
@@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
emails = append(emails, e)
}
var extra []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
logger.Warning("enrichClientStats:", err)
var loadErr error
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
loadErr = err
break
}
extra = append(extra, page...)
}
if loadErr != nil {
logger.Warning("enrichClientStats:", loadErr)
} else {
byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra {
@@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
return count > 0, nil
}
func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
shared := make(map[string]bool, len(emails))
want := make(map[string]struct{}, len(emails))
for _, e := range emails {
e = strings.ToLower(strings.TrimSpace(e))
if e != "" {
want[e] = struct{}{}
}
}
if len(want) == 0 {
return shared, nil
}
db := database.GetDB()
var rows []string
query := fmt.Sprintf(
"SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
return nil, err
}
for _, e := range rows {
e = strings.ToLower(strings.TrimSpace(e))
if _, ok := want[e]; ok {
shared[e] = true
}
}
return shared, nil
}
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
@@ -1575,12 +1615,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
structuralChange = true
}
// Only allow the node to disable a client (cs.Enable=false), never
// to re-enable one the panel has already disabled. A stale snapshot
// from the node arriving after a central disable would otherwise
// overwrite enable=false back to true, letting the client accumulate
// far more traffic than their limit before being disabled again.
enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
last_online = %s
WHERE email = ?`,
enableExpr,
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
@@ -1742,15 +1789,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
var disabledNodeIDs []int
err = submitTrafficWrite(func() error {
var inner error
needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
return inner
})
if err == nil && len(disabledNodeIDs) > 0 {
s.restartRemoteNodesOnDisable(disabledNodeIDs)
}
return
}
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
var err error
db := database.GetDB()
tx := db.Begin()
@@ -1764,11 +1815,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
}()
err = s.addInboundTraffic(tx, inboundTraffics)
if err != nil {
return false, false, err
return false, false, nil, err
}
err = s.addClientTraffic(tx, clientTraffics)
if err != nil {
return false, false, err
return false, false, nil, err
}
needRestart0, count, err := s.autoRenewClients(tx)
@@ -1779,7 +1830,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
}
disabledClientsCount := int64(0)
needRestart1, count, err := s.disableInvalidClients(tx)
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
if err != nil {
logger.Warning("Error in disabling invalid clients:", err)
} else if count > 0 {
@@ -1793,7 +1844,7 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
} else if count > 0 {
logger.Debugf("%v inbounds disabled", count)
}
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil
}
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -1828,9 +1879,16 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
emails = append(emails, traffic.Email)
}
dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
// Match purely by email. client_traffics is email-keyed (one shared row per
// email regardless of how many inbounds the client is attached to), and these
// emails come from the local xray's report, so they always belong to a client
// attached to a local inbound. The old `inbound_id NOT IN (node inbounds)`
// filter dropped the local traffic of a client attached to both a node and the
// mother inbound whenever the node inbound happened to be attached first — its
// shared row then carried the node inbound's id (AddClientStat uses OnConflict
// DoNothing and never refreshes it), so the local poll skipped it entirely.
err = tx.Model(xray.ClientTraffic{}).
Where("email IN (?) AND inbound_id NOT IN (?)", emails,
tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
Where("email IN (?)", emails).
Find(&dbClientTraffics).Error
if err != nil {
return err
@@ -2149,7 +2207,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
return needRestart, count, err
}
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
now := time.Now().Unix() * 1000
needRestart := false
@@ -2158,10 +2216,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true).
Find(&depletedRows).Error
if err != nil {
return false, 0, err
return false, 0, nil, err
}
if len(depletedRows) == 0 {
return false, 0, nil
return false, 0, nil, nil
}
depletedEmails := make([]string, 0, len(depletedRows))
@@ -2189,7 +2247,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
WHERE clients.email IN ?
`, depletedEmails).Scan(&targets).Error
if err != nil {
return false, 0, err
return false, 0, nil, err
}
}
@@ -2236,7 +2294,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
err = result.Error
count := result.RowsAffected
if err != nil {
return needRestart, count, err
return needRestart, count, nil, err
}
if len(depletedEmails) > 0 {
@@ -2247,6 +2305,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
}
}
disabledNodeIDs := make(map[int]struct{})
for inboundID, group := range remoteByInbound {
emails := make(map[string]struct{}, len(group))
for _, t := range group {
@@ -2255,10 +2314,43 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
needRestart = true
} else {
for _, t := range group {
if t.NodeID != nil {
disabledNodeIDs[*t.NodeID] = struct{}{}
}
}
}
}
return needRestart, count, nil
nodeIDs := make([]int, 0, len(disabledNodeIDs))
for nodeID := range disabledNodeIDs {
nodeIDs = append(nodeIDs, nodeID)
}
return needRestart, count, nodeIDs, nil
}
func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
if err != nil {
logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
return
}
if !restartOnDisable {
return
}
for _, nodeID := range nodeIDs {
nodeIDCopy := nodeID
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
if rtErr != nil {
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
continue
}
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
}
}
}
// markClientsDisabledInSettings flips client.enable=false in the inbound's
@@ -2438,6 +2530,32 @@ func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
}
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
const chunk = 400
for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails))
batch := emails[start:end]
if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
}
return nil
}
func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
const chunk = 400
for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails))
if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
return err
}
}
return nil
}
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
@@ -2991,16 +3109,33 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
}
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
t, client, err := s.GetClientByEmail(email)
db := database.GetDB()
var traffics []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
if len(traffics) == 0 {
return nil, nil
}
t := traffics[0]
if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
c := rec.ToClient()
t.UUID = c.ID
t.SubId = c.SubID
return t, nil
}
t2, client, err := s.GetClientByEmail(email)
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
if t != nil && client != nil {
t.UUID = client.ID
t.SubId = client.SubID
return t, nil
if t2 != nil && client != nil {
t2.UUID = client.ID
t2.SubId = client.SubID
return t2, nil
}
return nil, nil
}
@@ -3329,6 +3464,9 @@ func (s *InboundService) MigrateDB() {
}
func (s *InboundService) GetOnlineClients() []string {
if p == nil {
return []string{}
}
return p.GetOnlineClients()
}
+39 -30
View File
@@ -10,14 +10,20 @@ import (
"github.com/mhsanaei/3x-ui/v3/xray"
)
// TestAddClientTraffic_MatchesDespiteStaleInboundId reproduces the production bug where
// client_traffics rows survive an inbound delete+recreate with a stale inbound_id (the
// shared-by-email row keeps the deleted inbound's id, and AddClientStat's OnConflict-
// DoNothing never refreshes it). The old `inbound_id IN (local inbounds)` filter dropped
// those rows, so local traffic and online status stopped updating. The fix matches by
// email and only excludes rows owned by a node inbound, so a stale local row is still
// updated while a genuine node-owned row is left untouched.
func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
// TestAddClientTraffic_MatchesByEmail covers two scenarios that share one fix:
// client_traffics is keyed by email (one shared row per email no matter how many
// inbounds the client is attached to), so local traffic must be applied by email
// regardless of which inbound_id the row happens to carry.
//
// - staleEmail: the row points at an inbound id that no longer exists (a deleted
// earlier incarnation, AddClientStat's OnConflict-DoNothing never refreshes it).
// - dualEmail: the client is attached to both a node inbound and the mother inbound,
// but the node inbound was attached first, so the shared row carries the node
// inbound's id (issue #4921). The old `inbound_id NOT IN (node inbounds)` filter
// dropped this client's local traffic, leaving it stuck at zero and offline.
//
// Both must have their local traffic counted.
func TestAddClientTraffic_MatchesByEmail(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
@@ -27,11 +33,9 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
db := database.GetDB()
const localEmail = "local-user"
const nodeEmail = "node-user"
const staleEmail = "stale-user"
const dualEmail = "dual-user"
// A local inbound exists, but the local client's traffic row points at an inbound id
// that no longer exists (a deleted earlier incarnation) — the stale-pointer scenario.
localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
if err := db.Create(localInbound).Error; err != nil {
t.Fatalf("create local inbound: %v", err)
@@ -42,39 +46,44 @@ func TestAddClientTraffic_MatchesDespiteStaleInboundId(t *testing.T) {
t.Fatalf("create node inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: localEmail, Enable: true}).Error; err != nil {
t.Fatalf("create stale local client_traffics: %v", err)
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: staleEmail, Enable: true}).Error; err != nil {
t.Fatalf("create stale client_traffics: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: nodeEmail, Enable: true}).Error; err != nil {
t.Fatalf("create node client_traffics: %v", err)
// Attached to both inbounds, but the node inbound won the OnConflict so the
// shared row is owned by the node inbound id.
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: dualEmail, Enable: true}).Error; err != nil {
t.Fatalf("create dual client_traffics: %v", err)
}
svc := InboundService{}
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
{Email: localEmail, Up: 10, Down: 20},
{Email: nodeEmail, Up: 30, Down: 40},
{Email: staleEmail, Up: 10, Down: 20},
{Email: dualEmail, Up: 30, Down: 40},
})
if err != nil {
t.Fatalf("addClientTraffic: %v", err)
}
var local xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", localEmail).First(&local).Error; err != nil {
t.Fatalf("reload local row: %v", err)
var stale xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", staleEmail).First(&stale).Error; err != nil {
t.Fatalf("reload stale row: %v", err)
}
if local.Up != 10 || local.Down != 20 {
t.Errorf("stale-pointer local row not updated: up=%d down=%d, want 10/20", local.Up, local.Down)
if stale.Up != 10 || stale.Down != 20 {
t.Errorf("stale-pointer row not updated: up=%d down=%d, want 10/20", stale.Up, stale.Down)
}
if local.LastOnline == 0 {
t.Errorf("stale-pointer local row LastOnline not set")
if stale.LastOnline == 0 {
t.Errorf("stale-pointer row LastOnline not set")
}
var node xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", nodeEmail).First(&node).Error; err != nil {
t.Fatalf("reload node row: %v", err)
var dual xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", dualEmail).First(&dual).Error; err != nil {
t.Fatalf("reload dual row: %v", err)
}
if node.Up != 0 || node.Down != 0 {
t.Errorf("node-owned row should not be touched by local traffic: up=%d down=%d, want 0/0", node.Up, node.Down)
if dual.Up != 30 || dual.Down != 40 {
t.Errorf("node-owned row not updated by local traffic (issue #4921): up=%d down=%d, want 30/40", dual.Up, dual.Down)
}
if dual.LastOnline == 0 {
t.Errorf("node-owned row LastOnline not set (client stayed offline)")
}
}
+11 -6
View File
@@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
"subJsonFragment": "",
"subJsonNoises": "",
"subClashEnableRouting": "false",
"subClashRules": "",
"subJsonMux": "",
"subJsonRules": "",
"subJsonFinalMask": "",
"datepicker": "gregorian",
"warp": "",
"nord": "",
@@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
func (s *SettingService) GetSubJsonFragment() (string, error) {
return s.getString("subJsonFragment")
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
return s.getBool("subClashEnableRouting")
}
func (s *SettingService) GetSubJsonNoises() (string, error) {
return s.getString("subJsonNoises")
func (s *SettingService) GetSubClashRules() (string, error) {
return s.getString("subClashRules")
}
func (s *SettingService) GetSubJsonMux() (string, error) {
@@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules")
}
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask")
}
func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker")
}
+431
View File
@@ -0,0 +1,431 @@
package service
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"gorm.io/gorm"
)
func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
if tx == nil {
tx = database.GetDB()
}
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
for i := range clients {
c := clients[i]
email := strings.TrimSpace(c.Email)
if email == "" {
continue
}
incoming := c.ToRecord()
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = incoming
} else {
row.Flow = incoming.Flow
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
row.TgID = incoming.TgID
row.Comment = incoming.Comment
row.Reset = incoming.Reset
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt
if err := tx.Save(row).Error; err != nil {
return err
}
if err := tx.Model(&model.ClientRecord{}).
Where("id = ?", row.Id).
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
return err
}
}
link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
if err := tx.Create(&link).Error; err != nil {
return err
}
}
return nil
}
func makeScaleClients(n int) []model.Client {
out := make([]model.Client, n)
for i := 0; i < n; i++ {
out[i] = model.Client{
ID: uuid.NewString(),
Email: fmt.Sprintf("user-%07d@scale", i),
SubID: fmt.Sprintf("sub-%07d", i),
Enable: true,
}
}
return out
}
func TestSyncInboundPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{
Tag: fmt.Sprintf("scale-%d", n),
Enable: true,
Port: 40000,
Protocol: model.VLESS,
Settings: clientsSettings(t, clients),
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
start := time.Now()
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
seed := time.Since(start)
clients[n/2].Enable = !clients[n/2].Enable
start = time.Now()
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("toggle SyncInbound (new): %v", err)
}
toggleNew := time.Since(start)
start = time.Now()
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("noop SyncInbound (new): %v", err)
}
noopNew := time.Since(start)
toggleOld := time.Duration(0)
if n <= 10000 {
clients[n/2].Enable = !clients[n/2].Enable
start = time.Now()
if err := syncInboundOld(db, ib.Id, clients); err != nil {
t.Fatalf("toggle SyncInbound (old): %v", err)
}
toggleOld = time.Since(start)
}
var linkCount, recCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
db.Model(&model.ClientRecord{}).Count(&recCount)
if int(linkCount) != n || int(recCount) != n {
t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
}
oldStr, speedup := "skipped", ""
if toggleOld > 0 {
oldStr = toggleOld.Round(time.Millisecond).String()
speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
}
t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
noopNew.Round(time.Millisecond), oldStr, speedup)
})
}
}
func maxDur(d, floor time.Duration) time.Duration {
if d < floor {
return floor
}
return d
}
func TestAddDelClientPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
sizes := []int{5000, 20000, 50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{
Tag: fmt.Sprintf("adddel-%d", n),
Enable: true,
Port: 40000,
Protocol: model.VLESS,
Settings: clientsSettings(t, clients),
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
newC := model.Client{
ID: uuid.NewString(),
Email: "added-client@scale",
SubID: "added-sub",
Enable: true,
}
addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
start := time.Now()
if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
addDur := time.Since(start)
delId := clients[n/2].ID
start = time.Now()
if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
t.Fatalf("DelInboundClient: %v", err)
}
delDur := time.Since(start)
var recCount, linkCount int64
db.Model(&model.ClientRecord{}).Count(&recCount)
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
})
}
}
func TestGroupAndListPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
sizes := []int{5000, 100000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
db.Exec("ANALYZE")
emails := make([]string, n)
for i := 0; i < n; i++ {
emails[i] = clients[i].Email
}
start := time.Now()
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
t.Fatalf("AddToGroup: %v", err)
}
addDur := time.Since(start)
start = time.Now()
if _, err := svc.RemoveFromGroup(emails); err != nil {
t.Fatalf("RemoveFromGroup: %v", err)
}
rmDur := time.Since(start)
start = time.Now()
list, err := svc.List()
if err != nil {
t.Fatalf("List: %v", err)
}
listDur := time.Since(start)
if len(list) != n {
t.Fatalf("List returned %d, want %d", len(list), n)
}
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
})
}
}
func TestDelAllClientsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
sizes := []int{5000, 50000, 100000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
emails, err := inboundSvc.EmailsByInbound(ib.Id)
if err != nil {
t.Fatalf("EmailsByInbound: %v", err)
}
start := time.Now()
res, _, err := svc.BulkDelete(inboundSvc, emails, false)
if err != nil {
t.Fatalf("BulkDelete: %v", err)
}
dur := time.Since(start)
var recCount, linkCount int64
db.Model(&model.ClientRecord{}).Count(&recCount)
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
if recCount != 0 || linkCount != 0 {
t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
}
t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
})
}
}
func TestBulkOpsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
sizes := []int{5000, 20000, 50000, 100000}
const m = 2000
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(ib2).Error; err != nil {
t.Fatalf("create inbound2: %v", err)
}
emailsM := make([]string, m)
for i := 0; i < m; i++ {
emailsM[i] = clients[i].Email
}
t0 := time.Now()
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
t.Fatalf("BulkAdjust: %v", err)
}
adjustDur := time.Since(t0)
t0 = time.Now()
if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
t.Fatalf("BulkAttach: %v", err)
}
attachDur := time.Since(t0)
t0 = time.Now()
if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
t.Fatalf("BulkDetach: %v", err)
}
detachDur := time.Since(t0)
payloads := make([]ClientCreatePayload, m)
for i := 0; i < m; i++ {
payloads[i] = ClientCreatePayload{
Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
InboundIds: []int{ib.Id},
}
}
t0 = time.Now()
if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
t.Fatalf("BulkCreate: %v", err)
}
createDur := time.Since(t0)
t0 = time.Now()
if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
t.Fatalf("BulkDelete: %v", err)
}
deleteDur := time.Since(t0)
t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
})
}
}
+20
View File
@@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
if !t.isCommandForCurrentBot(&message) {
return nil
}
// Use goroutine with worker pool for concurrent command processing
go func() {
messageWorkerPool <- struct{}{} // Acquire worker
@@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
}
}
func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
return isCommandForBot(message.Text, botUsername())
}
func botUsername() string {
if bot == nil {
return ""
}
return bot.Username()
}
func isCommandForBot(text string, username string) bool {
_, commandUsername, _ := tu.ParseCommand(text)
return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
}
// sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage {
+24
View File
@@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
t.Fatal("Dial must be nil when no proxy is configured")
}
}
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
if !isCommandForBot("/status", "panel_bot") {
t.Fatal("untargeted commands must remain accepted")
}
}
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
t.Fatal("commands targeted to this bot must be accepted")
}
}
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
if isCommandForBot("/status@other_bot", "panel_bot") {
t.Fatal("commands targeted to another bot must be ignored")
}
}
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
if !isCommandForBot("/status@panel_bot", "") {
t.Fatal("commands must remain accepted when the current bot username is unavailable")
}
}
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
"subRoutingRules": "قواعد التوجيه",
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
"subClashEnableRouting": "تفعيل التوجيه",
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
"subClashRoutingRules": "قواعد التوجيه العامة",
"subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
"subListen": "IP الاستماع",
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
"subPort": "بورت الاستماع",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "حد IP الافتراضي"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
"packets": "الحزم",
"length": "الطول",
"interval": "الفاصل",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules",
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
"subClashEnableRouting": "Enable routing",
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
"subClashRoutingRules": "Global routing rules",
"subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
"subListen": "Listen IP",
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
"subPort": "Listen Port",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Default IP limit"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
"packets": "Packets",
"length": "Length",
"interval": "Interval",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
"subRoutingRules": "Reglas de enrutamiento",
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
"subClashEnableRouting": "Habilitar enrutamiento",
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
"subClashRoutingRules": "Reglas globales de enrutamiento",
"subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
"subListen": "Listening IP",
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
"subPort": "Puerto de Suscripción",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Límite IP por defecto"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
"packets": "Paquetes",
"length": "Longitud",
"interval": "Intervalo",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
"subRoutingRules": "قوانین مسیریابی",
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
"subClashEnableRouting": "فعال‌سازی مسیریابی",
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
"subClashRoutingRules": "قوانین مسیریابی سراسری",
"subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده می‌شوند.",
"subListen": "آدرس آی‌پی",
"subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
"subPort": "پورت",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "محدودیت IP پیش‌فرض"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "ماسک‌های finalmask ایکس‌ری (TCP/UDP) و تنظیمات QUIC که داخل همه‌ی stream های اشتراک JSON تزریق می‌شوند. به نسخه‌ی جدید هسته‌ی xray در کلاینت نیاز دارد.",
"packets": "بسته‌ها",
"length": "طول",
"interval": "بازه",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
"subRoutingRules": "Aturan routing",
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
"subClashEnableRouting": "Aktifkan routing",
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
"subClashRoutingRules": "Aturan routing global",
"subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
"subListen": "IP Pendengar",
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
"subPort": "Port Pendengar",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Batas IP default"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
"packets": "Paket",
"length": "Panjang",
"interval": "Interval",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
"subRoutingRules": "ルーティングルール",
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
"subClashEnableRouting": "ルーティングを有効化",
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
"subClashRoutingRules": "グローバルルーティングルール",
"subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
"subListen": "監視IP",
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
"subPort": "監視ポート",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "デフォルト IP 制限"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
"packets": "パケット",
"length": "長さ",
"interval": "間隔",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
"subRoutingRules": "Regras de roteamento",
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
"subClashEnableRouting": "Ativar roteamento",
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
"subClashRoutingRules": "Regras globais de roteamento",
"subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
"subListen": "IP de Escuta",
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
"subPort": "Porta de Escuta",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Limite de IP padrão"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
"packets": "Pacotes",
"length": "Comprimento",
"interval": "Intervalo",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
"subRoutingRules": "Правила маршрутизации",
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
"subClashEnableRouting": "Включить маршрутизацию",
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации",
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
"subListen": "Прослушивание IP",
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
"subPort": "Порт подписки",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Лимит IP по умолчанию"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
"packets": "Пакеты",
"length": "Длина",
"interval": "Интервал",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
"subRoutingRules": "Yönlendirme kuralları",
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
"subClashEnableRouting": "Yönlendirmeyi etkinleştir",
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekle.",
"subClashRoutingRules": "Genel yönlendirme kuralları",
"subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen Clash/Mihomo kuralları.",
"subListen": "Dinleme IP",
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
"subPort": "Dinleme Portu",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Varsayılan IP limiti"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Her JSON abonelik akışına eklenen xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir xray istemcisi gerektirir.",
"packets": "Paketler",
"length": "Uzunluk",
"interval": "Aralık",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
"subRoutingRules": "Правила маршрутизації",
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
"subClashEnableRouting": "Увімкнути маршрутизацію",
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
"subClashRoutingRules": "Глобальні правила маршрутизації",
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
"subListen": "Слухати IP",
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
"subPort": "Слухати порт",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Ліміт IP за замовч."
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
"packets": "Пакети",
"length": "Довжина",
"interval": "Інтервал",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subRoutingRules": "Quy tắc định tuyến",
"subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
"subClashEnableRouting": "Bật định tuyến",
"subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
"subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
"subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
"subListen": "Listening IP",
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
"subPort": "Cổng gói đăng ký",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "Giới hạn IP mặc định"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
"packets": "Gói",
"length": "Độ dài",
"interval": "Khoảng",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
"subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
"subClashEnableRouting": "启用路由",
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
"subClashRoutingRules": "全局路由规则",
"subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
"subListen": "监听 IP",
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
"subPort": "监听端口",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "默认 IP 限制"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
"packets": "数据包",
"length": "长度",
"interval": "间隔",
+6
View File
@@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
"subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
"subClashEnableRouting": "啟用路由",
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
"subClashRoutingRules": "全域路由規則",
"subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
"subListen": "監聽 IP",
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
"subPort": "監聽埠",
@@ -1070,6 +1074,8 @@
"defaultIpLimit": "預設 IP 限制"
},
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
"packets": "封包",
"length": "長度",
"interval": "間隔",
+53 -14
View File
@@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
echo "Panel paths set for domain: $domain"
echo " - Certificate File: $webCertFile"
echo " - Private Key File: $webKeyFile"
# Register the acme.sh install-cert hook so auto-renewal copies the
# renewed cert to these paths and reloads the panel. Without it acme.sh
# renews but never updates /root/cert, silently serving a stale cert.
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
~/.acme.sh/acme.sh --installcert -d "${domain}" \
--key-file "${webKeyFile}" \
--fullchain-file "${webCertFile}" \
--reloadcmd "x-ui restart" 2>&1 || true
echo "Registered acme.sh auto-renewal hook for ${domain}."
fi
restart
else
echo "Certificate or private key not found for domain: $domain."
@@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
LOGE "Failed to issue certificate for IP: ${server_ip}"
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
else
@@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
LOGE "Certificate files not found after installation"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null
return 1
fi
@@ -1576,14 +1586,30 @@ ssl_cert_issue() {
LOGD "Your domain is: ${domain}, checking it..."
SSL_ISSUED_DOMAIN="${domain}"
# detect existing certificate and reuse it if present
# detect existing certificate and reuse it only if its files are actually
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
# certs under ${domain}; a failed issuance can leave a domain entry in --list
# with no usable cert files, which must not be reused (it produces a 0-byte
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
LOGI "Existing certificate found for ${domain}, will reuse it."
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
else
local acmeCertDir=""
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
acmeCertDir=~/.acme.sh/${domain}_ecc
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
acmeCertDir=~/.acme.sh/${domain}
fi
if [[ -n "${acmeCertDir}" ]]; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
LOGI "Existing certificate found for ${domain}, will reuse it."
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
else
LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
fi
if [[ ${cert_exists} -eq 0 ]]; then
LOGI "Your domain is ready for issuing certificates now..."
fi
@@ -1611,7 +1637,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs."
rm -rf ~/.acme.sh/${domain}
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
exit 1
else
LOGE "Issuing certificate succeeded, installing certificates..."
@@ -1664,7 +1690,7 @@ ssl_cert_issue() {
else
LOGE "Installing certificate failed, exiting."
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
exit 1
fi
@@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex =
EOF
# Ports to exempt from the ban so an over-limit proxy client can never lock
# the administrator out of SSH or the panel. The ban still covers every other
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
# added later without regenerating these files.
local ssh_ports
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
local panel_port
panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
local exempt_ports="${ssh_ports}"
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
[INCLUDES]
before = iptables-allports.conf
@@ -2263,16 +2301,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
[Init]
name = default
protocol = tcp
chain = INPUT
exemptports = ${exempt_ports}
EOF
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"