Compare commits

...

13 Commits

Author SHA1 Message Date
7fe082a7f1 fix(nodes): stop multi-attached client traffic inflating across node inbounds
Xray counts client traffic globally per email, so a client attached to
several of a node's inbounds has its single shared counter copied onto
every inbound by the node's enriched inbound list. When those copies
diverge (legacy per-inbound rows surviving a v3.2.x->v3.3.x upgrade, or
any drift) the per-inbound delta loop read the lower sibling as a
node-counter reset and re-added its full value, inflating the client far
past real usage (#5274).

Fold each email to its per-field node-wide max before the delta loop so
every occurrence is equal: the per-email baseline dedup then holds and
the reset clamp never misfires.
2026-06-15 19:31:57 +02:00
f7ffe89813 fix(outbound): preserve non-ASCII characters in imported subscription tags (#5354)
SlugRemark stripped every non-ASCII character, so tags generated from
remarks like Cyrillic names collapsed to just their digits, making
imported outbounds hard to identify. Keep Unicode letters and digits in
the slug regex while still collapsing punctuation into dashes.
2026-06-15 19:16:57 +02:00
c1fbfd0510 fix(outbound): parse xmux from imported share links (#5353)
The inbound link generator bundles xmux and downloadSettings as nested
objects inside the `extra=` JSON blob, but the outbound link parser only
pulled scalar fields and headers from it, silently dropping xmux on
import. Extract the nested objects too so they round-trip into the
outbound XMUX sub-form.
2026-06-15 19:12:47 +02:00
cbb21b7575 fix(nodes): propagate single-client deletion to remote nodes (#5352)
Deleting a client attached to a remote-node inbound could silently fail
to reach the node, so the node's next traffic snapshot resurrected the
client once the 90s delete tombstone expired.

Two paths in the single-client delete (Delete -> DelInboundClientByEmail):

- A disabled client was skipped entirely: the node-propagation and
  mark-dirty block sat behind the client's enable flag (needApiDel), so a
  disabled client on a node never detached and never marked the node
  dirty. The bulk and multi-client delete paths already handle the node
  case independently of enable state; mirror that structure here.

- Remote.DeleteUser returned nil when resolveRemoteID failed, hiding the
  failure from the caller so the node was never marked dirty. Surface the
  error like AddClient/UpdateUser do, so the caller marks the node dirty
  and the next reconcile converges.

Add a regression test asserting a disabled node client's deletion marks
the node dirty.
2026-06-15 17:56:12 +02:00
cf5f37e409 fix(iplimit): ban UDP as well as TCP in fail2ban action (#5350)
The generated 3x-ipl fail2ban action only matched -p tcp, so UDP-based
inbounds (Hysteria2, TUIC, WireGuard) from a banned IP kept working,
bypassing IP-limit enforcement. Drop the protocol qualifier from the
chain jump and ban both tcp and udp, keeping the SSH/panel port exemption.
2026-06-15 17:34:23 +02:00
0d87bb8b4b fix(inbounds): flag conflicts with the reserved Xray API port (#5304)
The internal API inbound (tag "api", default port 62789 on 127.0.0.1) lives in
the Xray config template, not the inbounds table, so checkPortConflict never
caught a local user inbound reusing it — Xray then bound the port twice and
served requests unpredictably. Now reject a local TCP inbound whose listen
overlaps loopback on the reserved API port, read from the template (fallback
62789). Nodes are unaffected since they run their own Xray.
2026-06-15 17:21:06 +02:00
f00512d12e fix(frontend): TProxy schema, VLESS+XHTTP flow links, clearable Jalali date picker (#5339, #5322, #5313)
- #5339: accept transportless tunnel/TProxy streamSettings that carry no
  `security` key by adding a transportless branch to SecuritySettingsSchema,
  mirroring NetworkSettingsSchema. Fixes "streamSettings.security Invalid input".
- #5322: emit XTLS Vision `flow` in panel VLESS share links for XHTTP+vlessenc
  via the shared canEnableTlsFlow predicate, so panel links match the form and
  the subscription output.
- #5313: give the Jalali expiry date picker a working clear (X) button
  (remount on clear, since the library reads `value` only on mount) and a blank
  placeholder instead of the library's hardcoded Persian text.
2026-06-15 17:20:54 +02:00
cdaf5f80db fix(inbound): strip XHTTP client-only fields from xray config, keep for subscriptions (#5349)
Inbound XMUX and other client-side xHTTP knobs were written into
bin/config.json even though xray-core's server listener ignores them.
Strip them in GenXrayInboundConfig while leaving the DB row intact so
buildXhttpExtra still pushes defaults to clients via share links.
2026-06-15 16:35:43 +02:00
ac8cb505d1 fix(subscriptions): avoid shared mutable state during generation (#5270)
* fix(subscriptions): avoid shared mutable state during generation

* fix(subscriptions): serve external-link-only subs in JSON/Clash; load remark settings per request

The ForRequest refactor added an early `len(inbounds) == 0` return to
GetJson/GetClash that fired before external links were fetched, so a
subscription whose only entries are external links (or whose inbounds are
all disabled) rendered empty in the JSON and Clash formats. Drop the
premature check — the existing inbounds+externalLinks empty guard already
covers the truly-empty case.

Also load datepicker/emailInRemark in PrepareForRequest rather than only in
getSubs, so JSON and Clash remarks honor these settings instead of seeing
the zero values (emailInRemark previously depended on the shared-state leak
this PR fixes).

Add a regression test covering an external-link-only sub across both formats.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 16:23:47 +02:00
71616b7cf2 feat(web): cap request body size on state-changing routes (#5271)
* feat(web): cap request body size on state-changing routes

* fix(web): exempt importDB from request body size cap

The 10 MiB body cap was applied globally, which would break database
restore (/panel/api/server/importDB) on any panel whose SQLite backup
exceeds the limit. Make MaxBodyBytes accept exempt path suffixes and
pass importDB through uncapped; the cap still covers all other
state-changing routes. Add a test for the skip-suffix behavior.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 16:04:16 +02:00
628406117e fix(nodes): sync "start after first connect" expiry so un-activated nodes do not reset it (#5319)
* fix(nodes): stop un-activated nodes from resetting "start after first connect" expiry

In a multi-node setup a client is attached to inbounds on several nodes, but
its `client_traffics` row is shared per-email (the column is `gorm:"unique"`).
With "start expiry after first connect", the expiry is stored as a negative
duration and each node converts it to an absolute deadline (now+duration) the
first time the client connects *there*.

The master's per-node traffic merge wrote `expiry_time = ?` unconditionally for
every node sync. So a node where the client never connected keeps reporting the
un-activated negative duration and clobbers the absolute deadline that the node
where the client *did* connect had already activated — last writer wins. The
shared row flip-flops and usually lands back on the negative value, so the main
panel shows the timer "not started" while the active node counts down, and the
subscription (which reads this row and recomputes negative as now+duration on
every fetch) reports a perpetually-resetting, wrong expiry and usage.

Guard the merge so an un-activated (<= 0) value reported by a node can never
reset an already-activated absolute deadline. A positive node value is still
adopted, so a node that legitimately moves the deadline forward (traffic reset /
auto-renew) still propagates. The rule lives in both the SQL CASE used by the
merge and a small `mergeActivationExpiry` helper (kept in lockstep) that the
structural-change check reuses so the guard does not trigger spurious config
re-pushes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(nodes): cast expiry merge params to BIGINT for Postgres

The "start after first connect" merge guard introduced the comparison
`? <= 0` in the client_traffics expiry_time CASE. There Postgres infers
the parameter type as int4 from the literal 0, so binding a real expiry
value — a negative start-after-connect duration or a positive absolute
deadline (~1.7e12 ms) — overflows int4 and the whole setRemoteTrafficLocked
transaction fails, breaking node traffic and expiry sync on Postgres.
SQLite (dynamic typing) was unaffected.

Wrap both params in CAST(? AS BIGINT) (portable across SQLite and
Postgres) so the parameter is typed bigint, matching the explicit casts
the sibling GreatestExpr/ClientTrafficEnableMergeExpr helpers already use.

Verified against Postgres 16: TestNodeFirstConnectExpiry_NotClobbered
failed before this change and passes after; SQLite suite unchanged.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 15:46:19 +02:00
7605902324 Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345)
* test(audit): add gremlins/rapid/coverage tooling + AUDIT.md scaffold

* test(audit): hygiene sweep (race-clean except logger global; Finding #2) + smell inventory

* test(audit): cover untested error/edge branches (TLS proxy+pin, migration tag cleanup=Finding #1)

* test(audit): strengthen internal/sub link tests (dedup key, TLS/Reality mapping, clash well-formedness)

* test(audit): property (rapid) + fuzz tests for joinHostPort/userinfo/pin/ParseLink

* test(audit): tighten frontend subSortIndex rejection assertions + wire coverage

* ci(audit): add shuffle gate + non-blocking race job (Finding #2) + fuzz-smoke; document mutation policy

* chore(audit): gitignore frontend coverage output

* test(audit): exhaustive whole-repo pass — strengthen 5 weak/fake tests (netproxy, CSP, modal per-protocol loops, schema coercions)

* docs(contributing): add Testing section (conventions, race/shuffle, fuzz, mutation policy); drop AUDIT.md ledger

* fix(logger,migration): guard logBuffer with mutex; execute legacy tag cleanup (tx.Exec); make CI race gate blocking

* ci(mutation): add nightly scoped gremlins workflow (informational artifacts)

* test(audit): strengthen runtime tests — baseURL scheme/port bounds, isNonEmptySlice, trafficReset

* test(audit): strengthen clash tests — reality field mapping + tcp-header validation

* test(audit): runtime — egress-proxy + content-type tests; drop redundant bp=='' branch

* test(audit): strengthen link parser/helper tests (defaultPort, splitComma, base64, canonicalQuery, tls/reality/transport mapping)

* test(audit): strengthen sub/xray/common/netsafe/mtproto/config/middleware tests (kill surviving mutants)

* test(audit): raise timeout on protocol-iteration modal tests (heavy re-renders, slow on CI)

* fix(logger): GetLogs returns at most c entries (off-by-one fix; addresses PR review)

* perf(logger): snapshot logBuffer under lock so GetLogs doesn't block logging; clarify fuzz-seed docs (addresses PR review)
2026-06-15 15:17:03 +02:00
b5872af279 Frontend operation button size optimization (#5343)
* Update ClientsPage.tsx

* Update GroupsPage.tsx

* Update RowActions.tsx

* Update NodeList.tsx

* Update BalancersTab.tsx
2026-06-15 14:26:51 +02:00
72 changed files with 3660 additions and 450 deletions

View File

@ -35,7 +35,7 @@ jobs:
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
codegen:
runs-on: ubuntu-latest
@ -69,6 +69,39 @@ jobs:
- name: Run govulncheck
run: govulncheck ./...
# Race + shuffle hygiene gate: data races and order-dependent tests fail the build.
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Race + shuffle
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Fuzz critical parsers (smoke)
run: |
go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
frontend:
runs-on: ubuntu-latest
steps:

62
.github/workflows/mutation.yml vendored Normal file
View File

@ -0,0 +1,62 @@
name: Mutation testing
# Mutation testing (gremlins) is the objective check for "fake" tests: it mutates the
# source and a surviving (LIVED) mutant means no test caught the change. It is SLOW, so it
# runs nightly / on demand and scoped per package — never per-commit. It is informational:
# no thresholds are set, so it reports survivors as artifacts without failing the build.
on:
schedule:
- cron: "0 3 * * *" # 03:00 UTC daily
workflow_dispatch:
permissions:
contents: read
jobs:
gremlins:
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: sub
path: ./internal/sub/
exclude: ""
- name: runtime
path: ./internal/web/runtime/
exclude: ""
- name: link
path: ./internal/util/link/
exclude: ""
- name: database
path: ./internal/database/
exclude: 'dump_sqlite\.go'
- name: service
path: ./internal/web/service/
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Install gremlins
run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0
- name: Run gremlins on ${{ matrix.name }}
run: |
OUT="mutation-${{ matrix.name }}.json"
if [ -n "${{ matrix.exclude }}" ]; then
gremlins unleash -E '${{ matrix.exclude }}' -o "$OUT" ${{ matrix.path }}
else
gremlins unleash -o "$OUT" ${{ matrix.path }}
fi
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-${{ matrix.name }}
path: mutation-${{ matrix.name }}.json
if-no-files-found: ignore

View File

@ -236,6 +236,49 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `internal/config/` | Environment-variable helpers, paths, defaults |
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
## Testing
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
### Go conventions
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
### Running
| Goal | Command |
|------|---------|
| Standard run | `go test ./...` |
| Hygiene — data races + order-dependence | `go test -race -shuffle=on -count=1 ./...` (`-race` needs the C compiler from Prerequisites) |
| Coverage gaps | `go test -coverprofile=cov.out ./<pkg>/... && go tool cover -func=cov.out` |
| Fuzz a parser briefly | `go test -run '^$' -fuzz 'FuzzName$' -fuzztime=30s ./<pkg>/...` |
Frontend: `cd frontend && npm run test` (vitest), or `npm run test -- --coverage`.
### Property and fuzz tests
Input-heavy or pure logic (link builders, parsers, decoders) is also covered by **property tests** (`pgregory.net/rapid`) and **native fuzz targets** (`go test -fuzz`). A fuzz target's **seed corpus** (its inline `f.Add` cases plus any `testdata/fuzz` entries) runs as ordinary subtests under a plain `go test` — no `-fuzz` flag needed — so CI's normal test job exercises the seeds; the time-boxed *fuzzing* exploration (`-fuzz=...`) runs separately as the `fuzz-smoke` job.
### Mutation testing (optional, manual)
[gremlins](https://github.com/go-gremlins/gremlins) checks whether tests actually fail when the code is mutated — a surviving (`LIVED`) mutant means a weak test. It is **slow**, so run it **scoped per package**, never repo-wide or per-commit:
```bash
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
gremlins unleash ./internal/sub/
gremlins unleash -E 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' ./internal/web/service/
```
Treat each survivor as one of: a weak test (strengthen it), dead code (remove it), or an equivalent mutant (unkillable — leave it). Don't write a test purely to kill a mutant if it doesn't reflect real behavior.
CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml` — scoped per package, results uploaded as artifacts. It is **informational**, not a gate (no thresholds), so check the reports when hardening a suite rather than waiting for a red build.
### CI
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
## Sending a pull request
1. Branch off `main` (e.g. `feat/short-description`).

View File

@ -44,23 +44,24 @@ before = iptables-allports.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -j f2b-<name>
<iptables> -I <chain> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actionstop = <iptables> -D <chain> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -I f2b-<name> 1 -s <ip> -p udp -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> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -D f2b-<name> -s <ip> -p udp -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

1
frontend/.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules/
.vite/
*.log
*.tsbuildinfo
coverage/

View File

@ -37,6 +37,7 @@
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
@ -456,6 +457,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@ -3364,6 +3375,37 @@
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.8",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.8",
"vitest": "4.1.8"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
@ -3647,6 +3689,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -4941,6 +5002,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@ -5067,6 +5138,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@ -5320,6 +5398,45 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-file-download": {
"version": "0.4.12",
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
@ -5832,6 +5949,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -7033,6 +7191,19 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/swagger-client": {
"version": "3.37.4",
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz",

View File

@ -50,6 +50,7 @@
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
@ -73,4 +74,4 @@
"core-js-pure": false,
"tree-sitter-json": false
}
}
}

View File

@ -1,5 +1,6 @@
.jdp-wrap {
width: 100%;
position: relative;
}
.jdp-wrap > * {
@ -33,3 +34,38 @@
pointer-events: none;
opacity: 0.6;
}
/* persian-calendar-suite has no allowClear; overlay our own clear button so
the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */
.jdp-wrap .jdp-clear {
position: absolute;
top: 50%;
right: 11px;
transform: translateY(-50%);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
line-height: 1;
color: rgba(0, 0, 0, 0.25);
transition: color 0.2s;
}
.jdp-wrap .jdp-clear:hover {
color: rgba(0, 0, 0, 0.45);
}
.jdp-dark .jdp-clear {
color: rgba(255, 255, 255, 0.30);
}
.jdp-dark .jdp-clear:hover,
.jdp-ultra .jdp-clear:hover {
color: rgba(255, 255, 255, 0.45);
}

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
@ -54,6 +55,10 @@ export default function DateTimePicker({
}: DateTimePickerProps) {
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null);
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
// remounting via key is the only way to reflect an externally cleared value.
const [clearNonce, setClearNonce] = useState(0);
const persianTheme = useMemo(() => {
if (isUltra) return ULTRA_DARK_THEME;
@ -61,10 +66,21 @@ export default function DateTimePicker({
return LIGHT_THEME;
}, [isDark, isUltra]);
// The library hardcodes a Persian placeholder and exposes no working prop to
// override it, so clear it (or apply the caller's) on the input directly so
// the empty field shows no leftover Persian text. No dep array: re-apply
// after every render (incl. clear-remounts).
useEffect(() => {
if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder;
});
if (datepicker === 'jalalian') {
return (
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<PersianDateTimePicker
key={clearNonce}
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
if (next == null || next === '') {
@ -80,6 +96,21 @@ export default function DateTimePicker({
rtlCalendar
theme={persianTheme}
/>
{value && !disabled && (
<button
type="button"
className="jdp-clear"
aria-label="clear"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onChange(null);
setClearNonce((n) => n + 1);
}}
>
<CloseCircleFilled />
</button>
)}
</div>
);
}

View File

@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@ -186,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
const stream = inbound.streamSettings;
if (!stream) return '';
const tls = forceTls === 'same' ? stream.security : forceTls;
const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
const obj: Record<string, unknown> = {
v: '2',
ps: remark,
@ -382,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
@ -402,12 +402,23 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
} else {
params.set('security', 'none');
}
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
// vlessFlowAllowed and the form's flow-field gating so panel link, share
// link and subscription agree.
if (flow.length > 0 && canEnableTlsFlow({
protocol: inbound.protocol,
settings: inbound.settings,
streamSettings: stream,
})) {
params.set('flow', flow);
}
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);

View File

@ -9,8 +9,9 @@ import { Base64 } from '@/utils';
// fields the common vmess:// / vless:// links carry as query params.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux, reality shortIds,
// padding obfs key/header/placement, hysteria udphop are still left
// present in either the JSON or URL params. xmux and downloadSettings
// round-trip through the `extra` JSON blob. reality shortIds, padding
// obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — the legacy Outbound.fromLink
// was ~250 lines of dense edge-case handling we don't need to
// replicate verbatim for the common phone-to-panel workflow.
@ -33,6 +34,10 @@ const XHTTP_NUMBER_KEYS = [
const XHTTP_BOOL_KEYS = [
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
] as const;
// Nested objects the inbound link bundles into the `extra` JSON blob
// (and vmess JSON carries inline). The outbound form adapter expands
// xmux into the XMUX sub-form (enableXmux) on load.
const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
function asBool(s: string | null): boolean | undefined {
if (s === null) return undefined;
@ -88,6 +93,10 @@ function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): vo
for (const k of XHTTP_BOOL_KEYS) {
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
}
for (const k of XHTTP_OBJECT_KEYS) {
const v = json[k];
if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
}
}
function buildStream(network: string, security: string): Raw {

View File

@ -619,19 +619,19 @@ export default function ClientsPage() {
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),

View File

@ -407,10 +407,10 @@ export default function GroupsPage() {
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
</Tooltip>
</Space>
),

View File

@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
</div>
);

View File

@ -241,18 +241,18 @@ export default function NodeList({
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),

View File

@ -265,7 +265,7 @@ export default function BalancersTab({
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag">
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
{sel}
</Tag>
)),

View File

@ -15,9 +15,18 @@ export type Security = z.infer<typeof SecuritySchema>;
// 'none' neither key appears. The Xray panel's StreamSettings class emits
// `undefined` for the inactive branch which strips the key during JSON
// serialization, so this DU faithfully describes what's on disk.
export const SecuritySettingsSchema = z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
//
// Tunnel (dokodemo-door / TProxy) is transportless and may carry only
// `sockopt` — its streamSettings has no `security` key at all. The
// transportless branch accepts that shape, mirroring NetworkSettingsSchema's
// `network: never().optional()` handling. A present-but-invalid security
// still fails both branches so a typo can't slip through.
export const SecuritySettingsSchema = z.union([
z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
]),
z.object({ security: z.never().optional() }),
]);
export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;

View File

@ -1,161 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InboundFormModal > field structure is stable for every protocol > http 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > hysteria 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > mixed 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > shadowsocks 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > trojan 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > tun 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > tunnel 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > vless 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > vmess 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;
exports[`InboundFormModal > field structure is stable for every protocol > wireguard 1`] = `
[
"Enabled",
"Remark",
"Protocol",
"Address",
"Share address strategy",
"Subscription sort order",
"Port",
"Total Flow",
"Traffic Reset",
"Duration",
"Enabled",
]
`;

View File

@ -1,141 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`OutboundFormModal > field structure is stable for every protocol > blackhole 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > dns 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > freedom 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > hysteria 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > shadowsocks 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > socks 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > trojan 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > vless 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > vmess 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;
exports[`OutboundFormModal > field structure is stable for every protocol > wireguard 1`] = `
[
"Protocol",
"Tag",
"Send Through",
"Address",
"Port",
"ID",
"Encryption",
"Reverse tag",
"Mux",
]
`;

View File

@ -289,8 +289,18 @@ describe('subSortIndex', () => {
});
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
// Reject for the RIGHT reason: the issue must be about subSortIndex, not some
// unrelated field — otherwise a schema that rejects everything would pass.
const nonInt = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 });
expect(nonInt.success).toBe(false);
if (!nonInt.success) expect(nonInt.error.issues[0]?.path).toContain('subSortIndex');
const belowMin = InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 });
expect(belowMin.success).toBe(false);
if (!belowMin.success) expect(belowMin.error.issues[0]?.path).toContain('subSortIndex');
// A valid integer >= 1 must pass (guards against a mutant rejecting all values).
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 5 }).success).toBe(true);
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
});
});

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { screen, act } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
@ -31,15 +31,30 @@ describe('InboundFormModal', () => {
expect(fieldLabels().length).toBeGreaterThan(0);
});
it('field structure is stable for every protocol', () => {
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal();
const protocols = listSelectOptions('protocol');
expect(protocols.length).toBeGreaterThan(3);
const labelsByProto: Record<string, string[]> = {};
for (const proto of protocols) {
chooseSelectOption('protocol', proto);
expect(fieldLabels()).toMatchSnapshot(proto);
// Flush antd Form.useWatch('protocol') before reading — without it every iteration
// sees the same pre-update DOM and the loop asserts nothing (the original bug here).
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
labelsByProto[proto] = fieldLabels();
}
});
// The loop must actually exercise protocol-specific rendering: distinct protocols
// must yield distinct field sets (a vacuous loop makes them all identical).
const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
expect(distinctShapes.size).toBeGreaterThan(1);
// Spot-check a protocol-distinguishing field that must appear after the switch.
if (labelsByProto.shadowsocks) {
expect(labelsByProto.shadowsocks).toContain('Encryption method');
}
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
it('preserves custom share address strategy when editing a local inbound', async () => {
renderWithProviders(

View File

@ -567,3 +567,94 @@ describe('external proxy pinned cert (pcs)', () => {
expect(new URL(link).searchParams.has('pcs')).toBe(false);
});
});
// #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP
// when VLESS encryption (vlessenc) is on, matching the form's flow display
// and the backend subscription. Gating is via canEnableTlsFlow.
describe('genVlessLink flow gating (#5322)', () => {
function vlessXhttp(encryption: string) {
return InboundSchema.parse({
id: 1,
up: 0,
down: 0,
total: 0,
remark: 'vlessenc',
enable: true,
expiryTime: 0,
listen: '',
port: 443,
tag: 'inbound-vless-xhttp',
sniffing: {
enabled: false,
destOverride: [],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
},
protocol: 'vless',
settings: {
clients: [
{
id: '11111111-2222-3333-4444-555555555555',
email: 'a@example.test',
flow: 'xtls-rprx-vision',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
enable: true,
tgId: 0,
subId: 's1',
comment: '',
reset: 0,
},
],
decryption: 'none',
encryption,
fallbacks: [],
},
streamSettings: {
network: 'xhttp',
xhttpSettings: {},
security: 'none',
},
});
}
const clientId = '11111111-2222-3333-4444-555555555555';
it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => {
const link = genVlessLink({
inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'),
address: 'example.test',
port: 443,
clientId,
flow: 'xtls-rprx-vision',
});
expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
});
it('omits flow for VLESS+XHTTP without vless encryption', () => {
const link = genVlessLink({
inbound: vlessXhttp('none'),
address: 'example.test',
port: 443,
clientId,
flow: 'xtls-rprx-vision',
});
expect(new URL(link).searchParams.has('flow')).toBe(false);
});
it('still emits flow for classic TCP+REALITY Vision', () => {
const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
const typed = InboundSchema.parse(raw);
const link = genVlessLink({
inbound: typed,
address: 'example.test',
port: 443,
clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id,
flow: 'xtls-rprx-vision',
});
expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision');
});
});

View File

@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { act } from '@testing-library/react';
import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
import {
@ -27,13 +28,30 @@ describe('OutboundFormModal', () => {
expect(fieldLabels().length).toBeGreaterThan(0);
});
it('field structure is stable for every protocol', () => {
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal(null);
const protocols = listSelectOptions('protocol');
expect(protocols.length).toBeGreaterThan(3);
const labelsByProto: Record<string, string[]> = {};
for (const proto of protocols) {
chooseSelectOption('protocol', proto);
expect(fieldLabels()).toMatchSnapshot(proto);
// Flush antd Form.useWatch('protocol') so protocol-specific fields render before
// reading; otherwise every iteration sees the same default (vless) DOM.
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
labelsByProto[proto] = fieldLabels();
}
});
// Distinct protocols must yield distinct field sets (a vacuous loop is all-identical).
const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
expect(distinctShapes.size).toBeGreaterThan(1);
// vless carries an Encryption field; wireguard does not — proves real protocol switching.
if (labelsByProto.vless) {
expect(labelsByProto.vless).toContain('Encryption');
}
if (labelsByProto.wireguard) {
expect(labelsByProto.wireguard).not.toContain('Encryption');
}
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
});

View File

@ -392,6 +392,23 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
expect(xhttp.xPaddingBytes).toBe('900-9000');
});
it('extracts the nested xmux object from the extra JSON blob', () => {
// The inbound link bundles xmux into `extra` as a nested object
// (sub/service.go). It must survive import so the outbound form's
// XMUX sub-form populates rather than silently dropping it (#5353).
const extra = encodeURIComponent(JSON.stringify({
xmux: { maxConcurrency: '8-16', hMaxRequestTimes: '700-1000' },
}));
const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
+ '&extra=' + extra + '#t';
const parsed = parseVlessLink(link);
const xhttp = (parsed!.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
const xmux = xhttp.xmux as Record<string, unknown>;
expect(xmux).toBeDefined();
expect(xmux.maxConcurrency).toBe('8-16');
expect(xmux.hMaxRequestTimes).toBe('700-1000');
});
it('ignores malformed extra JSON without breaking the rest of the link', () => {
const link = 'vless://u@h:1?type=xhttp&security=none&path=%2F&host=&mode=auto'
+ '&extra=not-json&fp=chrome#t';

View File

@ -27,3 +27,35 @@ describe('InboundSettingsSchema fixtures', () => {
});
}
});
// The fixture tests above pin coerced values only via regenerable snapshots. These
// assert the load-bearing transforms directly, so a broken coercion fails independently
// of the snapshot baseline.
describe('InboundSettingsSchema coercions', () => {
it('vmess: defaults alterId to 0 and coerces a string tgId to a number', () => {
const parsed = InboundSettingsSchema.parse({
protocol: 'vmess',
settings: { clients: [{ id: 'u1', email: 'a@b.c', tgId: '12345' }] },
});
if (parsed.protocol !== 'vmess') throw new Error('discriminator narrowed to the wrong protocol');
const client = parsed.settings.clients[0];
expect(client.alterId).toBe(0); // .default(0) injected for omitted field
expect(client.tgId).toBe(12345); // string -> number transform
});
it('vmess: a non-numeric tgId coerces to 0', () => {
const parsed = InboundSettingsSchema.parse({
protocol: 'vmess',
settings: { clients: [{ id: 'u1', email: 'a@b.c', tgId: 'not-a-number' }] },
});
if (parsed.protocol !== 'vmess') throw new Error('wrong protocol');
expect(parsed.settings.clients[0].tgId).toBe(0); // Number(v) || 0
});
it('vless: defaults decryption and encryption to "none"', () => {
const parsed = InboundSettingsSchema.parse({ protocol: 'vless', settings: { clients: [] } });
if (parsed.protocol !== 'vless') throw new Error('wrong protocol');
expect(parsed.settings.decryption).toBe('none');
expect(parsed.settings.encryption).toBe('none');
});
});

View File

@ -66,7 +66,7 @@ describe('normalizeXhttpForWire stream-one', () => {
expect(out).not.toHaveProperty('scMaxEachPostBytes');
});
it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
it('keeps inbound xmux when enableXmux is on (stored for subscription extra; stripped from xray config on Go side)', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',

1
go.mod
View File

@ -31,6 +31,7 @@ require (
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
pgregory.net/rapid v1.3.0
)
require (

2
go.sum
View File

@ -305,3 +305,5 @@ gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TI
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=

View File

@ -0,0 +1,75 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// copyFile is the workhorse invoked by init()'s Windows-only DB migration
// (config.go:214), the branch guarded by the platform check on config.go:196.
// The init() guard itself cannot be re-driven from an in-process test (init runs
// once at package load, the OS check is a compile-time constant, and the old-DB
// source path is hardcoded to a system location), so these tests pin down the
// migration payload's contract instead.
func TestCopyFileCopiesContents(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.db")
dst := filepath.Join(dir, "dst.db")
want := []byte("3x-ui sqlite payload\x00\x01\x02")
if err := os.WriteFile(src, want, 0o600); err != nil {
t.Fatalf("write src: %v", err)
}
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile returned error: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dst: %v", err)
}
if string(got) != string(want) {
t.Errorf("dst contents = %q, want %q", got, want)
}
}
func TestCopyFileMissingSourceReturnsError(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "does-not-exist.db")
dst := filepath.Join(dir, "dst.db")
if err := copyFile(src, dst); err == nil {
t.Fatal("copyFile with missing source returned nil error, want error")
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("dst should not be created when source is missing, stat err = %v", err)
}
}
func TestCopyFileOverwritesDestination(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.db")
dst := filepath.Join(dir, "dst.db")
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
t.Fatalf("write src: %v", err)
}
if err := os.WriteFile(dst, []byte("stale-and-longer"), 0o600); err != nil {
t.Fatalf("write dst: %v", err)
}
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile returned error: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dst: %v", err)
}
if string(got) != "new" {
t.Errorf("dst contents = %q, want %q (truncated overwrite)", got, "new")
}
}

