mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-18 10:17:36 +07:00
Compare commits
13 Commits
53f6ed394f
...
7fe082a7f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fe082a7f1 | |||
| f7ffe89813 | |||
| c1fbfd0510 | |||
| cbb21b7575 | |||
| cf5f37e409 | |||
| 0d87bb8b4b | |||
| f00512d12e | |||
| cdaf5f80db | |||
| ac8cb505d1 | |||
| 71616b7cf2 | |||
| 628406117e | |||
| 7605902324 | |||
| b5872af279 |
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
@ -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
62
.github/workflows/mutation.yml
vendored
Normal 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
|
||||
@ -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`).
|
||||
|
||||
@ -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
1
frontend/.gitignore
vendored
@ -2,3 +2,4 @@ node_modules/
|
||||
.vite/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
|
||||
171
frontend/package-lock.json
generated
171
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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>
|
||||
)),
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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",
|
||||
]
|
||||
`;
|
||||
@ -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",
|
||||
]
|
||||
`;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
1
go.mod
@ -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
2
go.sum
@ -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=
|
||||
|
||||
75
internal/config/config_mutation_test.go
Normal file
75
internal/config/config_mutation_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
30
internal/logger/logger_test.go
Normal file
30
internal/logger/logger_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
99
internal/mtproto/manager_mutation_test.go
Normal file
99
internal/mtproto/manager_mutation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
51
internal/sub/external_only_sub_test.go
Normal file
51
internal/sub/external_only_sub_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
484
internal/sub/mutation_audit_test.go
Normal file
484
internal/sub/mutation_audit_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/sub/service_property_test.go
Normal file
88
internal/sub/service_property_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
89
internal/sub/service_sharelink_test.go
Normal file
89
internal/sub/service_sharelink_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
internal/util/common/format_mutation_test.go
Normal file
60
internal/util/common/format_mutation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
28
internal/util/link/outbound_fuzz_test.go
Normal file
28
internal/util/link/outbound_fuzz_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
201
internal/util/link/outbound_helpers_test.go
Normal file
201
internal/util/link/outbound_helpers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
102
internal/util/netsafe/netsafe_mutation_test.go
Normal file
102
internal/util/netsafe/netsafe_mutation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
43
internal/web/middleware/bodylimit.go
Normal file
43
internal/web/middleware/bodylimit.go
Normal 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
|
||||
}
|
||||
80
internal/web/middleware/bodylimit_test.go
Normal file
80
internal/web/middleware/bodylimit_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
99
internal/web/middleware/validate_mutation_test.go
Normal file
99
internal/web/middleware/validate_mutation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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{})
|
||||
|
||||
|
||||
73
internal/web/runtime/tls_client_property_test.go
Normal file
73
internal/web/runtime/tls_client_property_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
141
internal/web/service/node_client_expiry_sync_test.go
Normal file
141
internal/web/service/node_client_expiry_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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{}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
345
internal/xray/mutation_audit_test.go
Normal file
345
internal/xray/mutation_audit_test.go
Normal 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
15
x-ui.sh
@ -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
|
||||
|
||||
Reference in New Issue
Block a user