View File

@ -225,6 +225,49 @@ func jsonStringFieldFromRaw(r json.RawMessage) string {
return string(trimmed)
}
// StripInboundXhttpClientFields removes xHTTP knobs that belong on the
// client dialer and subscription share-link extras only. xray-core's XHTTP
// inbound listener does not consume them; the panel still stores them on
// the inbound row so buildXhttpExtra can push defaults to clients.
func StripInboundXhttpClientFields(streamSettings string) (string, bool) {
if streamSettings == "" {
return streamSettings, false
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return streamSettings, false
}
if stream["network"] != "xhttp" {
return streamSettings, false
}
xhttp, ok := stream["xhttpSettings"].(map[string]any)
if !ok || len(xhttp) == 0 {
return streamSettings, false
}
clientOnly := []string{
"xmux",
"downloadSettings",
"scMinPostsIntervalMs",
"uplinkChunkSize",
"noGRPCHeader",
}
changed := false
for _, key := range clientOnly {
if _, has := xhttp[key]; has {
delete(xhttp, key)
changed = true
}
}
if !changed {
return streamSettings, false
}
out, err := json.MarshalIndent(stream, "", " ")
if err != nil {
return streamSettings, false
}
return string(out), true
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen
@ -248,12 +291,16 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
settings = stripped
}
}
streamSettings := i.StreamSettings
if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
streamSettings = stripped
}
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
Protocol: protocol,
Settings: json_util.RawMessage(settings),
StreamSettings: json_util.RawMessage(i.StreamSettings),
StreamSettings: json_util.RawMessage(streamSettings),
Tag: i.Tag,
Sniffing: json_util.RawMessage(i.Sniffing),
}

View File

@ -188,3 +188,85 @@ func TestInboundClientIpsUnmarshalJSONAcceptsBothShapes(t *testing.T) {
})
}
}
func TestStripInboundXhttpClientFields_RemovesClientOnlyKnobs(t *testing.T) {
stream := `{
"network": "xhttp",
"security": "reality",
"xhttpSettings": {
"path": "/app",
"host": "example.com",
"mode": "stream-one",
"xmux": { "maxConcurrency": "16-32" },
"downloadSettings": { "network": "xhttp" },
"scMinPostsIntervalMs": "20-40",
"uplinkChunkSize": 4096,
"noGRPCHeader": true
}
}`
out, changed := StripInboundXhttpClientFields(stream)
if !changed {
t.Fatal("expected client-only xhttp fields to be stripped")
}
if strings.Contains(out, `"xmux"`) {
t.Fatalf("xmux should be removed from xray config stream: %s", out)
}
for _, key := range []string{"downloadSettings", "scMinPostsIntervalMs", "uplinkChunkSize", "noGRPCHeader"} {
if strings.Contains(out, `"`+key+`"`) {
t.Fatalf("%s should be removed from xray config stream: %s", key, out)
}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
xhttp := parsed["xhttpSettings"].(map[string]any)
if xhttp["path"] != "/app" || xhttp["host"] != "example.com" {
t.Fatalf("server fields must survive: %#v", xhttp)
}
}
func TestStripInboundXhttpClientFields_UnchangedWithoutClientFields(t *testing.T) {
stream := `{"network":"xhttp","xhttpSettings":{"path":"/app","mode":"stream-one"}}`
out, changed := StripInboundXhttpClientFields(stream)
if changed {
t.Fatalf("expected no change, got: %s", out)
}
if out != stream {
t.Fatalf("unchanged stream must be returned verbatim")
}
}
func TestStripInboundXhttpClientFields_NonXhttpPassthrough(t *testing.T) {
stream := `{"network":"ws","wsSettings":{"path":"/"}}`
out, changed := StripInboundXhttpClientFields(stream)
if changed || out != stream {
t.Fatalf("non-xhttp stream must pass through unchanged, got changed=%v out=%s", changed, out)
}
}
func TestGenXrayInboundConfig_OmitsInboundXmuxButDbRowUnchanged(t *testing.T) {
stream := `{
"network": "xhttp",
"xhttpSettings": {
"path": "/app",
"mode": "stream-one",
"xmux": { "maxConcurrency": "16-32", "hMaxRequestTimes": "600-900" }
}
}`
in := Inbound{
Protocol: VLESS,
Port: 443,
Listen: "0.0.0.0",
Tag: "in-xhttp",
Settings: `{"clients":[],"decryption":"none"}`,
StreamSettings: stream,
}
cfg := in.GenXrayInboundConfig()
if strings.Contains(string(cfg.StreamSettings), `"xmux"`) {
t.Fatalf("GenXrayInboundConfig must not emit xmux: %s", cfg.StreamSettings)
}
if strings.Contains(in.StreamSettings, `"xmux"`) == false {
t.Fatal("inbound row streamSettings must still carry xmux for subscriptions")
}
}

View File

@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@ -31,8 +32,10 @@ var (
logger *logging.Logger
fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval
logBuffer []struct {
// logBuffer maintains recent log entries in memory for web UI retrieval;
// logBufferMu guards it — written from many goroutines, read by the web UI.
logBufferMu sync.Mutex
logBuffer []struct {
time string
level logging.Level
log string
@ -193,6 +196,8 @@ func Errorf(format string, args ...any) {
// addToBuffer adds a log entry to the in-memory ring buffer for web UI retrieval.
func addToBuffer(level string, newLog string) {
t := time.Now()
logBufferMu.Lock()
defer logBufferMu.Unlock()
if len(logBuffer) >= maxLogBufferSize {
logBuffer = logBuffer[1:]
}
@ -214,9 +219,21 @@ func GetLogs(c int, level string) []string {
var output []string
logLevel, _ := logging.LogLevel(level)
for i := len(logBuffer) - 1; i >= 0 && len(output) <= c; i-- {
if logBuffer[i].level <= logLevel {
output = append(output, fmt.Sprintf("%s %s - %s", logBuffer[i].time, logBuffer[i].level, logBuffer[i].log))
// Snapshot (copy) under the lock, then filter/format unlocked: a UI log fetch
// must not block addToBuffer — and thus all logging — for the formatting loop.
// A copy (not a reslice) is required, since addToBuffer can append in place.
logBufferMu.Lock()
snapshot := make([]struct {
time string
level logging.Level
log string
}, len(logBuffer))
copy(snapshot, logBuffer)
logBufferMu.Unlock()
for i := len(snapshot) - 1; i >= 0 && len(output) < c; i-- {
if snapshot[i].level <= logLevel {
output = append(output, fmt.Sprintf("%s %s - %s", snapshot[i].time, snapshot[i].level, snapshot[i].log))
}
}
return output

View File

@ -0,0 +1,30 @@
package logger
import (
"fmt"
"testing"
)
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
// The loop condition must cap output at c (ERROR entries are queried at "debug"
// level so the level filter passes all of them, isolating the count).
func TestGetLogs_ReturnsAtMostC(t *testing.T) {
logBufferMu.Lock()
logBuffer = nil
logBufferMu.Unlock()
for i := 0; i < 5; i++ {
addToBuffer("ERROR", fmt.Sprintf("m%d", i))
}
cases := []struct{ c, want int }{
{0, 0},
{2, 2},
{5, 5},
{10, 5}, // capped at what's available
}
for _, tc := range cases {
if got := GetLogs(tc.c, "debug"); len(got) != tc.want {
t.Errorf("GetLogs(%d) returned %d entries, want %d", tc.c, len(got), tc.want)
}
}
}

View File

@ -0,0 +1,99 @@
package mtproto
import (
"testing"
)
// TestParseMetricLineBraceBoundary pins the contract of the brace-position
// guard in parseMetricLine (manager.go:425 -> `if end < brace`).
//
// Once a '{' is found at index `brace`, the matching '}' must appear AFTER it.
// A '}' that precedes the '{', or a '{' with no closing '}' at all
// (strings.IndexByte returns -1, which is < brace), is a malformed line and
// must yield an error rather than slicing past the brace.
func TestParseMetricLineBraceBoundary(t *testing.T) {
t.Run("closing brace before opening brace is malformed", func(t *testing.T) {
// '}' at index 8 comes before '{' at index 16: end < brace must hold,
// so this is rejected. Mutating `<` to `>`/`>=` would accept it.
_, _, _, err := parseMetricLine(`mtg_x_a}_b{direction="x"} 5`)
if err == nil {
t.Fatal("expected error for '}' appearing before '{'")
}
})
t.Run("opening brace with no closing brace is malformed", func(t *testing.T) {
// No '}' at all -> end == -1, which is < brace. Must error.
// If the guard were dropped/inverted the code would slice line[brace+1:-1]
// and panic; asserting a clean error keeps that contract.
_, _, _, err := parseMetricLine(`mtg_traffic{direction="x" 5`)
if err == nil {
t.Fatal("expected error for '{' without a closing '}'")
}
})
t.Run("well-formed braces are accepted", func(t *testing.T) {
// '{' at index 11, '}' at index 25: end > brace, so the guard must NOT
// fire and parsing must succeed. Guards against a mutant that always errors.
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="up"} 42`)
if err != nil {
t.Fatalf("well-formed line should parse: %v", err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if labels["direction"] != "up" {
t.Fatalf("labels=%v", labels)
}
if val != 42 {
t.Fatalf("val=%v", val)
}
})
}
// TestParseMetricLineLabelEqualsBoundary pins the contract of the '=' guard in
// the per-label loop (manager.go:430 -> `if eq < 0`).
//
// - eq < 0 (no '=' in the segment): the segment is skipped, no label added.
// - eq == 0 (segment begins with '='): the key is empty but the pair is STILL
// parsed, producing labels[""] = value. The boundary is `< 0`, not `<= 0`.
func TestParseMetricLineLabelEqualsBoundary(t *testing.T) {
t.Run("label segment without '=' is skipped, not fatal", func(t *testing.T) {
// "novalue" has no '=' (eq == -1) and must be skipped. A real key=val
// segment in the same line must still be parsed. Mutating `< 0` to `> 0`
// would take kv[:eq] with eq=-1 and panic; mutating away the skip would
// also corrupt parsing.
name, labels, val, err := parseMetricLine(`mtg_traffic{novalue,direction="down"} 9`)
if err != nil {
t.Fatalf("line with a value-less label should still parse: %v", err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if _, present := labels["novalue"]; present {
t.Fatalf("value-less segment must not create a label: %v", labels)
}
if labels["direction"] != "down" {
t.Fatalf("real label must still be parsed: %v", labels)
}
if val != 9 {
t.Fatalf("val=%v", val)
}
})
t.Run("label segment beginning with '=' is parsed as empty key", func(t *testing.T) {
// "=onlyvalue": eq == 0. Since the guard is `< 0`, this is NOT skipped:
// it yields labels[""] = "onlyvalue". A mutant changing `< 0` to `<= 0`
// would skip it, losing the empty-key entry.
_, labels, _, err := parseMetricLine(`mtg_traffic{=onlyvalue} 1`)
if err != nil {
t.Fatalf("segment with empty key should still parse: %v", err)
}
v, present := labels[""]
if !present {
t.Fatalf("eq==0 segment must produce an empty-key label: %v", labels)
}
if v != "onlyvalue" {
t.Fatalf("empty-key label value=%q", v)
}
})
}

View File

@ -70,6 +70,27 @@ func TestBuildURLs_EmptySubId(t *testing.T) {
}
}
func TestForRequestDoesNotMutateSharedService(t *testing.T) {
initSubDB(t)
base := &SubService{}
first := base.ForRequest("first.example.com")
second := base.ForRequest("second.example.com")
if base.address != "" || base.nodesByID != nil {
t.Fatalf("ForRequest mutated the shared service: address=%q nodes=%v", base.address, base.nodesByID)
}
firstURL, _, _ := first.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
secondURL, _, _ := second.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
if !strings.Contains(firstURL, "first.example.com") {
t.Fatalf("first request URL = %q, want first.example.com", firstURL)
}
if !strings.Contains(secondURL, "second.example.com") {
t.Fatalf("second request URL = %q, want second.example.com", secondURL)
}
}
// A subscriber arriving via a reverse proxy (subURI configured with full
// HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
// URLs as in the main subURL — not the raw sub-server port 2096.

View File

@ -22,13 +22,12 @@ func NewSubClashService(enableRouting bool, clashRules string, subService *SubSe
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
// Set per-request state so resolveInboundAddress sees the node map.
s.SubService.PrepareForRequest(host)
inbounds, err := s.SubService.getInboundsBySubId(subId)
subReq := s.SubService.ForRequest(host)
inbounds, err := subReq.getInboundsBySubId(subId)
if err != nil {
return "", "", err
}
externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
if err != nil {
return "", "", err
}
@ -40,14 +39,14 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
seenEmails := make(map[string]struct{})
for _, inbound := range inbounds {
clients := s.SubService.matchingClients(inbound, subId)
clients := subReq.matchingClients(inbound, subId)
if len(clients) == 0 {
continue
}
s.SubService.projectThroughFallbackMaster(inbound)
subReq.projectThroughFallbackMaster(inbound)
for _, client := range clients {
seenEmails[client.Email] = struct{}{}
proxies = append(proxies, s.getProxies(inbound, client, host)...)
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
}
}
for _, ext := range externalLinks {
@ -73,7 +72,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
for e := range seenEmails {
emails = append(emails, e)
}
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
traffic, _ := subReq.AggregateTrafficByEmails(emails)
proxyNames := make([]string, 0, len(proxies)+1)
for _, proxy := range proxies {
@ -140,12 +139,12 @@ func fallbackProxyName(proxy map[string]any, idx int) string {
return fmt.Sprintf("proxy-%d", idx+1)
}
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any {
stream := s.streamData(inbound.StreamSettings)
// For node-managed inbounds the Clash proxy "server" must be the
// node's address, not the request host. resolveInboundAddress handles
// the node→subscriber-host fallback chain.
defaultDest := s.SubService.resolveInboundAddress(inbound)
defaultDest := subReq.resolveInboundAddress(inbound)
if defaultDest == "" {
defaultDest = host
}
@ -187,7 +186,7 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
}
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy["remark"].(string))
if len(proxy) > 0 {
proxies = append(proxies, proxy)
}
@ -195,15 +194,15 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
return proxies
}
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
// Hysteria has its own transport + TLS model, applyTransport /
// applySecurity don't fit.
if inbound.Protocol == model.Hysteria {
return s.buildHysteriaProxy(inbound, client, extraRemark)
return s.buildHysteriaProxy(subReq, inbound, client, extraRemark)
}
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"name": subReq.genRemark(inbound, client.Email, extraRemark),
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
@ -274,7 +273,7 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
// directly instead of going through streamData/tlsData, because those
// helpers prune fields (like `allowInsecure` / the salamander obfs
// block) that the hysteria proxy wants preserved.
func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
@ -286,7 +285,7 @@ func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client mode
}
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"name": subReq.genRemark(inbound, client.Email, extraRemark),
"type": proxyType,
"server": inbound.Listen,
"port": inbound.Port,

View File

@ -41,6 +41,64 @@ func TestEnsureUniqueProxyNames(t *testing.T) {
}
}
// TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
// applySecurity (clash_service.go ~488): a regression that drops servername,
// public-key, short-id, or client-fingerprint would hand mihomo a broken reality
// proxy. The existing clash tests don't assert any of these.
func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "reality",
"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}},
"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid reality stream")
}
if proxy["tls"] != true {
t.Fatalf("tls = %v, want true", proxy["tls"])
}
if proxy["servername"] != "reality.example.com" {
t.Fatalf("servername = %v, want reality.example.com", proxy["servername"])
}
if proxy["client-fingerprint"] != "chrome" {
t.Fatalf("client-fingerprint = %v, want chrome", proxy["client-fingerprint"])
}
opts, _ := proxy["reality-opts"].(map[string]any)
if opts == nil {
t.Fatal("reality-opts missing")
}
if opts["public-key"] != "PBKvalue" {
t.Fatalf("public-key = %v, want PBKvalue", opts["public-key"])
}
if opts["short-id"] != "ab12" {
t.Fatalf("short-id = %v, want ab12", opts["short-id"])
}
}
// TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359):
// plain tcp and a "none" header are representable in clash; a non-none obfs header is
// not, so applyTransport must reject it (returning false drops it from the YAML).
func TestApplyTransport_TCPHeader(t *testing.T) {
svc := &SubClashService{}
if !svc.applyTransport(map[string]any{}, "tcp", map[string]any{}) {
t.Fatal("plain tcp must be buildable")
}
noneStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}}
if !svc.applyTransport(map[string]any{}, "tcp", noneStream) {
t.Fatal("tcp + header type none must be buildable")
}
httpStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "http"}}}
if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected")
}
}
func TestApplyTransport_XHTTP(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
@ -141,7 +199,7 @@ func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testi
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
if proxy["encryption"] != encryption {
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
@ -173,7 +231,7 @@ func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
if proxy["flow"] != "xtls-rprx-vision" {
t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
@ -198,7 +256,7 @@ func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
if _, ok := proxy["flow"]; ok {
t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
@ -223,9 +281,23 @@ func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, "")
if _, ok := proxy["encryption"]; ok {
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
}
// The rest of the proxy must still be well-formed — otherwise a mutant that
// drops encryption *and* corrupts a core field passes the absence check alone.
if proxy["type"] != "vless" {
t.Fatalf("type = %v, want vless", proxy["type"])
}
if proxy["server"] != "203.0.113.1" {
t.Fatalf("server = %v, want 203.0.113.1", proxy["server"])
}
if proxy["port"] != 443 {
t.Fatalf("port = %v, want 443", proxy["port"])
}
if proxy["uuid"] != client.ID {
t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID)
}
}

View File

@ -137,7 +137,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
func (a *SUBController) subs(c *gin.Context) {
subId := c.Param("subid")
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
subs, emails, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
subReq := a.subService.ForRequest(host)
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
if err != nil || len(subs) == 0 {
writeSubError(c, err)
} else {
@ -149,7 +150,7 @@ func (a *SUBController) subs(c *gin.Context) {
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
}
@ -161,7 +162,7 @@ func (a *SUBController) subs(c *gin.Context) {
basePath = "/"
}
basePathStr := basePath.(string)
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
a.serveSubPage(c, basePathStr, page)
return
}

View File

@ -0,0 +1,51 @@
package sub
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// A subscription whose only entries are external links — no enabled standard
// inbound — must still render in the JSON and Clash formats, not just the raw
// one. Regression guard for the premature len(inbounds)==0 early return that
// short-circuited GetJson/GetClash before external links were ever fetched.
func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
initSubDB(t)
db := database.GetDB()
rec := &model.ClientRecord{Email: "ext@x", SubID: "ext-only", UUID: "ext-uuid", Enable: true}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
link := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#orig"
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, Remark: "DE-Provider", SortIndex: 1}).Error; err != nil {
t.Fatalf("seed external link: %v", err)
}
base := NewSubService(false, "-io")
jsonOut, _, err := NewSubJsonService("", "", "", base).GetJson("ext-only", "sub.example.com")
if err != nil {
t.Fatalf("GetJson err = %v", err)
}
if jsonOut == "" {
t.Fatal("GetJson returned empty for an external-link-only sub")
}
if !strings.Contains(jsonOut, "DE-Provider") {
t.Fatalf("GetJson missing external remark: %s", jsonOut)
}
clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com")
if err != nil {
t.Fatalf("GetClash err = %v", err)
}
if clashOut == "" {
t.Fatal("GetClash returned empty for an external-link-only sub")
}
if !strings.Contains(clashOut, "DE-Provider") {
t.Fatalf("GetClash missing external proxy: %s", clashOut)
}
}

View File

@ -58,14 +58,12 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
// Set per-request state on the shared SubService so any
// resolveInboundAddress call inside picks node-aware host values.
s.SubService.PrepareForRequest(host)
inbounds, err := s.SubService.getInboundsBySubId(subId)
subReq := s.SubService.ForRequest(host)
inbounds, err := subReq.getInboundsBySubId(subId)
if err != nil {
return "", "", err
}
externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
if err != nil {
return "", "", err
}
@ -79,15 +77,15 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
seenEmails := make(map[string]struct{})
// Prepare Inbounds
for _, inbound := range inbounds {
clients := s.SubService.matchingClients(inbound, subId)
clients := subReq.matchingClients(inbound, subId)
if len(clients) == 0 {
continue
}
s.SubService.projectThroughFallbackMaster(inbound)
subReq.projectThroughFallbackMaster(inbound)
for _, client := range clients {
seenEmails[client.Email] = struct{}{}
configArray = append(configArray, s.getConfig(inbound, client, host)...)
configArray = append(configArray, s.getConfig(subReq, inbound, client, host)...)
}
}
for _, ext := range externalLinks {
@ -120,7 +118,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
for e := range seenEmails {
emails = append(emails, e)
}
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
traffic, _ := subReq.AggregateTrafficByEmails(emails)
// Combile outbounds
var finalJson []byte
@ -134,7 +132,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
return string(finalJson), header, nil
}
func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
var newJsonArray []json_util.RawMessage
stream := s.streamData(inbound.StreamSettings)
@ -143,7 +141,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
// For node-managed inbounds we want the node's address — request
// host won't reach the right xray. resolveInboundAddress already
// implements the node→subscriber-host fallback chain.
defaultDest := s.SubService.resolveInboundAddress(inbound)
defaultDest := subReq.resolveInboundAddress(inbound)
if defaultDest == "" {
defaultDest = host
}
@ -204,7 +202,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = s.SubService.genRemark(inbound, client.Email, extPrxy["remark"].(string))
newConfigJson["remarks"] = subReq.genRemark(inbound, client.Email, extPrxy["remark"].(string))
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
newJsonArray = append(newJsonArray, newConfig)

View File

@ -0,0 +1,484 @@
package sub
import (
"encoding/base64"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// initMutDB spins up a real temp SQLite DB for tests that exercise DB-backed
// query helpers, mirroring the house pattern in service_sharelink/dedup tests.
func initMutDB(t *testing.T) {
t.Helper()
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
}
// --- json_service.go:40 — rules are merged into routing only when non-empty ---
func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
rules := `[{"type":"field","domain":["geosite:ads"],"outboundTag":"block"}]`
svc := NewSubJsonService("", rules, "", nil)
routing, ok := svc.configJson["routing"].(map[string]any)
if !ok {
t.Fatalf("routing missing: %#v", svc.configJson["routing"])
}
got, _ := routing["rules"].([]any)
// default.json ships exactly 1 rule; the custom rule must be prepended.
if len(got) != 2 {
t.Fatalf("rules len = %d, want 2 (custom prepended to default)", len(got))
}
first, _ := got[0].(map[string]any)
if domains, _ := first["domain"].([]any); len(domains) != 1 || domains[0] != "geosite:ads" {
t.Fatalf("custom rule must come first, got %#v", got[0])
}
}
func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
routing, _ := svc.configJson["routing"].(map[string]any)
got, _ := routing["rules"].([]any)
if len(got) != 1 {
t.Fatalf("rules len = %d, want 1 (no custom rules → default untouched)", len(got))
}
}
// --- json_service.go:331,356,408 — mux is attached only when configured ---
func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
const mux = `{"enabled":true,"concurrency":8}`
client := model.Client{ID: "uuid-1", Password: "p4ss"}
cases := []struct {
name string
raw []byte
wantMux bool
protocol model.Protocol
}{
{"vmess mux", NewSubJsonService(mux, "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", "", nil).genVless(&model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", "", nil).genServer(&model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client), false, model.Trojan},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var ob map[string]any
if err := json.Unmarshal(tc.raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
m, has := ob["mux"]
if tc.wantMux {
if !has {
t.Fatalf("mux must be set when configured, outbound = %#v", ob)
}
mm, _ := m.(map[string]any)
if mm["enabled"] != true || mm["concurrency"] != float64(8) {
t.Fatalf("mux payload wrong: %#v", m)
}
} else if has {
t.Fatalf("mux must be omitted when empty, outbound = %#v", ob)
}
})
}
}
// --- json_service.go:268 — a non-empty finalMask that merges to nothing must
// not add the finalmask key (the `len(merged) > 0` guard). ---
func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// finalMask is non-empty (passes the len(fm)==0 early return) but its only
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
// so applyGlobalFinalMask (json_service.go:268) must NOT set finalmask.
svc := NewSubJsonService("", "", `{"tcp":[]}`, nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["finalmask"]; ok {
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
}
// Sanity: a finalMask that DOES merge to something still gets set, so the
// guard is the only distinguishing factor.
svc2 := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, nil)
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream2["finalmask"]; !ok {
t.Fatal("non-empty finalMask must be set")
}
}
// --- json_service.go:494 — an empty extra tcp slice must not clobber the base ---
func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
base := map[string]any{"tcp": []any{map[string]any{"type": "keep"}}}
extra := map[string]any{"tcp": []any{}} // empty → must be ignored
merged := mergeFinalMask(base, extra)
tcp, _ := merged["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp len = %d, want 1 (empty extra must not drop or append)", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "keep" {
t.Fatalf("base tcp mask lost: %#v", tcp)
}
// Sanity: a non-empty extra DOES append, so the guard is the only thing
// distinguishing the two paths.
extra2 := map[string]any{"tcp": []any{map[string]any{"type": "add"}}}
merged2 := mergeFinalMask(base, extra2)
if tcp2, _ := merged2["tcp"].([]any); len(tcp2) != 2 {
t.Fatalf("non-empty extra must append: len = %d, want 2", len(tcp2))
}
}
// --- service.go:69-77 — configuredPublicHost priority: subDomain > webDomain > "" ---
func TestConfiguredPublicHost_Priority(t *testing.T) {
initMutDB(t)
db := database.GetDB()
set := func(key, val string) {
if err := db.Save(&model.Setting{Key: key, Value: val}).Error; err != nil {
t.Fatalf("save %s: %v", key, err)
}
}
s := &SubService{}
// Both empty → "".
if got := s.configuredPublicHost(); got != "" {
t.Fatalf("no domains configured: got %q, want empty", got)
}
// Only webDomain → webDomain wins (exercises the second branch, service.go:73).
set("webDomain", "web.example.com")
if got := s.configuredPublicHost(); got != "web.example.com" {
t.Fatalf("webDomain fallback: got %q, want web.example.com", got)
}
// subDomain set → subDomain takes precedence over webDomain (service.go:70).
set("subDomain", "sub.example.com")
if got := s.configuredPublicHost(); got != "sub.example.com" {
t.Fatalf("subDomain priority: got %q, want sub.example.com", got)
}
}
// --- service.go:248 — AggregateTrafficByEmails tracks the MAX LastOnline ---
func TestAggregateTrafficByEmails_LastOnlineIsMax(t *testing.T) {
initMutDB(t)
db := database.GetDB()
rows := []xray.ClientTraffic{
{Email: "a@x", Up: 10, Down: 20, LastOnline: 100},
{Email: "b@x", Up: 1, Down: 2, LastOnline: 500}, // the max
{Email: "c@x", Up: 3, Down: 4, LastOnline: 300},
}
for i := range rows {
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
}
s := &SubService{}
agg, lastOnline := s.AggregateTrafficByEmails([]string{"a@x", "b@x", "c@x"})
if lastOnline != 500 {
t.Fatalf("lastOnline = %d, want 500 (max across rows)", lastOnline)
}
// Up/Down must still sum so a mutant can't pass by zeroing everything.
if agg.Up != 14 || agg.Down != 26 {
t.Fatalf("agg up/down = %d/%d, want 14/26", agg.Up, agg.Down)
}
}
// --- service.go:329 — projectThroughFallbackMaster returns false for nil ---
func TestProjectThroughFallbackMaster_Nil(t *testing.T) {
s := &SubService{}
if s.projectThroughFallbackMaster(nil) {
t.Fatal("nil inbound must yield false (no projection, no DB hit)")
}
}
// --- service.go:555 — empty client flow must not emit a flow param even when allowed ---
func TestGenVlessLink_NoFlowWhenClientFlowEmpty(t *testing.T) {
// tcp+reality is a flow-allowed combo; with an empty client flow the
// len(...)>0 guard (service.go:555) must keep `flow` out of the link.
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{"serverNames":["r.example.com"],"shortIds":["ab"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}
}`
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "noflow",
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"encryption":"none"}`,
StreamSettings: stream,
}
s := &SubService{remarkModel: "-ieo"}
if link := s.genVlessLink(inbound, "user"); strings.Contains(link, "flow=") {
t.Fatalf("empty client flow must not produce a flow param, got %q", link)
}
}
// --- service.go:906-913 — applyPathAndHostParams host source ---
func TestApplyPathAndHostParams(t *testing.T) {
// Direct host wins (service.go:908 true branch).
params := map[string]string{}
applyPathAndHostParams(map[string]any{"path": "/p", "host": "direct.example.com"}, params)
if params["path"] != "/p" {
t.Fatalf("path = %q, want /p", params["path"])
}
if params["host"] != "direct.example.com" {
t.Fatalf("direct host = %q, want direct.example.com", params["host"])
}
// No direct host → fall back to headers.Host (service.go:908 false branch).
params = map[string]string{}
applyPathAndHostParams(map[string]any{
"path": "/p",
"headers": map[string]any{"Host": "via-header.example.com"},
}, params)
if params["host"] != "via-header.example.com" {
t.Fatalf("header host fallback = %q, want via-header.example.com", params["host"])
}
// Empty-string host must NOT shadow the header fallback (len(host) > 0 guard).
params = map[string]string{}
applyPathAndHostParams(map[string]any{
"path": "/p",
"host": "",
"headers": map[string]any{"Host": "via-header.example.com"},
}, params)
if params["host"] != "via-header.example.com" {
t.Fatalf("empty host must defer to headers, got %q", params["host"])
}
}
// --- external_config.go:39,42,55,58 — getClientExternalLinksBySubId ---
func TestGetClientExternalLinksBySubId(t *testing.T) {
initMutDB(t)
db := database.GetDB()
s := &SubService{}
// No client rows for the subId → nil, no error (service.go path :42).
out, err := s.getClientExternalLinksBySubId("missing")
if err != nil {
t.Fatalf("missing subId err = %v, want nil", err)
}
if out != nil {
t.Fatalf("missing subId = %#v, want nil", out)
}
// A client with NO external-link rows → nil (the rows-empty guard :58).
bare := &model.ClientRecord{Email: "bare@x", SubID: "sub-bare", UUID: "u", Enable: true}
if err := db.Create(bare).Error; err != nil {
t.Fatalf("seed bare client: %v", err)
}
out, err = s.getClientExternalLinksBySubId("sub-bare")
if err != nil {
t.Fatalf("bare subId err = %v", err)
}
if out != nil {
t.Fatalf("client with no links = %#v, want nil", out)
}
// A client with two link rows: ordering by sort_index and email/enable
// attribution from the owning client (the loop copies rec.Email/rec.Enable).
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://b", Remark: "second", SortIndex: 5}).Error; err != nil {
t.Fatalf("seed link b: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
t.Fatalf("seed link a: %v", err)
}
out, err = s.getClientExternalLinksBySubId("sub-ok")
if err != nil {
t.Fatalf("ok subId err = %v", err)
}
if len(out) != 2 {
t.Fatalf("entries = %d, want 2", len(out))
}
// sort_index ASC: the SortIndex=1 row comes first.
if out[0].Value != "trojan://a" || out[1].Value != "trojan://b" {
t.Fatalf("ordering wrong: %#v", out)
}
// Email + Enable must be copied from the owning client, not the link row
// (which carries neither field). The enabled owner → Enable true.
if out[0].Email != "owner@x" || out[0].Enable != true {
t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
}
// A DISABLED client must produce entries with Enable=false, proving the
// value is read from the client row (Enable has a gorm default:true, so
// flip it with a raw UPDATE that bypasses the default).
dis := &model.ClientRecord{Email: "off@x", SubID: "sub-off", UUID: "u3", Enable: true}
if err := db.Create(dis).Error; err != nil {
t.Fatalf("seed disabled client: %v", err)
}
if err := db.Model(&model.ClientRecord{}).Where("id = ?", dis.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable client: %v", err)
}
if err := db.Create(&model.ClientExternalLink{ClientId: dis.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://c", SortIndex: 1}).Error; err != nil {
t.Fatalf("seed link c: %v", err)
}
offOut, err := s.getClientExternalLinksBySubId("sub-off")
if err != nil {
t.Fatalf("off subId err = %v", err)
}
if len(offOut) != 1 {
t.Fatalf("disabled client entries = %d, want 1", len(offOut))
}
if offOut[0].Email != "off@x" || offOut[0].Enable != false {
t.Fatalf("disabled attribution wrong: email=%q enable=%v", offOut[0].Email, offOut[0].Enable)
}
}
// --- external_config.go:102 — applyRemarkToLink appends a fragment when none exists ---
func TestApplyRemarkToLink_NoFragmentAppends(t *testing.T) {
link := "trojan://pw@1.2.3.4:8443?security=tls"
out := applyRemarkToLink(link, "DE-Node")
if out != link+"#DE-Node" {
t.Fatalf("no-fragment link must get the remark appended, got %q", out)
}
}
// --- external_config.go:111 — applyVmessRemark falls back to RawURLEncoding ---
func TestApplyVmessRemark_RawURLEncodingFallback(t *testing.T) {
// The "aa?" ps forces a URL-safe char (_) in the RawURL encoding, so
// base64.StdEncoding.DecodeString fails and the RawURLEncoding fallback
// path (external_config.go:111) must take over. (ps is overwritten below,
// so its value is irrelevant to the assertions.)
payload := map[string]any{"v": "2", "ps": "aa?", "add": "1.2.3.4", "port": "443", "id": "uuid"}
b, _ := json.Marshal(payload)
link := "vmess://" + base64.RawURLEncoding.EncodeToString(b)
// Guard the premise: this link must NOT be std-decodable, else the fallback
// branch is never reached and the test is meaningless.
if _, err := base64.StdEncoding.DecodeString(padBase64Sub(strings.TrimPrefix(link, "vmess://"))); err == nil {
t.Fatal("test premise broken: link is std-base64 decodable, fallback not exercised")
}
out := applyRemarkToLink(link, "NL-Node")
if out == link {
t.Fatalf("raw-url-encoded vmess remark was not applied (fallback decode broken): %q", out)
}
// The result re-encodes with StdEncoding; decode and verify ps + credentials.
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
if err != nil {
t.Fatalf("decode out: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["ps"] != "NL-Node" {
t.Fatalf("ps = %v, want NL-Node", got["ps"])
}
if got["id"] != "uuid" {
t.Fatalf("credentials lost via fallback path: %#v", got)
}
}
// --- external_config.go:130 — padBase64Sub pads to a multiple of 4 ---
func TestPadBase64Sub(t *testing.T) {
cases := map[string]string{
"": "",
"a": "a===",
"ab": "ab==",
"abc": "abc=",
"abcd": "abcd",
}
for in, want := range cases {
if got := padBase64Sub(in); got != want {
t.Fatalf("padBase64Sub(%q) = %q, want %q", in, got, want)
}
if len(padBase64Sub(in))%4 != 0 {
t.Fatalf("padBase64Sub(%q) length not a multiple of 4", in)
}
}
}
// --- external_subscription.go:122 — base64 body decode strips embedded whitespace ---
func TestDecodeSubscriptionBody_StripsWhitespaceInBase64(t *testing.T) {
plain := "vless://uuid@a.com:443#one\ntrojan://pw@b.com:8443#two\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
// Inject whitespace into the base64 token; tryDecodeBase64Body must strip it
// (external_subscription.go:122) so decoding still succeeds.
half := len(encoded) / 2
dirty := encoded[:half] + "\n \t" + encoded[half:]
links := decodeSubscriptionBody([]byte(dirty))
if len(links) != 2 || links[0] != "vless://uuid@a.com:443#one" || links[1] != "trojan://pw@b.com:8443#two" {
t.Fatalf("whitespace-laden base64 body decoded wrong: %#v", links)
}
}
// --- clash_service.go:123 — duplicate proxy names disambiguate as base-N ---
func TestEnsureUniqueProxyNames_SuffixSequence(t *testing.T) {
proxies := []map[string]any{
{"name": "node"},
{"name": "node"},
{"name": "node"},
}
ensureUniqueProxyNames(proxies)
if proxies[0]["name"] != "node" {
t.Fatalf("first occurrence must keep base name, got %v", proxies[0]["name"])
}
if proxies[1]["name"] != "node-2" {
t.Fatalf("second duplicate = %v, want node-2", proxies[1]["name"])
}
if proxies[2]["name"] != "node-3" {
t.Fatalf("third duplicate = %v, want node-3", proxies[2]["name"])
}
}
// --- clash_service.go:422,447 — empty transport opts must NOT add the *-opts key ---
func TestApplyTransport_EmptyOptsOmitted(t *testing.T) {
svc := &SubClashService{}
// httpupgrade with no path/host → opts empty → no http-upgrade-opts key (clash:422).
huProxy := map[string]any{}
if !svc.applyTransport(huProxy, "httpupgrade", map[string]any{"httpupgradeSettings": map[string]any{}}) {
t.Fatal("httpupgrade must still be buildable")
}
if huProxy["network"] != "httpupgrade" {
t.Fatalf("network = %v, want httpupgrade", huProxy["network"])
}
if _, ok := huProxy["http-upgrade-opts"]; ok {
t.Fatalf("empty opts must not set http-upgrade-opts: %#v", huProxy["http-upgrade-opts"])
}
// xhttp with no path/host/mode → opts empty → no xhttp-opts key (clash:447).
xhProxy := map[string]any{}
if !svc.applyTransport(xhProxy, "xhttp", map[string]any{"xhttpSettings": map[string]any{}}) {
t.Fatal("xhttp must still be buildable")
}
if xhProxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", xhProxy["network"])
}
if _, ok := xhProxy["xhttp-opts"]; ok {
t.Fatalf("empty opts must not set xhttp-opts: %#v", xhProxy["xhttp-opts"])
}
}

View File

@ -49,11 +49,18 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
}
}
// PrepareForRequest sets per-request state (host + nodes map) on the
// shared SubService. Called by every entry point — GetSubs, GetJson,
// GetClash — so resolveInboundAddress sees the right host and the
// freshly-loaded node map regardless of which sub flavour the client
// hit.
// ForRequest returns a shallow copy with request-scoped state populated.
// Subscription controllers share one base SubService, so request-specific
// fields such as address and nodesByID must live on a per-request copy.
func (s *SubService) ForRequest(host string) *SubService {
req := *s
req.PrepareForRequest(host)
return &req
}
// PrepareForRequest sets per-request state (host + nodes map) on this
// SubService instance. HTTP handlers should call ForRequest instead so the
// controller's shared base service is never mutated by concurrent requests.
func (s *SubService) PrepareForRequest(host string) {
if !isRoutableHost(host) {
if d := s.configuredPublicHost(); d != "" {
@ -64,6 +71,23 @@ func (s *SubService) PrepareForRequest(host string) {
}
s.address = host
s.loadNodes()
s.loadRemarkSettings()
}
// loadRemarkSettings populates the per-request remark formatting state so
// every subscription format — raw, JSON, Clash — renders remarks the same
// way. genRemark reads emailInRemark and the date formatter reads datepicker;
// loading these only in getSubs left JSON/Clash with the zero values.
func (s *SubService) loadRemarkSettings() {
var err error
s.datepicker, err = s.settingService.GetDatepicker()
if err != nil {
s.datepicker = "gregorian"
}
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
if err != nil {
s.emailInRemark = true
}
}
func (s *SubService) configuredPublicHost() string {
@ -139,7 +163,10 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
// GetSubs retrieves subscription links for a given subscription ID and host.
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
s.PrepareForRequest(host)
return s.ForRequest(host).getSubs(subId)
}
func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.ClientTraffic, error) {
var result []string
var emails []string
var traffic xray.ClientTraffic
@ -157,16 +184,6 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
return nil, nil, 0, traffic, nil
}
s.datepicker, err = s.settingService.GetDatepicker()
if err != nil {
s.datepicker = "gregorian"
}
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
if err != nil {
s.emailInRemark = true
}
seenEmails := make(map[string]struct{})
for _, inbound := range inbounds {
clients := s.matchingClients(inbound, subId)

View File

@ -3,6 +3,7 @@ package sub
import (
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@ -62,4 +63,35 @@ func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
if len(emails) != 1 {
t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
}
// Identity, not just count: the single surviving link must be for this client.
if !strings.Contains(links[0], uuid) {
t.Fatalf("surviving link must carry the client uuid %q, got %q", uuid, links[0])
}
}
// TestMatchingClients_DedupsCaseInsensitiveEmail pins the dedup KEY, not just the count:
// the two entries differ only by email case, so dropping strings.ToLower (or keying on
// another field) yields two clients. The byte-identical dupes above can't catch that.
func TestMatchingClients_DedupsCaseInsensitiveEmail(t *testing.T) {
const subId = "s1"
const uuid = "11111111-2222-4333-8444-555555555555"
ib := &model.Inbound{
Protocol: model.VLESS,
Settings: `{"clients":[
{"id":"` + uuid + `","email":"Dup@Example.com","subId":"` + subId + `","enable":true},
{"id":"` + uuid + `","email":"dup@example.com","subId":"` + subId + `","enable":true}
]}`,
}
s := &SubService{}
got := s.matchingClients(ib, subId)
if len(got) != 1 {
t.Fatalf("case-differing duplicate emails must dedup to 1 client, got %d", len(got))
}
if got[0].Email != "Dup@Example.com" {
t.Fatalf("first occurrence must be kept, got %q", got[0].Email)
}
// A wrong subId must still be excluded (guards the subId filter at service.go:127).
if other := s.matchingClients(ib, "nope"); len(other) != 0 {
t.Fatalf("non-matching subId must yield 0 clients, got %d", len(other))
}
}

View File

@ -0,0 +1,88 @@
package sub
import (
"net"
"net/url"
"strconv"
"strings"
"testing"
"pgregory.net/rapid"
)
// TestProp_JoinHostPort_Bracketing asserts the RFC-3986 authority contract for any
// host/port: SplitHostPort must recover the (un-bracketed) host and the exact port,
// and an IPv6 literal is bracketed exactly once regardless of input brackets.
func TestProp_JoinHostPort_Bracketing(t *testing.T) {
hosts := []string{
"1.2.3.4", "example.com", "sub.host.test",
"2001:db8::1", "[2001:db8::1]", "::1", "[::1]", "fe80::1%eth0",
}
rapid.Check(t, func(t *rapid.T) {
host := rapid.SampledFrom(hosts).Draw(t, "host")
port := rapid.IntRange(0, 65535).Draw(t, "port")
out := joinHostPort(host, port)
gotHost, gotPort, err := net.SplitHostPort(out)
if err != nil {
t.Fatalf("SplitHostPort(%q) failed: %v", out, err)
}
wantHost := strings.Trim(host, "[]")
if gotHost != wantHost {
t.Fatalf("host round-trip: joinHostPort(%q,%d)=%q -> host %q, want %q", host, port, out, gotHost, wantHost)
}
if gotPort != strconv.Itoa(port) {
t.Fatalf("port round-trip: got %q, want %d (out=%q)", gotPort, port, out)
}
// An IPv6 literal (contains a colon in the host) must be bracketed once.
if strings.Contains(wantHost, ":") {
if strings.Count(out, "[") != 1 || strings.Count(out, "]") != 1 {
t.Fatalf("IPv6 host not bracketed exactly once: %q", out)
}
}
})
}
// TestProp_EncodeUserinfo_RoundTrip asserts encodeUserinfo produces a userinfo token
// that net/url parses back to the original password for ANY input — the contract that
// trojan/ss links rely on. A field-mapping mutant that mangles escaping breaks this.
func TestProp_EncodeUserinfo_RoundTrip(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
pw := rapid.String().Draw(t, "pw")
raw := "trojan://" + encodeUserinfo(pw) + "@example.com:443"
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse(%q) failed for pw=%q: %v", raw, pw, err)
}
if got := u.User.Username(); got != pw {
t.Fatalf("userinfo round-trip mismatch: pw=%q got=%q", pw, got)
}
})
}
// TestProp_SplitLinkLines_Invariants asserts splitLinkLines never emits empty or
// untrimmed lines, and that re-splitting its own joined output is a fixed point.
func TestProp_SplitLinkLines_Invariants(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
raw := rapid.String().Draw(t, "raw")
out := splitLinkLines(raw)
for i, line := range out {
if line == "" {
t.Fatalf("splitLinkLines emitted an empty line at %d for %q", i, raw)
}
if line != strings.TrimSpace(line) {
t.Fatalf("splitLinkLines emitted an untrimmed line %q", line)
}
}
rejoined := splitLinkLines(strings.Join(out, "\n"))
if len(rejoined) != len(out) {
t.Fatalf("not a fixed point: %d -> %d lines", len(out), len(rejoined))
}
for i := range out {
if rejoined[i] != out[i] {
t.Fatalf("fixed-point mismatch at %d: %q vs %q", i, out[i], rejoined[i])
}
}
})
}

View File

@ -0,0 +1,89 @@
package sub
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// shareLinkInbound builds a VLESS inbound with one client and the given stream
// settings, mirroring flowTestInbound but without forcing a flow.
func shareLinkInbound(streamSettings string) *model.Inbound {
return &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "sharelink",
Settings: `{"clients":[{"id":"11111111-2222-4333-8444-555555555555","email":"user"}],"decryption":"none","encryption":"none"}`,
StreamSettings: streamSettings,
}
}
// TestGenVlessLink_TLSParamsMapped locks every field that applyShareTLSParams
// (service.go:1029) writes into a TLS share link. Without these assertions a mutant
// that drops `sni`, swaps a key, or skips `pcs`/`alpn`/`fp` survives the whole suite —
// the existing flow tests only check `flow=`.
func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"tls",
"tcpSettings":{"header":{"type":"none"}},
"tlsSettings":{
"serverName":"sni.example.com",
"alpn":["h2","http/1.1"],
"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["YWJj"]}
}
}`
s := &SubService{remarkModel: "-ieo"}
link := s.genVlessLink(shareLinkInbound(stream), "user")
// url.Values.Encode() percent-encodes values: "," -> %2C, "/" -> %2F.
wants := []string{
"security=tls",
"sni=sni.example.com",
"fp=chrome",
"alpn=h2%2Chttp%2F1.1",
"pcs=YWJj",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("TLS link missing %q\n got: %s", w, link)
}
}
}
// TestGenVlessLink_RealityParamsMapped locks the reality field mapping
// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element
// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
// Distinct pbk/sid values catch a pbk<->sid swap mutant.
func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`
s := &SubService{remarkModel: "-ieo"}
link := s.genVlessLink(shareLinkInbound(stream), "user")
wants := []string{
"security=reality",
"sni=reality.example.com",
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("reality link missing %q\n got: %s", w, link)
}
}
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
}
}

View File

@ -0,0 +1,60 @@
package common
import "testing"
// TestFormatTraffic_UnitBoundaries pins the exact switch point in the loop
// condition `size >= 1024`: a unit must roll over at exactly 1024 (not 1023,
// not 1025), and a value one byte short must stay in the lower unit. This kills
// CONDITIONALS_BOUNDARY (>= -> >) and ARITHMETIC_BASE on the 1024 comparison.
func TestFormatTraffic_UnitBoundaries(t *testing.T) {
cases := []struct {
name string
bytes int64
want string
}{
// Just below the first boundary: must NOT roll over to KB.
{"one_below_kb", 1023, "1023.00B"},
// Exactly at the boundary: must roll over to KB.
{"exactly_kb", 1024, "1.00KB"},
// Just above: stays in KB.
{"one_above_kb", 1025, "1.00KB"},
// Just below the MB boundary: stays in KB (proves division divisor 1024).
{"one_below_mb", 1024*1024 - 1, "1024.00KB"},
// Exactly at the MB boundary: rolls over to MB.
{"exactly_mb", 1024 * 1024, "1.00MB"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := FormatTraffic(c.bytes); got != c.want {
t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
}
})
}
}
// TestFormatTraffic_ClampsAtPB pins the upper bound guard
// `unitIndex < len(units)-1`: huge values must clamp at PB instead of indexing
// past the units slice. A mutated bound (< -> <= via CONDITIONALS_BOUNDARY, or
// len(units)-1 -> len(units)+1 via INVERT_NEGATIVES/ARITHMETIC_BASE) would run
// one extra iteration and panic with index-out-of-range, so the assertion that
// these return a normal "PB" string kills those mutants.
func TestFormatTraffic_ClampsAtPB(t *testing.T) {
const pb = int64(1024 * 1024 * 1024 * 1024 * 1024)
cases := []struct {
name string
bytes int64
want string
}{
// Stays at PB even though size is still >= 1024 at the PB level.
{"1024_pb", 1024 * pb, "1024.00PB"},
// Max int64 must not overflow the units slice.
{"max_int64", 9223372036854775807, "8192.00PB"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := FormatTraffic(c.bytes); got != c.want {
t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
}
})
}
}

View File

@ -781,8 +781,10 @@ func base64DecodeFlexible(s string) (string, error) {
return "", fmt.Errorf("base64 decode failed")
}
// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
// SlugRemark turns a free-form remark into a tag segment, keeping Unicode
// letters and digits (so non-ASCII remarks like Cyrillic stay readable) and
// replacing every other run of characters with a single dash.
var slugRe = regexp.MustCompile(`[^\p{L}\p{N}]+`)
func SlugRemark(remark string) string {
s := strings.ToLower(strings.TrimSpace(remark))

View File

@ -0,0 +1,28 @@
package link
import "testing"
// FuzzParseLink asserts the parser never panics and upholds its (result, error) contract
// — exactly one non-nil. It base64-decodes and type-asserts attacker-controllable JSON,
// the classic panic source.
func FuzzParseLink(f *testing.F) {
seeds := []string{
"",
"not-a-link",
"vmess://eyJ2IjoiMiIsInBzIjoidCIsImFkZCI6ImEuY29tIiwicG9ydCI6IjQ0MyIsImlkIjoiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwibmV0IjoidGNwIn0=",
"vless://11111111-2222-4333-8444-555555555555@a.com:443?type=tcp&security=none#x",
"trojan://pass@a.com:443?security=tls#x",
"ss://YWVzLTI1Ni1nY206cGFzcw==@a.com:8388#x",
"hysteria2://pass@a.com:443?sni=a.com#x",
"wireguard://cGsdkey@a.com:51820?publickey=pub#x",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
res, err := ParseLink(s)
if (res == nil) == (err == nil) {
t.Fatalf("ParseLink(%q): exactly one of (result, error) must be non-nil; got res=%v err=%v", s, res, err)
}
})
}

View File

@ -0,0 +1,201 @@
package link
import (
"encoding/base64"
"net/url"
"reflect"
"testing"
)
func TestDefaultPort(t *testing.T) {
cases := []struct {
in string
def int
want int
}{
{"", 443, 443},
{"8080", 443, 8080},
{"0", 443, 443}, // non-positive falls back
{"-1", 443, 443}, // negative falls back
{"abc", 443, 443}, // unparseable falls back
{"65535", 443, 65535},
}
for _, c := range cases {
if got := defaultPort(c.in, c.def); got != c.want {
t.Errorf("defaultPort(%q,%d) = %d, want %d", c.in, c.def, got, c.want)
}
}
}
func TestFirstNonEmptyAndParam(t *testing.T) {
if got := firstNonEmpty("a", "b"); got != "a" {
t.Errorf("firstNonEmpty(a,b) = %q, want a", got)
}
if got := firstNonEmpty("", "b"); got != "b" {
t.Errorf("firstNonEmpty(,b) = %q, want b", got)
}
p := url.Values{"x": {""}, "y": {"hit"}, "z": {"z"}}
if got := firstParam(p, "x", "y", "z"); got != "hit" {
t.Errorf("firstParam = %q, want hit (first non-empty)", got)
}
if got := firstParam(p, "x"); got != "" {
t.Errorf("firstParam(only empty) = %q, want empty", got)
}
}
func TestSplitComma(t *testing.T) {
if got := splitComma(""); got != nil {
t.Errorf("splitComma(empty) = %v, want nil", got)
}
if got := splitComma("a, ,b ,, c"); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Errorf("splitComma trim/skip = %v, want [a b c]", got)
}
if got := splitCommaOrDefault("", []string{"d"}); !reflect.DeepEqual(got, []string{"d"}) {
t.Errorf("splitCommaOrDefault(empty) = %v, want [d]", got)
}
if got := splitCommaOrDefault("x,y", []string{"d"}); !reflect.DeepEqual(got, []string{"x", "y"}) {
t.Errorf("splitCommaOrDefault(x,y) = %v, want [x y]", got)
}
}
func TestPadAndBase64DecodeFlexible(t *testing.T) {
if got := padBase64("abc"); got != "abc=" {
t.Errorf("padBase64(abc) = %q, want abc=", got)
}
if got := padBase64("abcd"); got != "abcd" {
t.Errorf("padBase64(abcd) = %q, want unchanged", got)
}
std := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secret"))
if got, err := base64DecodeFlexible(std); err != nil || got != "aes-256-gcm:secret" {
t.Errorf("base64DecodeFlexible(std) = (%q,%v), want (aes-256-gcm:secret,nil)", got, err)
}
rawURL := base64.RawURLEncoding.EncodeToString([]byte("m:p"))
if got, err := base64DecodeFlexible(rawURL); err != nil || got != "m:p" {
t.Errorf("base64DecodeFlexible(rawurl) = (%q,%v), want (m:p,nil)", got, err)
}
if _, err := base64DecodeFlexible("!!!not!!!"); err == nil {
t.Error("base64DecodeFlexible(garbage) should error")
}
}
func TestDecodeHash(t *testing.T) {
if got := decodeHash(""); got != "" {
t.Errorf("decodeHash(empty) = %q, want empty", got)
}
if got := decodeHash("a%20b"); got != "a b" {
t.Errorf("decodeHash(a%%20b) = %q, want 'a b'", got)
}
if got := decodeHash("plain"); got != "plain" {
t.Errorf("decodeHash(plain) = %q, want plain", got)
}
}
func TestCanonicalQuery_SortsKeys(t *testing.T) {
// unsorted input must come out key-sorted for a stable identity
got := canonicalQuery(url.Values{"c": {"3"}, "a": {"1"}, "b": {"2"}})
if got != "a=1&b=2&c=3" {
t.Fatalf("canonicalQuery = %q, want a=1&b=2&c=3", got)
}
}
// stream navigates res.Outbound["streamSettings"][key] as a map.
func streamSub(t *testing.T, res *ParseResult, key string) map[string]any {
t.Helper()
ss, _ := res.Outbound["streamSettings"].(map[string]any)
m, ok := ss[key].(map[string]any)
if !ok {
t.Fatalf("streamSettings.%s missing/not a map: %#v", key, ss)
}
return m
}
func TestParse_RealitySecurityMapped(t *testing.T) {
res, err := ParseLink("vless://uuid@h.com:443?type=tcp&security=reality&pbk=PBK&sid=SID&sni=SNI&fp=firefox&spx=%2Fspx&pqv=PQV")
if err != nil {
t.Fatalf("parse: %v", err)
}
re := streamSub(t, res, "realitySettings")
for k, want := range map[string]string{"publicKey": "PBK", "shortId": "SID", "serverName": "SNI", "fingerprint": "firefox", "spiderX": "/spx", "mldsa65Verify": "PQV"} {
if re[k] != want {
t.Errorf("realitySettings[%q] = %v, want %q", k, re[k], want)
}
}
}
func TestParse_TLSSecurityMapped(t *testing.T) {
res, err := ParseLink("trojan://pw@h.com:443?type=tcp&security=tls&sni=SNI&fp=chrome&alpn=h2,http/1.1&ech=ECH&pcs=PCS")
if err != nil {
t.Fatalf("parse: %v", err)
}
tls := streamSub(t, res, "tlsSettings")
if tls["serverName"] != "SNI" || tls["fingerprint"] != "chrome" || tls["echConfigList"] != "ECH" || tls["pinnedPeerCertSha256"] != "PCS" {
t.Errorf("tlsSettings fields = %#v", tls)
}
if alpn, _ := tls["alpn"].([]string); !reflect.DeepEqual(alpn, []string{"h2", "http/1.1"}) {
t.Errorf("alpn = %#v, want [h2 http/1.1]", tls["alpn"])
}
}
func TestParse_WSAndGRPCTransport(t *testing.T) {
ws, err := ParseLink("vless://uuid@h.com:443?type=ws&host=H&path=%2Fwspath")
if err != nil {
t.Fatalf("parse ws: %v", err)
}
wss := streamSub(t, ws, "wsSettings")
if wss["host"] != "H" || wss["path"] != "/wspath" {
t.Errorf("wsSettings = %#v, want host=H path=/wspath", wss)
}
grpc, err := ParseLink("vless://uuid@h.com:443?type=grpc&serviceName=svc&authority=auth&mode=multi")
if err != nil {
t.Fatalf("parse grpc: %v", err)
}
gs := streamSub(t, grpc, "grpcSettings")
if gs["serviceName"] != "svc" || gs["authority"] != "auth" || gs["multiMode"] != true {
t.Errorf("grpcSettings = %#v, want serviceName=svc authority=auth multiMode=true", gs)
}
}
func TestParse_TCPHTTPHeader(t *testing.T) {
res, err := ParseLink("vless://uuid@h.com:443?type=tcp&headerType=http&host=ex.com&path=%2F")
if err != nil {
t.Fatalf("parse: %v", err)
}
tcp := streamSub(t, res, "tcpSettings")
header, _ := tcp["header"].(map[string]any)
if header["type"] != "http" {
t.Errorf("tcp header type = %v, want http", header["type"])
}
}
func TestParseVless_CoreFields(t *testing.T) {
res, err := ParseLink("vless://the-uuid@9.9.9.9:8443?type=tcp&security=none&flow=xtls-rprx-vision#tag1")
if err != nil {
t.Fatalf("parse: %v", err)
}
st, _ := res.Outbound["settings"].(map[string]any)
if st["address"] != "9.9.9.9" || st["port"] != 8443 || st["id"] != "the-uuid" || st["flow"] != "xtls-rprx-vision" {
t.Errorf("vless settings = %#v", st)
}
}
func TestParseTrojanAndSS_CoreFields(t *testing.T) {
tr, err := ParseLink("trojan://secret@t.com:443?type=tcp&security=tls#tj")
if err != nil {
t.Fatalf("parse trojan: %v", err)
}
srv := tr.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
if srv["address"] != "t.com" || srv["port"] != 443 || srv["password"] != "secret" {
t.Errorf("trojan server = %#v", srv)
}
ssLink := "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:sspass")) + "@s.com:8388#ss1"
ss, err := ParseLink(ssLink)
if err != nil {
t.Fatalf("parse ss: %v", err)
}
ssrv := ss.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
if ssrv["address"] != "s.com" || ssrv["port"] != 8388 || ssrv["password"] != "sspass" || ssrv["method"] != "aes-256-gcm" {
t.Errorf("ss server = %#v", ssrv)
}
}

View File

@ -59,4 +59,11 @@ func TestSlugAndSuggest(t *testing.T) {
if tag != "hk-sg-01" {
t.Errorf("suggest tag got %q", tag)
}
// Non-ASCII letters/digits are preserved rather than stripped.
if got := SlugRemark("Москва 🇷🇺 01"); got != "москва-01" {
t.Errorf("unicode slug got %q", got)
}
if got := SuggestTag("ru-", "Сервер 2", 0); got != "ru-сервер-2" {
t.Errorf("unicode suggest tag got %q", got)
}
}

View File

@ -2,6 +2,8 @@ package netproxy
import (
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
)
@ -22,6 +24,10 @@ func TestNewHTTPClient(t *testing.T) {
{name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true},
}
// baseTransport clones http.DefaultTransport, whose Proxy and DialContext are already
// non-nil — so "!= nil" can't prove our proxy/dialer was applied. Check the real values.
defaultDialPtr := reflect.ValueOf(http.DefaultTransport.(*http.Transport).DialContext).Pointer()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, err := NewHTTPClient(tc.proxyURL, 5*time.Second)
@ -37,16 +43,36 @@ func TestNewHTTPClient(t *testing.T) {
if client.Timeout != 5*time.Second {
t.Errorf("timeout = %v, want 5s", client.Timeout)
}
// Empty proxyURL → a plain direct client with no custom transport.
if tc.proxyURL == "" {
if client.Transport != nil {
t.Errorf("empty proxy must yield a direct client (nil Transport), got %T", client.Transport)
}
return
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", client.Transport)
}
if tc.wantProxy {
transport, ok := client.Transport.(*http.Transport)
if !ok || transport.Proxy == nil {
t.Errorf("expected transport with Proxy set for %q", tc.proxyURL)
// Prove the CONFIGURED proxy is applied: transport.Proxy(req) must
// return our URL, not the cloned default's ProxyFromEnvironment.
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
u, perr := transport.Proxy(req)
if perr != nil {
t.Fatalf("transport.Proxy returned error: %v", perr)
}
if u == nil || u.String() != tc.proxyURL {
t.Errorf("transport.Proxy(req) = %v, want %q (configured proxy not applied)", u, tc.proxyURL)
}
}
if tc.wantDial {
transport, ok := client.Transport.(*http.Transport)
if !ok || transport.DialContext == nil {
t.Errorf("expected transport with DialContext set for %q", tc.proxyURL)
if transport.DialContext == nil {
t.Fatal("DialContext is nil")
}
// Must be the socks5 dialer, not the cloned default DialContext.
if reflect.ValueOf(transport.DialContext).Pointer() == defaultDialPtr {
t.Error("DialContext is still the default; socks5 dialer was not applied")
}
}
})

View File

@ -0,0 +1,102 @@
package netsafe
import (
"context"
"strings"
"testing"
)
// TestSSRFGuardedDialContext_LiteralIPSkipsResolver pins the netsafe.go:37
// decision (`if ip := net.ParseIP(host); ip != nil`). The string "fe80::1%eth0"
// is rejected by net.ParseIP (returns nil) but accepted by the resolver, which
// yields the link-local address fe80::1. With the branch intact, ParseIP returns
// nil so the host falls through to LookupIPAddr, resolves to fe80::1, and is
// blocked by IsBlockedIP -> the error mentions the resolved blocked address.
// If the condition is flipped to `ip == nil`, the nil-IP literal path is taken
// instead: ips = [{IP: nil}], IsBlockedIP(nil) is false, the guard never fires
// and the error would never say "blocked private/internal address fe80::1".
func TestSSRFGuardedDialContext_LiteralIPSkipsResolver(t *testing.T) {
_, err := SSRFGuardedDialContext(context.Background(), "tcp", "[fe80::1%eth0]:80")
if err == nil {
t.Fatal("expected error for link-local host with zone suffix")
}
if !strings.Contains(err.Error(), "blocked private/internal address fe80::1") {
t.Fatalf("expected guard to block resolved link-local fe80::1, got: %v", err)
}
}
// TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked complements the above by
// confirming that a valid IP literal (parsed by the line 37 branch) is still run
// through IsBlockedIP and rejected with the literal in the message.
func TestSSRFGuardedDialContext_LiteralPrivateIPv6Blocked(t *testing.T) {
_, err := SSRFGuardedDialContext(context.Background(), "tcp", "[::1]:80")
if err == nil {
t.Fatal("expected dial to ::1 to be blocked")
}
if !strings.Contains(err.Error(), "blocked private/internal address ::1") {
t.Fatalf("expected '::1' literal in blocked error, got: %v", err)
}
}
// TestNormalizeHost_LengthBoundary pins the netsafe.go:76 length check
// (`len(addr) > 253`). A valid-pattern hostname of exactly 253 chars must be
// accepted (kills `>` -> `>=` / off-by-one mutations of the bound), while the
// same hostname at 254 chars must be rejected.
func TestNormalizeHost_LengthBoundary(t *testing.T) {
label := strings.Repeat("a", 61)
base := label + "." + label + "." + label + "." // 186 chars, valid pattern
h253 := base + strings.Repeat("a", 253-len(base))
if len(h253) != 253 {
t.Fatalf("test setup: expected 253-char host, got %d", len(h253))
}
h254 := h253 + "a"
got, err := NormalizeHost(h253)
if err != nil {
t.Fatalf("NormalizeHost(253-char valid host) returned error: %v", err)
}
if got != h253 {
t.Fatalf("NormalizeHost(253-char host) = %q, want unchanged input", got)
}
if _, err := NormalizeHost(h254); err == nil {
t.Fatal("NormalizeHost(254-char host) expected error, got nil")
}
}
// TestNormalizeHost_PatternClauseIndependentOfLength pins the OR in line 76:
// a short hostname (well under the 253 limit) that violates the pattern must
// still be rejected. If `||` were mutated to `&&`, this short-but-invalid host
// would slip through because the length clause is false.
func TestNormalizeHost_PatternClauseIndependentOfLength(t *testing.T) {
cases := []string{
"under_score.example.com",
"bad host",
"exa$mple.com",
"-leadingdash.com",
}
for _, in := range cases {
t.Run(in, func(t *testing.T) {
if len(in) > 253 {
t.Fatalf("test setup: %q should be short to isolate the pattern clause", in)
}
if _, err := NormalizeHost(in); err == nil {
t.Fatalf("NormalizeHost(%q) expected error for invalid pattern, got nil", in)
}
})
}
}
// TestNormalizeHost_ValidShortHostAccepted ensures a short valid-pattern host is
// accepted, so a mutation dropping the `!` on the pattern match (rejecting valid
// hosts) is caught alongside the rejection cases above.
func TestNormalizeHost_ValidShortHostAccepted(t *testing.T) {
const in = "node-1.example.com"
got, err := NormalizeHost(in)
if err != nil {
t.Fatalf("NormalizeHost(%q) returned error: %v", in, err)
}
if got != in {
t.Fatalf("NormalizeHost(%q) = %q, want %q", in, got, in)
}
}

View File

@ -0,0 +1,43 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// MaxBodyBytes caps the request body size for state-changing requests. It wraps
// the body in an http.MaxBytesReader so that any handler reading it (gin's
// ShouldBind, manual io.ReadAll, etc.) receives an error once the limit is
// exceeded, which the existing bind-failure path reports as a 400 rather than
// allocating an unbounded buffer or starting a long DB transaction.
//
// Methods without a body (GET/HEAD/OPTIONS/TRACE) and a non-positive limit are
// passed through untouched. Paths ending in one of skipSuffixes are also passed
// through uncapped — these are routes that legitimately accept a large upload
// (e.g. database restore, which streams a multi-MiB SQLite file).
func MaxBodyBytes(limit int64, skipSuffixes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
if limit > 0 {
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
default:
if c.Request.Body != nil && !hasSuffix(c.Request.URL.Path, skipSuffixes) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
}
}
}
c.Next()
}
}
// hasSuffix reports whether path ends in any of the given suffixes.
func hasSuffix(path string, suffixes []string) bool {
for _, s := range suffixes {
if strings.HasSuffix(path, s) {
return true
}
}
return false
}

View File

@ -0,0 +1,80 @@
package middleware
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestMaxBodyBytes(t *testing.T) {
gin.SetMode(gin.TestMode)
const limit = 16
r := gin.New()
r.Use(MaxBodyBytes(limit))
r.POST("/x", func(c *gin.Context) {
if _, err := io.ReadAll(c.Request.Body); err != nil {
c.String(http.StatusRequestEntityTooLarge, "too big")
return
}
c.String(http.StatusOK, "ok")
})
r.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
// Body within the limit is read normally.
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", strings.NewReader("0123456789")))
if w.Code != http.StatusOK {
t.Errorf("under-limit POST: got %d, want 200", w.Code)
}
// Body over the limit makes the handler's read fail (no unbounded buffer).
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(make([]byte, limit*4))))
if w.Code == http.StatusOK {
t.Errorf("over-limit POST should not succeed, got 200")
}
// Bodyless methods pass through untouched.
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
if w.Code != http.StatusOK {
t.Errorf("GET should pass through, got %d", w.Code)
}
}
func TestMaxBodyBytesSkipSuffix(t *testing.T) {
gin.SetMode(gin.TestMode)
const limit = 16
r := gin.New()
r.Use(MaxBodyBytes(limit, "/server/importDB"))
read := func(c *gin.Context) {
if _, err := io.ReadAll(c.Request.Body); err != nil {
c.String(http.StatusRequestEntityTooLarge, "too big")
return
}
c.String(http.StatusOK, "ok")
}
r.POST("/server/importDB", read)
r.POST("/x", read)
// Exempt route reads an over-limit body without error.
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/server/importDB", bytes.NewReader(make([]byte, limit*4))))
if w.Code != http.StatusOK {
t.Errorf("exempt route should pass through over-limit body, got %d", w.Code)
}
// Non-exempt route is still capped.
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(make([]byte, limit*4))))
if w.Code == http.StatusOK {
t.Errorf("non-exempt over-limit POST should not succeed, got 200")
}
}

View File

@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@ -80,7 +81,9 @@ func TestSecurityHeadersMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeadersMiddleware(true))
var capturedNonce string
router.GET("/", func(c *gin.Context) {
capturedNonce = c.GetString("csp_nonce")
c.String(http.StatusOK, "ok")
})
@ -101,6 +104,33 @@ func TestSecurityHeadersMiddleware(t *testing.T) {
if got := headers.Get("Strict-Transport-Security"); got == "" {
t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
}
// CSP is the highest-value header here: assert it stays nonce-bound with its hardening
// directives, so weakening it (unsafe-inline, dropped frame-ancestors, broken nonce) fails.
csp := headers.Get("Content-Security-Policy")
if csp == "" {
t.Fatal("Content-Security-Policy header must be set")
}
if capturedNonce == "" {
t.Fatal("csp_nonce context value must be set (the injected inline script reads it)")
}
if want := "script-src 'self' 'nonce-" + capturedNonce + "'"; !strings.Contains(csp, want) {
t.Fatalf("CSP script-src must be bound to the per-request nonce %q; got %q", want, csp)
}
for _, directive := range []string{"object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'"} {
if !strings.Contains(csp, directive) {
t.Errorf("CSP missing hardening directive %q; got %q", directive, csp)
}
}
// script-src must NOT allow 'unsafe-inline' (it would defeat the nonce). Check the
// script-src directive in isolation, since style-src legitimately uses unsafe-inline.
scriptDir := csp[strings.Index(csp, "script-src"):]
if i := strings.Index(scriptDir, ";"); i >= 0 {
scriptDir = scriptDir[:i]
}
if strings.Contains(scriptDir, "unsafe-inline") {
t.Errorf("CSP script-src must not allow 'unsafe-inline': %q", scriptDir)
}
}
func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {

View File

@ -0,0 +1,99 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// The accept side of validate.go:45 — `c.ShouldBindWith(&dst, binding.JSON)` must SUCCEED
// for a well-formed JSON body and decode it into the destination struct. If the conditional
// is flipped (err != nil -> err == nil) or the bind call is dropped, a valid body would be
// rejected or the fields would come back zero-valued; both fail these assertions.
func TestBindJSONAndValidate_ValidJSONDecodesAndPasses(t *testing.T) {
var got *sampleBody
r := newRouter(func(c *gin.Context) {
var ok bool
got, ok = BindJSONAndValidate[sampleBody](c)
if !ok {
t.Fatalf("expected ok=true for valid JSON; got false")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if got == nil {
t.Fatal("expected decoded struct; got nil")
}
if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
t.Fatalf("decoded JSON mismatch: %+v", got)
}
}
// The reject side of validate.go:45 — a malformed JSON body must be caught by the bind
// conditional, returning (nil,false) with a parse-error Message and NO validator Issues.
// If the conditional is flipped so malformed input bypasses the bind check, control falls
// through to validate.Struct on a zero-valued struct, which would instead emit validator
// Issues (e.g. rule="required"/"gte"). Asserting empty Issues + non-empty Message pins the
// distinct parse-failure path that line 45 owns.
func TestBindJSONAndValidate_MalformedJSONRejectedWithoutValidatorIssues(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindJSONAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on malformed JSON; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
if msg.Success {
t.Fatal("expected Success=false on malformed JSON")
}
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
if len(payload.Issues) != 0 {
t.Fatalf("expected empty Issues for a JSON parse error (not validator output); got %+v", payload.Issues)
}
if payload.Message == "" {
t.Fatal("expected non-empty Message describing the JSON parse error")
}
}
// BindJSONAndValidateInto shares the same line-45-style bind conditional (line 57). Cover its
// accept side: a valid JSON body must bind onto the caller-supplied destination and pass,
// overwriting any pre-populated field. A flipped/dropped bind check leaves the destination
// untouched (or returns false), which these assertions catch.
func TestBindJSONAndValidateInto_ValidJSONBindsOntoDestination(t *testing.T) {
dst := &sampleBody{Tag: "preset"}
r := newRouter(func(c *gin.Context) {
if !BindJSONAndValidateInto(c, dst) {
t.Fatal("expected ok=true for valid JSON; got false")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":8443,"protocol":"trojan","tag":"inbound-8443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if dst.Port != 8443 || dst.Protocol != "trojan" {
t.Fatalf("expected JSON to bind onto destination; got %+v", dst)
}
if dst.Tag != "inbound-8443" {
t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
}
}

View File

@ -87,9 +87,6 @@ func (r *Remote) baseURL() (string, error) {
return "", fmt.Errorf("invalid node port %d", r.node.Port)
}
bp := r.node.BasePath
if bp == "" {
bp = "/"
}
if !strings.HasSuffix(bp, "/") {
bp += "/"
}
@ -342,7 +339,10 @@ func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string
}
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return nil
// Can't confirm the delete reached the node — surface it so the caller
// marks the node dirty and a reconcile converges, instead of silently
// dropping the delete and letting the next snapshot resurrect the client.
return fmt.Errorf("remote DeleteUser: resolve tag %q: %w", ib.Tag, err)
}
body := map[string]any{"inboundIds": []int{id}}
_, err = r.do(ctx, http.MethodPost,

View File

@ -1,12 +1,20 @@
package runtime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type stubEgress struct{ url string }
func (s stubEgress) NodeEgressProxyURL(int) string { return s.url }
// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
// sits on only one side: the node may store the bare tag while the central
// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
@ -50,6 +58,115 @@ func TestWireInboundIncludesShareAddressFields(t *testing.T) {
}
}
func TestRemoteHTTPClientEgressProxy(t *testing.T) {
// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})
c, err := withTag.httpClient()
if err != nil {
t.Fatalf("httpClient: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default")
}
// No OutboundTag → no egress proxy → shared default client (verify mode).
noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"})
c2, err := noTag.httpClient()
if err != nil {
t.Fatalf("httpClient: %v", err)
}
if c2 != defaultNodeHTTPClient {
t.Fatal("no OutboundTag must use the shared default client")
}
}
func TestRemoteDoSetsContentType(t *testing.T) {
var gotCT string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCT = r.Header.Get("Content-Type")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true}`))
}))
defer srv.Close()
r := NewRemote(nodeForServer(t, srv, "skip", ""), nil)
if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil {
t.Fatalf("do: %v", err)
}
if gotCT != "application/x-www-form-urlencoded" {
t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT)
}
}
func TestRemoteBaseURL(t *testing.T) {
cases := []struct {
name string
scheme string
port int
bp string
want string
wantErr bool
}{
{"https default path", "https", 443, "", "https://example.com:443/", false},
{"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false},
{"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false},
{"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false},
{"port zero rejected", "https", 0, "/", "", true},
{"port above range rejected", "https", 65536, "/", "", true},
{"negative port rejected", "https", -1, "/", "", true},
{"max port accepted", "https", 65535, "/", "https://example.com:65535/", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil)
got, err := r.baseURL()
if c.wantErr {
if err == nil {
t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Fatalf("baseURL = %q, want %q", got, c.want)
}
})
}
}
func TestIsNonEmptySlice(t *testing.T) {
cases := []struct {
name string
in any
want bool
}{
{"non-empty slice", []any{1}, true},
{"empty slice", []any{}, false},
{"nil slice", []any(nil), false},
{"not a slice", "x", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := isNonEmptySlice(c.in); got != c.want {
t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want)
}
})
}
}
func TestWireInboundTrafficReset(t *testing.T) {
with := wireInbound(&model.Inbound{TrafficReset: "daily"})
if got := with.Get("trafficReset"); got != "daily" {
t.Fatalf("trafficReset = %q, want daily", got)
}
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
without := wireInbound(&model.Inbound{})
if without.Has("trafficReset") {
t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset"))
}
}
func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) {
values := wireInbound(&model.Inbound{})

View File

@ -0,0 +1,73 @@
package runtime
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"pgregory.net/rapid"
)
func insertColons(h string) string {
var b strings.Builder
for i := 0; i < len(h); i += 2 {
if i > 0 {
b.WriteByte(':')
}
b.WriteString(h[i : i+2])
}
return b.String()
}
// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every
// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes
// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant
// that breaks one decoding path is caught across the whole input space.
func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw")
hx := hex.EncodeToString(raw)
forms := []string{
hx,
strings.ToUpper(hx),
insertColons(hx),
base64.StdEncoding.EncodeToString(raw),
base64.RawStdEncoding.EncodeToString(raw),
base64.URLEncoding.EncodeToString(raw),
base64.RawURLEncoding.EncodeToString(raw),
}
for _, f := range forms {
got, err := DecodeCertPin(f)
if err != nil {
t.Fatalf("DecodeCertPin(%q) errored: %v", f, err)
}
if !bytes.Equal(got, raw) {
t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw)
}
}
})
}
// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never
// returns a non-32-byte slice with a nil error, and never returns bytes alongside an
// error. Seeded from the known-good/known-bad cases.
func FuzzDecodeCertPin(f *testing.F) {
seed := sha256.Sum256([]byte("seed"))
f.Add(hex.EncodeToString(seed[:]))
f.Add(base64.StdEncoding.EncodeToString(seed[:]))
f.Add(insertColons(hex.EncodeToString(seed[:])))
f.Add("")
f.Add("not-a-pin")
f.Fuzz(func(t *testing.T, s string) {
got, err := DecodeCertPin(s)
if err == nil && len(got) != sha256.Size {
t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size)
}
if err != nil && got != nil {
t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got)
}
})
}

View File

@ -116,8 +116,67 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
}
func TestHTTPClientForNodePinInvalid(t *testing.T) {
if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}, ""); err == nil {
t.Fatal("expected error for invalid pin")
// pin mode must fail closed, and with a specific error per cause — not merely
// "some error" (which a bug anywhere in the build path would also satisfy).
cases := []struct {
name string
pin string
wantErr string
}{
{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
{"empty pin", "", "certificate pin is empty"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
if err == nil {
t.Fatalf("expected error for pin %q", c.pin)
}
if !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
}
})
}
}
// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("proxy client must not be the shared default client")
}
tr, ok := c.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport is %T, want *http.Transport", c.Transport)
}
if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
}
}
// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
// using system-CA verification and NOT a pin VerifyConnection.
func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
if err != nil {
t.Fatalf("HTTPClientForNode: %v", err)
}
if c == defaultNodeHTTPClient {
t.Fatal("proxy client must not be the shared default client")
}
if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
t.Fatal("verify mode must not install a pin VerifyConnection")
}
}

View File

@ -742,15 +742,17 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
}
}
if needApiDel {
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
return false, perr
}
if dirty {
markDirty = true
}
if oldInbound.NodeID == nil {
if oldInbound.NodeID == nil {
// Local inbound: a disabled client isn't in the running Xray, so only
// a live one (needApiDel) needs an API removal.
if needApiDel {
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
return false, perr
}
if dirty {
markDirty = true
}
if !push {
needRestart = true
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
@ -762,7 +764,19 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
logger.Debug("Error in deleting client on", rt.Name(), ":", email)
needRestart = true
}
} else if push {
}
} else {
// Node inbound: propagate the delete regardless of the enable flag —
// the node's own DB still carries a disabled client and would
// resurrect it on the next snapshot otherwise.
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
return false, perr
}
if dirty {
markDirty = true
}
if push {
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
markDirty = true

View File

@ -244,7 +244,7 @@ func (s *InboundService) MigrationRequirements() {
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE position('0.0.0.0:' in tag) > 0;`
}
err = tx.Raw(tagCleanup).Error
err = tx.Exec(tagCleanup).Error
if err != nil {
return
}

View File

@ -90,6 +90,45 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *
}
}
// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
// externalProxy detection query returns rows and the cleanup is reached (it early-returns
// at len(externalProxy)==0 otherwise). The cleanup must use tx.Exec, not tx.Raw, which
// only builds a non-SELECT statement without running it.
func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
legacy := &model.Inbound{
UserId: 1,
Tag: "inbound-0.0.0.0:30002",
Enable: true,
Port: 30002,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
}
if err := db.Create(legacy).Error; err != nil {
t.Fatalf("create legacy inbound: %v", err)
}
svc := InboundService{}
svc.MigrationRequirements()
var got model.Inbound
if err := db.First(&got, legacy.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.Tag != "inbound-30002" {
t.Fatalf("legacy 0.0.0.0: tag not stripped: got %q, want %q", got.Tag, "inbound-30002")
}
}
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()

View File

@ -153,6 +153,25 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
}
// mergeActivationExpiry reconciles a node-reported client expiry with the value
// already stored on the master. "Start after first connect" persists a negative
// duration that each node converts to an absolute deadline (now+duration) the
// first time the client connects there. The per-email client_traffics row is
// shared across every node, so a node that has not yet seen a first connection
// keeps reporting the negative duration — which must never reset a deadline
// another node already activated.
//
// A node may legitimately move an already-activated deadline forward (traffic
// reset / auto-renew extends it), so any positive node value is still adopted —
// only an un-activated (<= 0) value is rejected once an absolute deadline
// exists. Kept in lockstep with the SQL CASE in setRemoteTrafficLocked.
func mergeActivationExpiry(existing, node int64) int64 {
if existing > 0 && node <= 0 {
return existing
}
return node
}
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
var structuralChange bool
err := submitTrafficWrite(func() error {
@ -268,13 +287,28 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// entirely — an email whose stats moved to (or always lived under) a
// sibling inbound still needs its baseline for the sibling's delta
// computation (#5202).
//
// Xray counts traffic per email, not per inbound, so a multi-attached
// client's shared counter is copied onto every inbound it's on. Fold each
// email to its per-field max (nodeEmailTotals) so divergent copies can't make
// the reset clamp re-add a lower sibling as fresh traffic (#5274).
snapEmailsAll := make(map[string]struct{})
nodeEmailTotals := make(map[string]nodeTrafficCounter)
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
}
for i := range snapIb.ClientStats {
snapEmailsAll[snapIb.ClientStats[i].Email] = struct{}{}
email := snapIb.ClientStats[i].Email
snapEmailsAll[email] = struct{}{}
cur := nodeEmailTotals[email]
if snapIb.ClientStats[i].Up > cur.Up {
cur.Up = snapIb.ClientStats[i].Up
}
if snapIb.ClientStats[i].Down > cur.Down {
cur.Down = snapIb.ClientStats[i].Down
}
nodeEmailTotals[email] = cur
}
}
@ -500,14 +534,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
for _, cs := range snapIb.ClientStats {
snapEmails[cs.Email] = struct{}{}
// Node-wide total, not this inbound's possibly-stale copy (#5274).
canon := nodeEmailTotals[cs.Email]
base, seen := nodeBaselines[cs.Email]
var deltaUp, deltaDown int64
if seen {
if deltaUp = cs.Up - base.Up; deltaUp < 0 {
deltaUp = cs.Up
if deltaUp = canon.Up - base.Up; deltaUp < 0 {
deltaUp = canon.Up
}
if deltaDown = cs.Down - base.Down; deltaDown < 0 {
deltaDown = cs.Down
if deltaDown = canon.Down - base.Down; deltaDown < 0 {
deltaDown = canon.Down
}
}
@ -522,8 +559,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
Total: cs.Total,
ExpiryTime: cs.ExpiryTime,
Reset: cs.Reset,
Up: cs.Up,
Down: cs.Down,
Up: canon.Up,
Down: canon.Down,
LastOnline: cs.LastOnline,
}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).
@ -534,40 +571,49 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
centralCSByEmail[cs.Email] = row
existingEmails[cs.Email] = struct{}{}
structuralChange = true
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
return false, err
}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: canon.Up, Down: canon.Down}
continue
}
if existing := centralCSByEmail[cs.Email]; existing != nil &&
(existing.Enable != cs.Enable ||
existing.Total != cs.Total ||
existing.ExpiryTime != cs.ExpiryTime ||
existing.ExpiryTime != mergeActivationExpiry(existing.ExpiryTime, cs.ExpiryTime) ||
existing.Reset != cs.Reset) {
structuralChange = true
}
enableExpr := database.ClientTrafficEnableMergeExpr()
// expiry_time merge mirrors mergeActivationExpiry: a node that has not
// yet seen the client's first connection keeps reporting the negative
// "start after first connect" duration, which must never reset the
// absolute deadline another node already activated. A positive node
// value is still adopted (e.g. auto-renew moves the deadline forward).
// CAST(? AS BIGINT): in the `<= 0` comparison Postgres would otherwise
// infer int4 from the literal and overflow on real expiry values.
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
last_online = %s
SET up = up + ?, down = down + ?, enable = %s, total = ?,
expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END,
reset = ?, last_online = %s
WHERE email = ?`,
enableExpr,
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
deltaUp, deltaDown, cs.Enable, cs.Total,
cs.ExpiryTime, cs.ExpiryTime, cs.Reset,
cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
}
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
return false, err
}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: canon.Up, Down: canon.Down}
}
for k, existing := range centralCS {

View File

@ -0,0 +1,141 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// TestMergeActivationExpiry covers the pure reconciliation rule in isolation.
func TestMergeActivationExpiry(t *testing.T) {
const (
dur = int64(-2592000000) // 30 days as a "start after first connect" duration
early = int64(1000) // earliest absolute deadline (first connection)
late = int64(2000) // a later absolute deadline
)
cases := []struct {
name string
existing, node int64
want int64
}{
{"master unset takes node duration", 0, dur, dur},
{"master unset takes node activation", 0, early, early},
{"activation adopted over stored duration", dur, early, early},
{"node still un-activated does not reset deadline", early, dur, early},
{"node un-activated zero does not reset deadline", early, 0, early},
{"node renewal extends the deadline forward", early, late, late},
{"node positive adopted even if earlier", late, early, early},
{"both un-activated keep node value", dur, dur, dur},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := mergeActivationExpiry(c.existing, c.node); got != c.want {
t.Fatalf("mergeActivationExpiry(%d,%d) = %d, want %d", c.existing, c.node, got, c.want)
}
})
}
}
// TestNodeFirstConnectExpiry_NotClobbered reproduces the multi-node bug: a
// client is attached to inbounds on two nodes with a "start after first connect"
// expiry. The client connects only on node 1, which activates an absolute
// deadline; node 2 never sees a connection and keeps reporting the negative
// duration. The shared per-email client_traffics row must hold the activated
// deadline — a later node-2 sync must not reset it back to "not started".
func TestNodeFirstConnectExpiry_NotClobbered(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
createNodeInbound(t, db, 2, "n2-in", 41002)
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000) // 30 days, not yet started
// Both nodes start out reporting the un-activated negative duration.
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != duration {
t.Fatalf("before any connection: expiry = %d, want %d", got, duration)
}
// Client connects on node 1: it activates an absolute deadline.
const activated = int64(1893456000000) // some absolute ms timestamp
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
}
// Node 2 (no connection there) keeps reporting the negative duration. This
// must NOT reset the activated deadline.
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("node 2 clobbered the activated deadline: expiry = %d, want %d", got, activated)
}
// Subsequent node 1 syncs keep the same absolute deadline.
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("after further node 1 sync: expiry = %d, want %d", got, activated)
}
}
// TestNodeFirstConnectExpiry_NotClobbered_WithSettings exercises the full
// production sync path — snapshots carrying real settings JSON, which drives the
// GetClients/SyncInbound branch inside setRemoteTrafficLocked — to prove that
// branch does not re-derive the per-email client_traffics.expiry_time from the
// node's (still negative) settings and undo the merge guard.
func TestNodeFirstConnectExpiry_NotClobbered_WithSettings(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "delayed")
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000)
const activated = int64(1893456000000)
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
actSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":1893456000000}]}`
// Both nodes start un-activated.
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
// Node 1 activates (both its ClientStats and its settings now carry the
// absolute deadline, like a real node after adjustTraffics).
syncNodeWithSettings(t, svc, 1, "n1-in", actSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated)
}
// Node 2 still reports the negative duration in BOTH ClientStats and
// settings. Neither the merge nor SyncInbound may reset the deadline.
syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("node 2 settings-sync clobbered the deadline: expiry = %d, want %d", got, activated)
}
}
// TestNodeRenewExtendsExpiry guards against over-correcting: a node that renews
// a client (traffic reset / auto-renew) legitimately moves the deadline FORWARD
// to a later absolute timestamp, and that must still propagate to the master.
// The guard only rejects un-activated (<= 0) values, never a positive one.
func TestNodeRenewExtendsExpiry(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "renewing"
const first = int64(1893456000000)
const renewed = first + int64(2592000000) // +30 days after auto-renew
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 10, Down: 10, ExpiryTime: first, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != first {
t.Fatalf("after activation: expiry = %d, want %d", got, first)
}
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 20, Down: 20, ExpiryTime: renewed, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != renewed {
t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
}
}

View File

@ -274,6 +274,49 @@ func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) {
assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives")
}
// TestMultiAttach_SameNode_DivergentSiblings reproduces #5274: a client is
// attached to several inbounds of the SAME node. Xray reports client traffic
// globally per email, so the node's enriched inbound list copies one shared
// counter onto every inbound the client is on. When those copies diverge — a
// legacy per-inbound row surviving the v3.2.x→v3.3.x upgrade, or any drift —
// the per-inbound delta loop used to treat the lower sibling as a node-counter
// reset and re-add its full value, inflating the client far past real usage.
// The merge must collapse the email to its node-wide total and count it once.
func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "multi")
createNodeInboundWithClient(t, db, 1, "n1-b", 41002, "multi")
createNodeInboundWithClient(t, db, 1, "n1-c", 41003, "multi")
svc := &InboundService{}
const email = "multi"
settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email)
// The three inbounds report the same email with diverging values; the
// node's true per-email total is the largest (the shared global counter).
sync := func(a, b, c int64) {
t.Helper()
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
{Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: a, Down: a, Enable: true}}},
{Tag: "n1-b", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: b, Down: b, Enable: true}}},
{Tag: "n1-c", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: c, Down: c, Enable: true}}},
}}
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
t.Fatalf("sync: %v", err)
}
}
sync(100, 50, 80)
assertUpDown(t, readTraffic(t, db, email), 100, 100, "first sync counts the node total once, not the sum")
sync(150, 60, 90)
assertUpDown(t, readTraffic(t, db, email), 150, 150, "second sync: grew by 50, not by every sibling")
// Equal siblings (the healthy current-schema case) must still accrue once.
sync(200, 200, 200)
assertUpDown(t, readTraffic(t, db, email), 200, 200, "equal siblings accrue the single increment")
}
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}

View File

@ -65,6 +65,48 @@ func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
}
}
// Deleting a *disabled* client attached to a node inbound must still propagate
// to the node. The node's own DB carries the (disabled) client, so the central
// panel has to mark the node dirty (→ reconcile) instead of dropping the delete
// and letting the next traffic snapshot resurrect the client. Regression for
// the enable-flag gate that used to skip the node path entirely (#5352).
func TestDelInboundClientByEmail_DisabledNodeClientMarksDirty(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
// Offline node so nodePushPlan reports dirty without needing a live runtime.
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "offline"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
id := node.Id
central := &model.Inbound{
UserId: 1,
NodeID: &id,
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"a@x","enable":false}]}`,
}
if err := db.Create(central).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
if _, err := clientSvc.DelInboundClientByEmail(inboundSvc, central.Id, "a@x", false); err != nil {
t.Fatalf("DelInboundClientByEmail: %v", err)
}
if _, _, dirty, _, err := (&NodeService{}).NodeSyncState(id); err != nil {
t.Fatalf("NodeSyncState: %v", err)
} else if !dirty {
t.Fatal("deleting a disabled node client must mark the node dirty (#5352)")
}
}
// ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
// edit that re-dirties the node during a reconcile is not silently cleared.
func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {

View File

@ -115,8 +115,11 @@ func (d *portConflictDetail) String() string {
}
if name == "" {
name = fmt.Sprintf("#%d", d.InboundID)
} else {
} else if d.InboundID > 0 {
name = fmt.Sprintf("'%s' (#%d)", name, d.InboundID)
} else {
// reserved/system inbounds (e.g. the Xray API) have no DB id.
name = fmt.Sprintf("'%s'", name)
}
listen := d.Listen
if isAnyListen(listen) {
@ -126,7 +129,52 @@ func (d *portConflictDetail) String() string {
d.Port, transportTagSuffix(d.Transports), name, listen)
}
// defaultXrayAPIPort is the loopback port of the internal Xray API inbound
// (tag "api") seeded into the config template. Used as a fallback when the
// template can't be parsed.
const defaultXrayAPIPort = 62789
// reservedAPIPort returns the port of the internal Xray API inbound declared
// in the config template, falling back to defaultXrayAPIPort.
func reservedAPIPort() int {
tmpl, err := (&SettingService{}).GetXrayConfigTemplate()
if err != nil || tmpl == "" {
return defaultXrayAPIPort
}
var parsed struct {
Inbounds []struct {
Port int `json:"port"`
Tag string `json:"tag"`
} `json:"inbounds"`
}
if json.Unmarshal([]byte(tmpl), &parsed) != nil {
return defaultXrayAPIPort
}
for _, in := range parsed.Inbounds {
if in.Tag == "api" && in.Port > 0 {
return in.Port
}
}
return defaultXrayAPIPort
}
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
// The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row,
// so a local user inbound reusing its port would leave Xray binding the
// port twice (#5304). Nodes run their own Xray, so this only applies to
// the local panel.
if inbound.NodeID == nil && inbound.Port == reservedAPIPort() &&
newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
return &portConflictDetail{
Tag: "api",
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: transportTCP,
}, nil
}
db := database.GetDB()
var candidates []*model.Inbound
@ -138,7 +186,6 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
return nil, err
}
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
for _, c := range candidates {
if !sameNode(c.NodeID, inbound.NodeID) {
continue

View File

@ -669,3 +669,65 @@ func TestIsAutoGeneratedTag(t *testing.T) {
})
}
}
// the internal Xray API inbound (tag "api", loopback TCP) isn't a DB row, so
// checkPortConflict must still reject a local user inbound that reuses its
// reserved port — otherwise Xray binds the port twice (#5304).
func TestCheckPortConflict_ReservedAPIPortBlockedLocal(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "user-62789",
Listen: "0.0.0.0",
Port: defaultXrayAPIPort,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
}
got, err := svc.checkPortConflict(candidate, 0)
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if got == nil {
t.Fatalf("local inbound on the reserved API port %d must conflict", defaultXrayAPIPort)
}
if msg := got.String(); !strings.Contains(msg, "api") {
t.Fatalf("conflict message should name the api inbound; got %q", msg)
}
}
// nodes run their own Xray with their own API port, so a node inbound on the
// central panel's reserved API port must be allowed.
func TestCheckPortConflict_ReservedAPIPortAllowedOnNode(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "node-62789",
Listen: "0.0.0.0",
Port: defaultXrayAPIPort,
Protocol: model.VLESS,
StreamSettings: `{"network":"tcp"}`,
NodeID: intPtr(1),
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("node inbound on the reserved API port must be allowed; got=%v err=%v", got, err)
}
}
// the API inbound is TCP-only, so a UDP-only inbound (e.g. hysteria) may share
// its port — same tcp/udp coexistence the rest of the checks allow.
func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "hyst-62789",
Listen: "0.0.0.0",
Port: defaultXrayAPIPort,
Protocol: model.Hysteria,
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err)
}
}

View File

@ -153,6 +153,15 @@ func (s *Server) initRouter() (*gin.Engine, error) {
sendHSTS := directHTTPS && !config.IsSkipHSTS()
engine.Use(middleware.SecurityHeadersMiddleware(sendHSTS))
// Cap request bodies on state-changing requests so a stolen session/API
// token or a buggy client can't force large allocations or long DB
// transactions via bulk create/attach/import endpoints. GET/HEAD/OPTIONS
// carry no body and are left untouched. importDB restores a full SQLite
// backup that legitimately exceeds the cap, so it's exempt. Follow-up: make
// the limit a setting.
const maxRequestBodyBytes = 10 << 20 // 10 MiB
engine.Use(middleware.MaxBodyBytes(maxRequestBodyBytes, "/panel/api/server/importDB"))
webDomain, err := s.settingService.GetWebDomain()
if err != nil {
return nil, err

View File

@ -0,0 +1,345 @@
package xray
import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
)
// ---------------------------------------------------------------------------
// hot_diff.go mutation audits
// ---------------------------------------------------------------------------
// TestDiffOutbounds_EmptyOutboundsNoPanic pins hot_diff.go:154 — the
// `len(oldOut) > 0` guard that protects the oldOut[0]/newOut[0] index. With no
// outbounds on either side the first-outbound identity check must be SKIPPED
// (an empty hot diff), never executed; a mutated guard (`>= 0`) would index a
// nil slice and panic.
func TestDiffOutbounds_EmptyOutboundsNoPanic(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.OutboundConfigs = nil
newCfg := makeHotConfig()
newCfg.OutboundConfigs = nil
diff, ok := ComputeHotDiff(oldCfg, newCfg)
if !ok {
t.Fatal("identical empty-outbound configs must be hot-appliable")
}
if len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
t.Fatalf("no outbounds on either side must yield no outbound ops, got %+v", diff)
}
}
// TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart pins the other side
// of the hot_diff.go:154 boundary. With exactly ONE outbound, changing its
// content touches the default (first) handler, which has no replace API — it
// must force a restart. A mutated guard (`> 1`) would skip the first-outbound
// check at this length and wrongly classify the change as hot-appliable.
func TestDiffOutbounds_SingleFirstOutboundChangeNeedsRestart(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"}]`)
newCfg := makeHotConfig()
newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"}]`)
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("changing the only (default) outbound must force a restart")
}
}
// TestRoutingWithoutReloadable_EmptyInput pins hot_diff.go:219 — the
// `len(raw) > 0` guard that skips JSON decoding of empty input. Empty input
// must canonicalize to the empty object `{}` with ok=true (no rules/balancers
// to strip). A mutated guard (`>= 0`) would feed an empty reader to the JSON
// decoder, get io.EOF, and wrongly return ok=false.
func TestRoutingWithoutReloadable_EmptyInput(t *testing.T) {
out, ok := routingWithoutReloadable([]byte{})
if !ok {
t.Fatal("empty routing input must canonicalize successfully")
}
if string(out) != "{}" {
t.Fatalf("empty routing input must canonicalize to {}, got %q", out)
}
// nil input behaves the same as empty.
out, ok = routingWithoutReloadable(nil)
if !ok || string(out) != "{}" {
t.Fatalf("nil routing input must canonicalize to {}, ok=%v out=%q", ok, out)
}
}
// TestRoutingWithoutReloadable_StripsRulesAndBalancers complements the guard
// test: with real content the reloadable keys (rules, balancers) are removed
// and only the restart-only remainder is returned. This pins that a routing
// change limited to rules/balancers leaves an identical remainder.
func TestRoutingWithoutReloadable_StripsRulesAndBalancers(t *testing.T) {
a, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[{"x":1}],"balancers":[{"y":2}]}`))
if !ok {
t.Fatal("valid routing input must parse")
}
b, ok := routingWithoutReloadable([]byte(`{"domainStrategy":"AsIs","rules":[],"balancers":[]}`))
if !ok {
t.Fatal("valid routing input must parse")
}
if string(a) != string(b) {
t.Fatalf("rules/balancers must be stripped: %q != %q", a, b)
}
if string(a) != `{"domainStrategy":"AsIs"}` {
t.Fatalf("remainder must keep only restart-only keys, got %q", a)
}
}
// TestApiTagFromConfig pins hot_diff.go:357 — the three-part guard
// `len(api) > 0 && Unmarshal == nil && parsed.Tag != ""`. Each conjunct must
// hold for a custom tag to be honored; otherwise the default "api" is used.
func TestApiTagFromConfig(t *testing.T) {
cases := []struct {
name string
api string
want string
}{
{"empty input falls back to api", "", "api"},
{"explicit tag honored", `{"tag":"my-api"}`, "my-api"},
{"empty tag falls back to api", `{"tag":""}`, "api"},
{"missing tag falls back to api", `{"services":["StatsService"]}`, "api"},
{"unparsable falls back to api", `{not-json`, "api"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := apiTagFromConfig(json_util.RawMessage(tc.api))
if got != tc.want {
t.Fatalf("apiTagFromConfig(%q) = %q, want %q", tc.api, got, tc.want)
}
})
}
}
// TestApiTagDrivesInboundRestartGuard ties hot_diff.go:357 to its consumer:
// the api tag resolved from the api section is the tag whose inbound change
// forces a restart. With a custom api.tag, changing that inbound must NOT be
// hot-appliable (it carries the gRPC server the panel talks through).
func TestApiTagDrivesInboundRestartGuard(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`)
oldCfg.InboundConfigs[0].Tag = "custom-api"
newCfg := makeHotConfig()
newCfg.API = json_util.RawMessage(`{"services":["HandlerService"],"tag":"custom-api"}`)
newCfg.InboundConfigs[0].Tag = "custom-api"
newCfg.InboundConfigs[0].Port = 62790 // change the custom-api inbound
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("changing the inbound named by a custom api.tag must force a restart")
}
}
// ---------------------------------------------------------------------------
// process.go mutation audits (pure-logic, cross-platform)
// ---------------------------------------------------------------------------
// TestIsRunning_ExitedProcessWithClosedDone pins process.go:240 — the
// `if p.done != nil` guard that decides whether to consult the done channel.
// When the process has exited (done closed) but ProcessState has not yet been
// observed, IsRunning must report false via the closed-channel select. A
// mutated guard (`== nil`) would skip the select and wrongly report true.
func TestIsRunning_ExitedProcessWithClosedDone(t *testing.T) {
p := newProcess(nil)
p.cmd = &exec.Cmd{Process: &os.Process{}}
done := make(chan struct{})
close(done)
p.done = done
if p.IsRunning() {
t.Fatal("a process whose done channel is closed must report not running")
}
}
// TestIsRunning_LiveProcessWithOpenDone is the complementary case: an open
// done channel and no ProcessState means the process is alive, so IsRunning
// must report true (the select's default branch is taken).
func TestIsRunning_LiveProcessWithOpenDone(t *testing.T) {
p := newProcess(nil)
p.cmd = &exec.Cmd{Process: &os.Process{}}
p.done = make(chan struct{}) // open
if !p.IsRunning() {
t.Fatal("a process with an open done channel and live cmd must report running")
}
}
// TestGetResult pins process.go:260 — the
// `if len(lastLine) == 0 && exitErr != nil` choice between the captured log
// line and the exit error string.
func TestGetResult(t *testing.T) {
cases := []struct {
name string
lastLine string
exitErr error
want string
}{
{"no line, has error -> error string", "", errProcessTest("boom"), "boom"},
{"has line -> line wins over error", "last log", errProcessTest("boom"), "last log"},
{"no line, no error -> empty", "", nil, ""},
{"has line, no error -> line", "last log", nil, "last log"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := newProcess(nil)
p.logWriter.lastLine = tc.lastLine
p.exitErr = tc.exitErr
if got := p.GetResult(); got != tc.want {
t.Fatalf("GetResult() = %q, want %q", got, tc.want)
}
})
}
}
type errProcessTest string
func (e errProcessTest) Error() string { return string(e) }
// TestRefreshLocalOnline_GraceBoundaryEmails pins the exact `<` boundary at
// process.go:407: an email idle for EXACTLY graceMs must be aged out (the
// window is half-open, age < grace). A mutated comparison (`<=`) would keep it.
func TestRefreshLocalOnline_GraceBoundaryEmails(t *testing.T) {
p := newOnlineTestProcess()
const grace = int64(20000)
p.RefreshLocalOnline([]string{"edge"}, nil, 0, grace)
// now-ts == grace exactly: age is not strictly < grace, so it must drop.
p.RefreshLocalOnline(nil, nil, grace, grace)
for _, e := range p.GetLocalOnlineClients() {
if e == "edge" {
t.Fatalf("email idle exactly graceMs must age out (half-open window), got online %v", p.GetLocalOnlineClients())
}
}
// One millisecond inside the window must still be online.
p2 := newOnlineTestProcess()
p2.RefreshLocalOnline([]string{"edge"}, nil, 0, grace)
p2.RefreshLocalOnline(nil, nil, grace-1, grace)
if !containsString(p2.GetLocalOnlineClients(), "edge") {
t.Fatalf("email idle graceMs-1 must still be online, got %v", p2.GetLocalOnlineClients())
}
}
// TestRefreshLocalOnline_GraceBoundaryInbounds pins the same `<` boundary at
// process.go:423 for inbound tags.
func TestRefreshLocalOnline_GraceBoundaryInbounds(t *testing.T) {
p := newOnlineTestProcess()
const grace = int64(20000)
p.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace)
p.RefreshLocalOnline(nil, nil, grace, grace)
for _, tag := range p.GetLocalActiveInbounds() {
if tag == "in-edge" {
t.Fatalf("inbound idle exactly graceMs must age out, got active %v", p.GetLocalActiveInbounds())
}
}
p2 := newOnlineTestProcess()
p2.RefreshLocalOnline(nil, []string{"in-edge"}, 0, grace)
p2.RefreshLocalOnline(nil, nil, grace-1, grace)
if !containsString(p2.GetLocalActiveInbounds(), "in-edge") {
t.Fatalf("inbound idle graceMs-1 must still be active, got %v", p2.GetLocalActiveInbounds())
}
}
func containsString(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// process.go mutation audits (require a real child process; re-invoke the
// test binary so they run cross-platform, no signals needed)
// ---------------------------------------------------------------------------
// TestWaitForCommand_CrashExitRecordsError pins process.go:554 — the
// `if err == nil || intentionalStop` guard. A process that exits with a
// NON-zero code on its own (not an intentional Stop) is a crash and its error
// MUST be recorded. A mutated guard that negates the err check (`err != nil`)
// would early-return and drop the error.
func TestWaitForCommand_CrashExitRecordsError(t *testing.T) {
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "crash-exit")
cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
p := newProcess(nil)
if err := p.startCommand(cmd); err != nil {
t.Fatalf("startCommand: %v", err)
}
// We never call Stop -> intentionalStop stays false; the child exits 2.
if err := p.waitForExit(5 * time.Second); err != nil {
t.Fatalf("child did not exit: %v", err)
}
if p.GetErr() == nil {
t.Fatal("a non-intentional non-zero exit must record an error")
}
}
// TestStop_RemovesTempConfigFile pins process.go:579 — the
// `if p.configPath != ""` guard that removes the per-run temp config file on
// Stop (so test runs never disturb the main config.json). A mutated guard
// (`== ""`) would skip the removal and leak the temp file.
func TestStop_RemovesTempConfigFile(t *testing.T) {
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
tmpCfg := filepath.Join(t.TempDir(), "test-config.json")
if err := os.WriteFile(tmpCfg, []byte("{}"), 0o644); err != nil {
t.Fatalf("write temp config: %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=TestMutationAuditHelper", "--", "block")
cmd.Env = append(os.Environ(), "XRAY_MUT_HELPER=1")
p := newProcess(nil)
p.configPath = tmpCfg
if err := p.startCommand(cmd); err != nil {
t.Fatalf("startCommand: %v", err)
}
t.Cleanup(func() {
if p.IsRunning() {
p.intentionalStop.Store(true)
_ = p.cmd.Process.Kill()
_ = p.waitForExit(2 * time.Second)
}
})
if !p.IsRunning() {
t.Fatal("helper process must be running before Stop")
}
if err := p.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
if _, err := os.Stat(tmpCfg); !os.IsNotExist(err) {
t.Fatalf("temp config file must be removed on Stop, stat err=%v", err)
}
}
// TestMutationAuditHelper is the re-invoked child for the process tests above.
// It is inert unless XRAY_MUT_HELPER=1 is set.
func TestMutationAuditHelper(t *testing.T) {
if os.Getenv("XRAY_MUT_HELPER") != "1" {
return
}
mode := ""
for i, arg := range os.Args {
if arg == "--" && i+1 < len(os.Args) {
mode = os.Args[i+1]
break
}
}
switch mode {
case "crash-exit":
os.Exit(2)
case "block":
select {}
}
}

15
x-ui.sh
View File

@ -2409,8 +2409,8 @@ 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.
# TCP and UDP port (including all Xray inbounds, e.g. UDP-based Hysteria2), 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"
@ -2426,23 +2426,24 @@ before = iptables-allports.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -j f2b-<name>
<iptables> -I <chain> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actionstop = <iptables> -D <chain> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -I f2b-<name> 1 -s <ip> -p udp -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> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -D f2b-<name> -s <ip> -p udp -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