mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 03:54:46 +03:00
Compare commits
8 Commits
a31fa9abfa
...
63b46cd612
| Author | SHA1 | Date | |
|---|---|---|---|
| 63b46cd612 | |||
| 2ddcf53020 | |||
| 13e87a18c8 | |||
| bd1c27b03d | |||
| 0ff3c23948 | |||
| f294e1806d | |||
| 4019f47de2 | |||
| 8411b1dd9e |
@@ -138,7 +138,8 @@ jobs:
|
||||
- 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)
|
||||
# internal/web/service runs ~10x slower under -race and overruns the 10m default.
|
||||
go test -race -shuffle=on -count=1 -timeout 25m $(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.
|
||||
|
||||
@@ -183,13 +183,17 @@ jobs:
|
||||
cd ../..
|
||||
|
||||
- name: Package
|
||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||
run: |
|
||||
tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||
sha256sum x-ui-linux-${{ matrix.platform }}.tar.gz > x-ui-linux-${{ matrix.platform }}.tar.gz.sha256
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-linux-${{ matrix.platform }}
|
||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
path: |
|
||||
./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
./x-ui-linux-${{ matrix.platform }}.tar.gz.sha256
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
@@ -197,8 +201,8 @@ jobs:
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz*
|
||||
file_glob: true
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
@@ -316,12 +320,16 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||
$hash = (Get-FileHash x-ui-windows-amd64.zip -Algorithm SHA256).Hash.ToLower()
|
||||
[IO.File]::WriteAllText("$PWD\x-ui-windows-amd64.zip.sha256", "$hash x-ui-windows-amd64.zip`n")
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-windows-amd64
|
||||
path: ./x-ui-windows-amd64.zip
|
||||
path: |
|
||||
./x-ui-windows-amd64.zip
|
||||
./x-ui-windows-amd64.zip.sha256
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
@@ -329,8 +337,8 @@ jobs:
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-windows-amd64.zip
|
||||
asset_name: x-ui-windows-amd64.zip
|
||||
file: x-ui-windows-amd64.zip*
|
||||
file_glob: true
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
@@ -398,4 +406,4 @@ jobs:
|
||||
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
|
||||
fi
|
||||
|
||||
retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber
|
||||
retry gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip dev-artifacts/*.sha256 --clobber
|
||||
|
||||
@@ -54,8 +54,9 @@ test-go: dist-stub ## Go tests (shuffle, no cache)
|
||||
go test -shuffle=on -count=1 $(GO_PKGS)
|
||||
|
||||
.PHONY: race
|
||||
# internal/web/service runs ~10x slower under -race and overruns go test's 10m default.
|
||||
race: dist-stub ## Go tests with the race detector (needs a C compiler)
|
||||
go test -race -shuffle=on -count=1 $(GO_PKGS)
|
||||
go test -race -shuffle=on -count=1 -timeout 25m $(GO_PKGS)
|
||||
|
||||
.PHONY: test-fe
|
||||
test-fe: ## Frontend tests (vitest)
|
||||
|
||||
@@ -141,10 +141,10 @@ S1 = 87
|
||||
S2 = 44
|
||||
S3 = 21
|
||||
S4 = 9
|
||||
H1 = 462980921-463150218
|
||||
H2 = 1177681572-1177787900
|
||||
H3 = 1907413509-1907903969
|
||||
H4 = 2029908558-2030313135
|
||||
H1 = 463065432
|
||||
H2 = 912345678
|
||||
H3 = 1345678901
|
||||
H4 = 1987654321
|
||||
I1 = <r 148>
|
||||
HeaderProtectionKey = 8Iu83eHDA3fMKKSGaEsVW9Ycd2lYYzc0MYlk1jJTvE4=
|
||||
ContentPaddingAddition = 17-49
|
||||
|
||||
@@ -56,8 +56,11 @@ _openapi:
|
||||
- depth: 2
|
||||
title: Replace a client's external links and external subscriptions. Sends the
|
||||
full set; the server replaces all rows. Disabled rows stay saved for
|
||||
editing but are not emitted in generated subscriptions.
|
||||
url: '#replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions'
|
||||
editing but are not emitted in generated subscriptions. The owning
|
||||
client's disabled or expired state also stops these rows from being
|
||||
emitted on future subscription fetches; credentials already imported by
|
||||
an app remain valid until the external provider revokes them.
|
||||
url: '#replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions-the-owning-clients-disabled-or-expired-state-also-stops-these-rows-from-being-emitted-on-future-subscription-fetches-credentials-already-imported-by-an-app-remain-valid-until-the-external-provider-revokes-them'
|
||||
- depth: 2
|
||||
title: Reset the up/down counters for every client globally. Quotas and expiry
|
||||
are not affected. Triggers an Xray restart if any counter actually
|
||||
@@ -318,8 +321,11 @@ _openapi:
|
||||
id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
|
||||
- content: Replace a client's external links and external subscriptions. Sends the
|
||||
full set; the server replaces all rows. Disabled rows stay saved for
|
||||
editing but are not emitted in generated subscriptions.
|
||||
id: replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions
|
||||
editing but are not emitted in generated subscriptions. The owning
|
||||
client's disabled or expired state also stops these rows from being
|
||||
emitted on future subscription fetches; credentials already imported
|
||||
by an app remain valid until the external provider revokes them.
|
||||
id: replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions-the-owning-clients-disabled-or-expired-state-also-stops-these-rows-from-being-emitted-on-future-subscription-fetches-credentials-already-imported-by-an-app-remain-valid-until-the-external-provider-revokes-them
|
||||
- content: Reset the up/down counters for every client globally. Quotas and expiry
|
||||
are not affected. Triggers an Xray restart if any counter actually
|
||||
moved.
|
||||
@@ -544,21 +550,34 @@ _openapi:
|
||||
|
||||
|
||||
WireGuard is the only one of these that can fail. Allocation widens
|
||||
the search to the containing /16 before giving up with `wireguard: no
|
||||
free address available in <scope>`, and an `allowedIPs` supplied by
|
||||
the caller is validated instead of allocated: `wireguard: allowedIPs
|
||||
entry already used by another client: <address>` when a different
|
||||
client of that same inbound already holds it. The check is per
|
||||
inbound, so the same address on two different inbounds is accepted.
|
||||
The same validation runs on POST /panel/api/clients/{email}/attach,
|
||||
where a client that already carries an address brings it along.
|
||||
the search to the containing /16 before giving up with `inbound <id>:
|
||||
wireguard: no free address available in <scope>`, and an `allowedIPs`
|
||||
supplied by the caller is validated instead of allocated: `inbound
|
||||
<id>: wireguard: allowedIPs entry already used by another client:
|
||||
<address>` when a different client of that same inbound already holds
|
||||
it. The check is per inbound, so the same address on two different
|
||||
inbounds is accepted. The same validation runs on POST
|
||||
/panel/api/clients/{email}/attach, where a client that already carries
|
||||
an address brings it along.
|
||||
|
||||
|
||||
An `inboundIds` entry that names no existing inbound rejects the whole
|
||||
call before anything is written. Past that, the inbounds are applied
|
||||
concurrently and independently: one that fails no longer stops the
|
||||
others, so a `success:false` response can still have created the
|
||||
client on the rest. Every error names the inbound it came from
|
||||
(`inbound 7: <message>`), and several failures are reported together,
|
||||
one per line. `limitHwid` is applied only when every inbound
|
||||
succeeded, so re-run the call after fixing the failure.
|
||||
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
|
||||
instead of being given a fresh address, so the call fails with
|
||||
`wireguard: allowedIPs entry already used by another client:
|
||||
<address>` when a different client of the target inbound already holds
|
||||
it. Free the address on that inbound first — see POST
|
||||
/panel/api/clients/add for the full rule.'
|
||||
`inbound <id>: wireguard: allowedIPs entry already used by another
|
||||
client: <address>` when a different client of the target inbound
|
||||
already holds it. Free the address on that inbound first — see POST
|
||||
/panel/api/clients/add for the full rule. Inbounds are applied
|
||||
independently, so the remaining ones are still attached and a
|
||||
`success:false` response can be partial.'
|
||||
heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
|
||||
---
|
||||
|
||||
|
||||
@@ -123,9 +123,9 @@ _openapi:
|
||||
dev release. Only effective on dev builds.
|
||||
url: '#toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds'
|
||||
- depth: 2
|
||||
title: Refresh the default GeoIP / GeoSite data files. Body can include a
|
||||
fileName, or use the /:fileName variant.
|
||||
url: '#refresh-the-default-geoip--geosite-data-files-body-can-include-a-filename-or-use-the-filename-variant'
|
||||
title: Refresh the default GeoIP / GeoSite data files. Use the /:fileName
|
||||
variant to update one file.
|
||||
url: '#refresh-the-default-geoip--geosite-data-files-use-the-filename-variant-to-update-one-file'
|
||||
- depth: 2
|
||||
title: Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).
|
||||
url: '#refresh-a-single-geo-file-by-filename-eg-geoipdat-geositedat'
|
||||
@@ -271,9 +271,9 @@ _openapi:
|
||||
- content: Toggle the panel update channel between stable and the rolling
|
||||
per-commit dev release. Only effective on dev builds.
|
||||
id: toggle-the-panel-update-channel-between-stable-and-the-rolling-per-commit-dev-release-only-effective-on-dev-builds
|
||||
- content: Refresh the default GeoIP / GeoSite data files. Body can include a
|
||||
fileName, or use the /:fileName variant.
|
||||
id: refresh-the-default-geoip--geosite-data-files-body-can-include-a-filename-or-use-the-filename-variant
|
||||
- content: Refresh the default GeoIP / GeoSite data files. Use the /:fileName
|
||||
variant to update one file.
|
||||
id: refresh-the-default-geoip--geosite-data-files-use-the-filename-variant-to-update-one-file
|
||||
- content: Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).
|
||||
id: refresh-a-single-geo-file-by-filename-eg-geoipdat-geositedat
|
||||
- content: Return the last N lines of the panel’s own log.
|
||||
|
||||
@@ -18,9 +18,9 @@ _openapi:
|
||||
url: '#create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound'
|
||||
- depth: 2
|
||||
title: Update a balancer by id. Accepts the same form fields as create (full-row
|
||||
update, including the enabled toggle); omitting memberWeights clears
|
||||
stored weights.
|
||||
url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights'
|
||||
update); omitting memberWeights clears stored weights, while omitting
|
||||
enabled keeps its current value.
|
||||
url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-omitting-memberweights-clears-stored-weights-while-omitting-enabled-keeps-its-current-value'
|
||||
- depth: 2
|
||||
title: Delete a balancer by id.
|
||||
url: '#delete-a-balancer-by-id'
|
||||
@@ -36,9 +36,9 @@ _openapi:
|
||||
every client that sits on at least one selected inbound.
|
||||
id: create-a-subscription-balancer-it-appears-in-the-json-subscription-of-every-client-that-sits-on-at-least-one-selected-inbound
|
||||
- content: Update a balancer by id. Accepts the same form fields as create
|
||||
(full-row update, including the enabled toggle); omitting
|
||||
memberWeights clears stored weights.
|
||||
id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle-omitting-memberweights-clears-stored-weights
|
||||
(full-row update); omitting memberWeights clears stored weights, while
|
||||
omitting enabled keeps its current value.
|
||||
id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-omitting-memberweights-clears-stored-weights-while-omitting-enabled-keeps-its-current-value
|
||||
- content: Delete a balancer by id.
|
||||
id: delete-a-balancer-by-id
|
||||
- content: Delete a balancer by id (POST alias of DELETE for clients that cannot
|
||||
|
||||
+941
-69
File diff suppressed because it is too large
Load Diff
Generated
+152
-248
@@ -38,11 +38,11 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.4.1",
|
||||
@@ -54,7 +54,7 @@
|
||||
"storybook": "^10.6.0",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
@@ -496,9 +496,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@blazediff/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4195,13 +4195,6 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -4422,16 +4415,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-vite/node_modules/magic-string": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
|
||||
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-ast": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.12.0.tgz",
|
||||
@@ -5430,9 +5413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -5843,45 +5826,46 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz",
|
||||
"integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-5.0.0.tgz",
|
||||
"integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blazediff/core": "1.9.1",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"@blazediff/core": "1.10.0",
|
||||
"@vitest/mocker": "5.0.0",
|
||||
"@vitest/ui": "5.0.0",
|
||||
"@vitest/utils": "5.0.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"pngjs": "^7.0.0",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"ws": "^8.19.0"
|
||||
"tinyrainbow": "^3.1.1",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz",
|
||||
"integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz",
|
||||
"integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
"@vitest/browser": "5.0.0",
|
||||
"@vitest/mocker": "5.0.0",
|
||||
"tinyrainbow": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright": {
|
||||
@@ -5890,29 +5874,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz",
|
||||
"integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"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"
|
||||
"@vitest/istanbul-lib-coverage": "^1.0.0",
|
||||
"@vitest/istanbul-lib-report": "^1.0.0",
|
||||
"ast-v8-to-istanbul": "^1.0.5",
|
||||
"magicast": "^0.5.4",
|
||||
"obug": "^2.1.4",
|
||||
"std-env": "^4.2.0",
|
||||
"tinyrainbow": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
"@vitest/browser": "5.0.0",
|
||||
"vitest": "5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -5988,16 +5970,40 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"node_modules/@vitest/istanbul-lib-coverage": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz",
|
||||
"integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/istanbul-lib-report": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz",
|
||||
"integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/istanbul-lib-coverage": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
||||
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -6026,68 +6032,59 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.0.tgz",
|
||||
"integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
"tinyrainbow": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
|
||||
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"node_modules/@vitest/ui": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-5.0.0.tgz",
|
||||
"integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "5.0.0",
|
||||
"fflate": "^0.8.3",
|
||||
"flatted": "^3.4.4",
|
||||
"pathe": "^2.0.3",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyrainbow": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.0.tgz",
|
||||
"integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "5.0.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
"tinyrainbow": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -7273,6 +7270,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
|
||||
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -7453,16 +7464,6 @@
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -7583,13 +7584,6 @@
|
||||
"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": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
@@ -7881,45 +7875,6 @@
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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",
|
||||
@@ -8400,9 +8355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
|
||||
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8421,22 +8376,6 @@
|
||||
"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/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -10124,19 +10063,6 @@
|
||||
"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/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -10285,11 +10211,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
@@ -10772,38 +10701,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
|
||||
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.0",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "6.1.4",
|
||||
"tinyexec": "1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -10811,16 +10733,16 @@
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/browser-preview": "5.0.0",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.0",
|
||||
"@vitest/coverage-v8": "5.0.0",
|
||||
"@vitest/ui": "5.0.0",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
@@ -10861,24 +10783,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
|
||||
@@ -64,11 +64,11 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.4.1",
|
||||
@@ -80,7 +80,7 @@
|
||||
"storybook": "^10.6.0",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.11",
|
||||
@@ -95,6 +95,11 @@
|
||||
},
|
||||
"@typeschema/valibot": {
|
||||
"valibot": "^1.1.0"
|
||||
},
|
||||
"@storybook/addon-vitest": {
|
||||
"vitest": "$vitest",
|
||||
"@vitest/browser-playwright": "$@vitest/browser-playwright",
|
||||
"@vitest/browser": "5.0.0"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
+941
-69
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,8 @@ const SECURITY_SCHEMES = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
|
||||
description:
|
||||
'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
|
||||
},
|
||||
cookieAuth: {
|
||||
type: 'apiKey',
|
||||
@@ -55,9 +56,35 @@ function schemaFromType(t) {
|
||||
const itemType = v.slice(0, -2);
|
||||
return { type: 'array', items: { type: mapType(itemType) } };
|
||||
}
|
||||
if (v === 'file') return { type: 'string', format: 'binary' };
|
||||
return { type: mapType(v) };
|
||||
}
|
||||
|
||||
function schemaFromParam(p) {
|
||||
const schema = schemaFromType(p.type);
|
||||
if (p.defaultValue !== undefined) schema.default = p.defaultValue;
|
||||
if (p.minLength !== undefined) schema.minLength = p.minLength;
|
||||
if (p.pattern !== undefined) schema.pattern = p.pattern;
|
||||
return schema;
|
||||
}
|
||||
|
||||
function requestBodyContentType(ep, bodyParams) {
|
||||
const locations = new Set(bodyParams.map((p) => p.in));
|
||||
if (locations.size > 1) {
|
||||
throw new Error(
|
||||
`${ep.method} ${ep.path}: request body mixes parameter locations: ${[...locations].join(', ')}`,
|
||||
);
|
||||
}
|
||||
switch (bodyParams[0]?.in) {
|
||||
case 'body (form)':
|
||||
return 'application/x-www-form-urlencoded';
|
||||
case 'body (multipart)':
|
||||
return 'multipart/form-data';
|
||||
default:
|
||||
return 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
try {
|
||||
@@ -73,9 +100,8 @@ function paramToOpenApi(p) {
|
||||
in: p.in,
|
||||
required: p.in === 'path' ? true : !p.optional,
|
||||
description: p.desc || '',
|
||||
schema: schemaFromType(p.type),
|
||||
schema: schemaFromParam(p),
|
||||
};
|
||||
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -91,7 +117,7 @@ function buildOperation(ep, tag) {
|
||||
const params = [];
|
||||
const bodyParams = [];
|
||||
for (const p of ep.params || []) {
|
||||
if (p.in === 'body') {
|
||||
if (p.in.startsWith('body')) {
|
||||
bodyParams.push(p);
|
||||
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
|
||||
params.push(paramToOpenApi(p));
|
||||
@@ -113,26 +139,80 @@ function buildOperation(ep, tag) {
|
||||
|
||||
if (params.length > 0) op.parameters = params;
|
||||
|
||||
if (ep.body || bodyParams.length > 0) {
|
||||
const example = tryParseJson(ep.body);
|
||||
if (ep.body || bodyParams.length > 0 || ep.requestSchema) {
|
||||
const contentType = requestBodyContentType(ep, bodyParams);
|
||||
const example = contentType === 'application/json' ? tryParseJson(ep.body) : undefined;
|
||||
const properties = {};
|
||||
const required = [];
|
||||
for (const bp of bodyParams) {
|
||||
properties[bp.name] = {
|
||||
...schemaFromType(bp.type),
|
||||
...schemaFromParam(bp),
|
||||
description: bp.desc || '',
|
||||
};
|
||||
if (!bp.optional) required.push(bp.name);
|
||||
}
|
||||
const schema = bodyParams.length > 0
|
||||
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
|
||||
: { type: 'object' };
|
||||
let schema;
|
||||
if (ep.requestSchema) {
|
||||
if (bodyParams.length > 0 || ep.bodyRequiredOneOf?.length) {
|
||||
throw new Error(
|
||||
`${ep.method} ${ep.path}: requestSchema cannot be combined with body parameters or bodyRequiredOneOf`,
|
||||
);
|
||||
}
|
||||
schema = ep.requestSchema;
|
||||
} else {
|
||||
schema =
|
||||
bodyParams.length > 0
|
||||
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
|
||||
: { type: 'object' };
|
||||
if (ep.bodyRequiredOneOf?.length) {
|
||||
schema = {
|
||||
anyOf: ep.bodyRequiredOneOf.map((name) => {
|
||||
if (!properties[name]) {
|
||||
throw new Error(
|
||||
`${ep.method} ${ep.path}: bodyRequiredOneOf "${name}" is not a declared body parameter`,
|
||||
);
|
||||
}
|
||||
const branchProperties = { ...properties };
|
||||
for (const other of ep.bodyRequiredOneOf) {
|
||||
if (other === name || !branchProperties[other]) continue;
|
||||
const { pattern: _pattern, minLength: _minLength, ...rest } =
|
||||
branchProperties[other];
|
||||
branchProperties[other] = rest;
|
||||
}
|
||||
return {
|
||||
type: 'object',
|
||||
properties: branchProperties,
|
||||
required: [...required, name],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const encoding = {};
|
||||
if (contentType === 'application/x-www-form-urlencoded') {
|
||||
for (const bp of bodyParams) {
|
||||
const kind = schemaFromType(bp.type).type;
|
||||
if (kind === 'array') {
|
||||
encoding[bp.name] = { style: 'form', explode: true };
|
||||
} else if (kind === 'object') {
|
||||
// The panel reads such a field with json.Unmarshal, so it must be sent
|
||||
// as JSON text rather than form-style key/value pairs.
|
||||
encoding[bp.name] = { contentType: 'application/json' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
op.requestBody = {
|
||||
required: required.length > 0 || bodyParams.length === 0,
|
||||
required:
|
||||
Boolean(ep.requestSchema) ||
|
||||
Boolean(ep.bodyRequiredOneOf?.length) ||
|
||||
required.length > 0 ||
|
||||
bodyParams.length === 0,
|
||||
content: {
|
||||
'application/json': {
|
||||
[contentType]: {
|
||||
schema,
|
||||
...(Object.keys(encoding).length > 0 ? { encoding } : {}),
|
||||
...(example !== undefined ? { example } : {}),
|
||||
},
|
||||
},
|
||||
@@ -145,10 +225,14 @@ function buildOperation(ep, tag) {
|
||||
if (ep.responseSchema) {
|
||||
const obj = EXAMPLES[ep.responseSchema];
|
||||
if (obj === undefined) {
|
||||
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
|
||||
throw new Error(
|
||||
`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`,
|
||||
);
|
||||
}
|
||||
if (SCHEMAS[ep.responseSchema] === undefined) {
|
||||
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
|
||||
throw new Error(
|
||||
`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`,
|
||||
);
|
||||
}
|
||||
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
|
||||
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
|
||||
@@ -197,7 +281,7 @@ function buildOperation(ep, tag) {
|
||||
return op;
|
||||
}
|
||||
|
||||
function buildSpec() {
|
||||
export function buildSpec() {
|
||||
const paths = {};
|
||||
for (const section of sections) {
|
||||
const tag = section.title;
|
||||
@@ -221,9 +305,7 @@ function buildSpec() {
|
||||
description:
|
||||
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Current panel (basePath aware)' },
|
||||
],
|
||||
servers: [{ url: '/', description: 'Current panel (basePath aware)' }],
|
||||
components: {
|
||||
securitySchemes: SECURITY_SCHEMES,
|
||||
schemas: SCHEMAS,
|
||||
@@ -234,13 +316,13 @@ function buildSpec() {
|
||||
};
|
||||
}
|
||||
|
||||
const spec = buildSpec();
|
||||
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const spec = buildSpec();
|
||||
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
|
||||
|
||||
const pathCount = Object.keys(spec.paths).length;
|
||||
let opCount = 0;
|
||||
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
|
||||
console.log(`[openapi] wrote ${outPath}`);
|
||||
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
|
||||
|
||||
void pathToFileURL;
|
||||
const pathCount = Object.keys(spec.paths).length;
|
||||
let opCount = 0;
|
||||
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
|
||||
console.log(`[openapi] wrote ${outPath}`);
|
||||
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface DateTimePickerProps {
|
||||
format?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowClear?: boolean;
|
||||
maxDate?: Dayjs;
|
||||
}
|
||||
|
||||
const LIGHT_THEME = {
|
||||
@@ -53,6 +55,8 @@ export default function DateTimePicker({
|
||||
format = 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
allowClear = true,
|
||||
maxDate,
|
||||
}: DateTimePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
@@ -78,6 +82,14 @@ export default function DateTimePicker({
|
||||
return LIGHT_THEME;
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const commitChange = (next: Dayjs | null) => {
|
||||
if (next && maxDate && next.isAfter(maxDate)) {
|
||||
if (datepicker === 'jalalian') setClearNonce((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -100,19 +112,20 @@ export default function DateTimePicker({
|
||||
onChange={(next: number | string | null) => {
|
||||
if (suppressMountEmit.current) return;
|
||||
if (next == null || next === '') {
|
||||
onChange(null);
|
||||
commitChange(null);
|
||||
return;
|
||||
}
|
||||
const ms = typeof next === 'number' ? next : Number(next);
|
||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
||||
if (Number.isFinite(ms)) commitChange(dayjs(ms));
|
||||
}}
|
||||
showTime={showTime}
|
||||
outputFormat="timestamp"
|
||||
maxDate={maxDate?.toDate()}
|
||||
persianNumbers
|
||||
rtlCalendar
|
||||
theme={persianTheme}
|
||||
/>
|
||||
{value && !disabled && (
|
||||
{value && allowClear && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="jdp-clear"
|
||||
@@ -120,7 +133,7 @@ export default function DateTimePicker({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
commitChange(null);
|
||||
setClearNonce((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
@@ -134,13 +147,15 @@ export default function DateTimePicker({
|
||||
return (
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||
onChange={(next) => commitChange(next || null)}
|
||||
onCalendarChange={(next) => commitChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
needConfirm={false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
allowClear={allowClear}
|
||||
maxDate={maxDate}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function resolveExternalLinkExpiry(
|
||||
externalExpiry: number | null | undefined,
|
||||
clientExpiry: number | null | undefined,
|
||||
): number {
|
||||
const explicitExpiry = Number(externalExpiry) || 0;
|
||||
if (explicitExpiry > 0) return explicitExpiry;
|
||||
|
||||
const inheritedExpiry = Number(clientExpiry) || 0;
|
||||
return inheritedExpiry > 0 ? inheritedExpiry : 0;
|
||||
}
|
||||
@@ -51,21 +51,17 @@ const generateHeaderProtectionKey = (): string => {
|
||||
};
|
||||
|
||||
/*
|
||||
* Four non-overlapping "low-high" ranges for H1-H4: split the space into
|
||||
* four bands and take a random sub-range from each (>= 1000 wide, low
|
||||
* bound >= 5 since 1-4 are reserved for vanilla WireGuard message types).
|
||||
* Four distinct values for H1-H4, one per band; low bound >= 5 (1-4 are vanilla WG message types).
|
||||
* Single values, not ranges: with randomTrailers on, a wide range misclassifies transport packets as handshakes (amnezia-vpn/amneziawg-go#183).
|
||||
*/
|
||||
const generateHRanges = (): [string, string, string, string] => {
|
||||
const generateHValues = (): [string, string, string, string] => {
|
||||
const hMax = 2147483647;
|
||||
const hMinWidth = 1000;
|
||||
const lo = 5;
|
||||
const bandSize = Math.floor((hMax - lo + 1) / 4);
|
||||
return Array.from({ length: 4 }, (_, i) => {
|
||||
const bandLo = lo + i * bandSize;
|
||||
const bandHi = bandLo + bandSize - 1;
|
||||
const start = randInt(bandLo, bandHi - hMinWidth - 1);
|
||||
const end = randInt(start + hMinWidth, bandHi - 1);
|
||||
return `${start}-${end}`;
|
||||
return `${randInt(bandLo, bandHi)}`;
|
||||
}) as [string, string, string, string];
|
||||
};
|
||||
|
||||
@@ -76,7 +72,7 @@ export function generateAwgObfuscation(): AwgObfuscation {
|
||||
while (s1 + 56 === s2) {
|
||||
s2 = randInt(15, 150);
|
||||
}
|
||||
const [h1, h2, h3, h4] = generateHRanges();
|
||||
const [h1, h2, h3, h4] = generateHValues();
|
||||
|
||||
/*
|
||||
* Timing windows bracket WireGuard's stock constants (rekey 120s, reject
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ParamType =
|
||||
| 'string'
|
||||
| 'integer'
|
||||
| 'integer[]'
|
||||
| 'string[]'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
@@ -25,6 +26,8 @@ export interface EndpointParam {
|
||||
desc?: string;
|
||||
optional?: boolean;
|
||||
defaultValue?: string | number | boolean;
|
||||
minLength?: number;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
export interface Endpoint {
|
||||
@@ -38,6 +41,8 @@ export interface Endpoint {
|
||||
response?: string;
|
||||
errorResponse?: string;
|
||||
errorStatus?: number;
|
||||
requestSchema?: Record<string, unknown>;
|
||||
bodyRequiredOneOf?: string[];
|
||||
responseSchema?: string;
|
||||
responseSchemaArray?: boolean;
|
||||
}
|
||||
@@ -55,6 +60,118 @@ export interface Section {
|
||||
endpoints: Endpoint[];
|
||||
}
|
||||
|
||||
// /inbounds/update replaces the whole row, so it takes the same payload as /add.
|
||||
const inboundBody =
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}';
|
||||
|
||||
const outboundSubscriptionBodyParams: EndpointParam[] = [
|
||||
{
|
||||
name: 'remark',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Optional display label.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.',
|
||||
},
|
||||
{
|
||||
name: 'tagPrefix',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Prefix for generated outbound tags. Defaults to the lowest free "sub<N>-" prefix.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'updateInterval',
|
||||
in: 'body (form)',
|
||||
type: 'integer',
|
||||
desc: 'Seconds between auto-refreshes. Default 600.',
|
||||
optional: true,
|
||||
defaultValue: 600,
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Whether the subscription is active. Default true.',
|
||||
optional: true,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
name: 'allowPrivate',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Allow the URL to point at a private/internal/loopback address. Default false.',
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'allowInsecure',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: "Skip TLS certificate verification when fetching the subscription's URL. Default false.",
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'prepend',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: "Place this subscription's outbounds before the manual template outbounds. Default false.",
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
},
|
||||
];
|
||||
|
||||
const subBalancerBodyParams: EndpointParam[] = [
|
||||
{
|
||||
name: 'remark',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Display label, used as the config remarks (required).',
|
||||
},
|
||||
{
|
||||
name: 'strategy',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random". Default "random".',
|
||||
optional: true,
|
||||
defaultValue: 'random',
|
||||
},
|
||||
{
|
||||
name: 'inboundIds',
|
||||
in: 'body (form)',
|
||||
type: 'integer[]',
|
||||
desc: 'Repeated form keys selecting the member inbounds (required, at least one).',
|
||||
},
|
||||
{
|
||||
name: 'memberWeights',
|
||||
in: 'body (form)',
|
||||
type: 'object',
|
||||
desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'sortOrder',
|
||||
in: 'body (form)',
|
||||
type: 'integer',
|
||||
desc: '1-based position in the subscription list. Default 1.',
|
||||
optional: true,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Whether the balancer is emitted. Default true on create; unchanged when omitted on update.',
|
||||
optional: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const sections: readonly Section[] = [
|
||||
{
|
||||
id: 'authentication',
|
||||
@@ -75,6 +192,7 @@ export const sections: readonly Section[] = [
|
||||
in: 'body',
|
||||
type: 'string',
|
||||
desc: 'OTP code when 2FA is enabled. Omit otherwise.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
|
||||
@@ -153,7 +271,7 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary:
|
||||
'Create a new inbound. Send the full inbound payload (protocol, port, settings, streamSettings, sniffing, remark, expiryTime, total, enable). settings, streamSettings, and sniffing may be sent as nested JSON objects (preferred) or as JSON-encoded strings (legacy).',
|
||||
body: '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": {\n "clients": [{ "id": "...", "email": "user1" }],\n "decryption": "none",\n "fallbacks": []\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n }\n}',
|
||||
body: inboundBody,
|
||||
errorResponse: '{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||
},
|
||||
{
|
||||
@@ -177,6 +295,7 @@ export const sections: readonly Section[] = [
|
||||
summary:
|
||||
'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
|
||||
params: [{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }],
|
||||
body: inboundBody,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -517,6 +636,15 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updatePanel',
|
||||
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||
params: [
|
||||
{
|
||||
name: 'dev',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: "Override this run's channel. Omit to use the panel's configured channel.",
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "runId": "1735689600123456789"\n }\n}',
|
||||
},
|
||||
{
|
||||
@@ -538,16 +666,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile',
|
||||
summary:
|
||||
'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
|
||||
params: [
|
||||
{
|
||||
name: 'fileName',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.',
|
||||
},
|
||||
],
|
||||
body: 'fileName=geoip.dat',
|
||||
'Refresh the default GeoIP / GeoSite data files. Use the /:fileName variant to update one file.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -568,8 +687,22 @@ export const sections: readonly Section[] = [
|
||||
summary: 'Return the last N lines of the panel\u2019s own log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
{
|
||||
name: 'level',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Minimum log level filter.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'syslog',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Read system logs instead of the panel log.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: '{\n "level": "info",\n "syslog": false\n}',
|
||||
body: 'level=info&syslog=false',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
|
||||
},
|
||||
@@ -584,24 +717,28 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Keyword filter — only lines containing this string.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'showDirect',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"true" to include direct (freedom) traffic lines.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'showBlocked',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"true" to include blocked (blackhole) traffic lines.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'showProxy',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"true" to include proxy traffic lines.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
|
||||
@@ -625,6 +762,7 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Keyword filter — only rows/lines containing this string.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'filter=awg1',
|
||||
@@ -642,6 +780,14 @@ export const sections: readonly Section[] = [
|
||||
type: 'file',
|
||||
desc: 'Database backup or migration file to upload.',
|
||||
},
|
||||
{
|
||||
name: 'keepHostSettings',
|
||||
in: 'body (multipart)',
|
||||
type: 'boolean',
|
||||
desc: "Keep this machine's addresses, certificates and node identity. Default true.",
|
||||
optional: true,
|
||||
defaultValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -666,18 +812,23 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/server/getCertHash',
|
||||
summary:
|
||||
'Compute the hex SHA-256 of a certificate (DER) for pinning (pinnedPeerCertSha256). Provide either a server file path or inline PEM/DER content.',
|
||||
bodyRequiredOneOf: ['certFile', 'certContent'],
|
||||
params: [
|
||||
{
|
||||
name: 'certFile',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Path to a certificate file on the server. Takes precedence over certContent.',
|
||||
optional: true,
|
||||
pattern: '.*\\S.*',
|
||||
},
|
||||
{
|
||||
name: 'certContent',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Inline PEM (or DER) certificate content, used when certFile is empty.',
|
||||
optional: true,
|
||||
pattern: '.*\\S.*',
|
||||
},
|
||||
],
|
||||
body: 'certFile=/root/cert.crt',
|
||||
@@ -767,14 +918,25 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/server/clientIps',
|
||||
summary:
|
||||
'Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.',
|
||||
params: [
|
||||
{
|
||||
name: 'ips',
|
||||
in: 'body (json)',
|
||||
type: 'object[]',
|
||||
desc: 'Array of InboundClientIps to merge.',
|
||||
requestSchema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
clientEmail: { type: 'string' },
|
||||
ips: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { ip: { type: 'string' }, timestamp: { type: 'integer' } },
|
||||
required: ['ip', 'timestamp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['clientEmail', 'ips'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -868,7 +1030,7 @@ export const sections: readonly Section[] = [
|
||||
summary:
|
||||
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
|
||||
description:
|
||||
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `wireguard: allowedIPs entry already used by another client: <address>` when a different client of that same inbound already holds it. The check is per inbound, so the same address on two different inbounds is accepted. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.',
|
||||
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry already used by another client: <address>` when a different client of that same inbound already holds it. The check is per inbound, so the same address on two different inbounds is accepted. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
params: [
|
||||
{
|
||||
name: 'client',
|
||||
@@ -923,7 +1085,7 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/clients/:email/attach',
|
||||
summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.',
|
||||
description:
|
||||
'A WireGuard client brings its stored `allowedIPs` into the new inbound instead of being given a fresh address, so the call fails with `wireguard: allowedIPs entry already used by another client: <address>` when a different client of the target inbound already holds it. Free the address on that inbound first — see POST /panel/api/clients/add for the full rule.',
|
||||
'A WireGuard client brings its stored `allowedIPs` into the new inbound instead of being given a fresh address, so the call fails with `inbound <id>: wireguard: allowedIPs entry already used by another client: <address>` when a different client of the target inbound already holds it. Free the address on that inbound first — see POST /panel/api/clients/add for the full rule. Inbounds are applied independently, so the remaining ones are still attached and a `success:false` response can be partial.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{
|
||||
@@ -956,14 +1118,14 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:email/externalLinks',
|
||||
summary:
|
||||
"Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
|
||||
"Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{
|
||||
name: 'externalLinks',
|
||||
in: 'body',
|
||||
type: 'object[]',
|
||||
desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.',
|
||||
desc: "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means no link-specific expiry; the owning client's enabled state and expiry still apply. A negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.",
|
||||
},
|
||||
],
|
||||
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n ]\n}',
|
||||
@@ -1077,7 +1239,7 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
name: 'emails',
|
||||
in: 'body (json)',
|
||||
type: 'array',
|
||||
type: 'string[]',
|
||||
desc: 'Emails of existing clients to attach.',
|
||||
},
|
||||
{
|
||||
@@ -1100,7 +1262,7 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
name: 'emails',
|
||||
in: 'body (json)',
|
||||
type: 'array',
|
||||
type: 'string[]',
|
||||
desc: 'Emails of existing clients to detach.',
|
||||
},
|
||||
{
|
||||
@@ -1666,12 +1828,14 @@ export const sections: readonly Section[] = [
|
||||
in: 'body',
|
||||
type: 'string',
|
||||
desc: 'admin (default), monitor, or node-sync.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'expiresAt',
|
||||
in: 'body',
|
||||
type: 'number',
|
||||
desc: 'Future Unix milliseconds, or 0 for no expiry.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: '{\n "name": "central-panel-a",\n "scope": "node-sync",\n "expiresAt": 1798761600000\n}',
|
||||
@@ -1766,6 +1930,7 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1778,25 +1943,35 @@ export const sections: readonly Section[] = [
|
||||
name: 'action',
|
||||
in: 'path',
|
||||
type: 'string',
|
||||
desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).',
|
||||
desc: 'data — return Warp stats. del — delete Warp data. config — return current config. reg — register (sends keys). changeIp — rotate the endpoint. license — set a Warp+ key. interval — set automatic rotation in hours.',
|
||||
},
|
||||
{
|
||||
name: 'privateKey',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'publicKey',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'license',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=license.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'interval',
|
||||
in: 'body (form)',
|
||||
type: 'integer',
|
||||
desc: 'Non-negative hours between automatic rotations. Required when action=interval; 0 disables rotation.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1816,9 +1991,22 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=servers.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'key',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=setKey.',
|
||||
optional: true,
|
||||
},
|
||||
{ name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1837,24 +2025,28 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=reg.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'countryCode',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=servers.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'hostname',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Required when action=addKey.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1889,12 +2081,14 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"tcp" for a fast dial-only probe (parallel-safe), "real" for a real-delay probe whose delay is the full request time including tunnel establishment. Default/empty uses a full HTTP probe reporting the warm per-request round-trip. Both HTTP variants run through a temp xray instance.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||
@@ -1916,12 +2110,14 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP), "real" for real-delay probes whose delay is the full request time including tunnel establishment. Default/empty routes an HTTP request through each outbound and reports the warm per-request round-trip.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
|
||||
@@ -1953,6 +2149,7 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Outbound tag to force. Empty clears the override and returns control to the strategy.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'tag=b1&target=proxy',
|
||||
@@ -1962,38 +2159,58 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/xray/routeTest',
|
||||
summary:
|
||||
'Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.',
|
||||
bodyRequiredOneOf: ['domain', 'ip'],
|
||||
params: [
|
||||
{
|
||||
name: 'domain',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Target domain. Either domain or ip is required.',
|
||||
optional: true,
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
name: 'ip',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Target IP. Either domain or ip is required.',
|
||||
optional: true,
|
||||
minLength: 1,
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
in: 'body (form)',
|
||||
type: 'number',
|
||||
desc: 'Target port (optional).',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'network',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"tcp" (default) or "udp".',
|
||||
optional: true,
|
||||
},
|
||||
{ name: 'port', in: 'body (form)', type: 'number', desc: 'Target port (optional).' },
|
||||
{ name: 'network', in: 'body (form)', type: 'string', desc: '"tcp" (default) or "udp".' },
|
||||
{
|
||||
name: 'inboundTag',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Simulate arrival on this inbound (optional).',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Sniffed protocol such as http, tls, bittorrent (optional).',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'User attribution for user-based rules (optional).',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'domain=example.com&port=443&network=tcp',
|
||||
@@ -2097,6 +2314,7 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"ip" to parse the tokens as IP rules (geoip:, ext-ip:, leading !). Anything else parses them as domain rules (geosite:, ext-site:).',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
body: 'kind=domain&tokens=geosite:google,geosite:blabla',
|
||||
@@ -2112,52 +2330,17 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/xray/outbound-subs',
|
||||
summary:
|
||||
'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
|
||||
params: [
|
||||
{ name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
|
||||
{
|
||||
name: 'url',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.',
|
||||
},
|
||||
{
|
||||
name: 'tagPrefix',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".',
|
||||
},
|
||||
{
|
||||
name: 'updateInterval',
|
||||
in: 'body (form)',
|
||||
type: 'integer',
|
||||
desc: 'Seconds between auto-refreshes. Default 600.',
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Whether the subscription is active. Default true.',
|
||||
},
|
||||
{
|
||||
name: 'allowPrivate',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).',
|
||||
},
|
||||
{
|
||||
name: 'prepend',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: "Place this subscription's outbounds before the manual template outbounds (so one can become the default). Default false.",
|
||||
},
|
||||
],
|
||||
params: outboundSubscriptionBodyParams,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/outbound-subs/:id',
|
||||
summary:
|
||||
'Update an existing outbound subscription by id. Accepts the same form fields as create.',
|
||||
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' }],
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
|
||||
...outboundSubscriptionBodyParams,
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
@@ -2191,6 +2374,7 @@ export const sections: readonly Section[] = [
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: '"up" to raise priority, anything else to lower it.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2206,6 +2390,20 @@ export const sections: readonly Section[] = [
|
||||
type: 'string',
|
||||
desc: 'Subscription URL to preview (required).',
|
||||
},
|
||||
{
|
||||
name: 'allowPrivate',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Allow a private/internal/loopback URL. Default false.',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'allowInsecure',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Skip TLS certificate verification. Default false.',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -2229,52 +2427,18 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/sub-balancers',
|
||||
summary:
|
||||
'Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.',
|
||||
params: [
|
||||
{
|
||||
name: 'remark',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Display label, used as the config remarks (required).',
|
||||
},
|
||||
{
|
||||
name: 'strategy',
|
||||
in: 'body (form)',
|
||||
type: 'string',
|
||||
desc: 'Balancer strategy: "leastLoad", "leastPing", "roundRobin" or "random" (xray routing balancer strategies). Default "random".',
|
||||
},
|
||||
{
|
||||
name: 'inboundIds',
|
||||
in: 'body (form)',
|
||||
type: 'integer[]',
|
||||
desc: 'Repeated form keys selecting the member inbounds, e.g. inboundIds=1&inboundIds=3 (required, at least one).',
|
||||
},
|
||||
{
|
||||
name: 'memberWeights',
|
||||
in: 'body (form)',
|
||||
type: 'object',
|
||||
desc: 'leastLoad only: JSON object mapping inbound id to a static weight > 0, e.g. {"3":0.2}. Lower weight = picked more often; absent ids weigh 1. Rejected for other strategies; entries for unselected inbounds are dropped.',
|
||||
},
|
||||
{
|
||||
name: 'sortOrder',
|
||||
in: 'body (form)',
|
||||
type: 'integer',
|
||||
desc: '1-based position in the subscription list, interleaved with the inbounds subSortIndex. Default 1.',
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
in: 'body (form)',
|
||||
type: 'boolean',
|
||||
desc: 'Whether the balancer is emitted. Default true.',
|
||||
},
|
||||
],
|
||||
params: subBalancerBodyParams,
|
||||
responseSchema: 'SubBalancer',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/sub-balancers/:id',
|
||||
summary:
|
||||
'Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle); omitting memberWeights clears stored weights.',
|
||||
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
|
||||
'Update a balancer by id. Accepts the same form fields as create (full-row update); omitting memberWeights clears stored weights, while omitting enabled keeps its current value.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' },
|
||||
...subBalancerBodyParams,
|
||||
],
|
||||
responseSchema: 'SubBalancer',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
@@ -1370,17 +1371,22 @@ export default function ClientFormModal({
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={`externalLinks.${index}.expiryTime`}
|
||||
render={({ field: expiryField }) => (
|
||||
<DateTimePicker
|
||||
value={
|
||||
Number(expiryField.value) > 0
|
||||
? dayjs(Number(expiryField.value))
|
||||
: null
|
||||
}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
/>
|
||||
)}
|
||||
render={({ field: expiryField }) => {
|
||||
const displayedExpiry = resolveExternalLinkExpiry(
|
||||
expiryField.value,
|
||||
expiryDate,
|
||||
);
|
||||
const hasSpecificExpiry = Number(expiryField.value) > 0;
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
allowClear={hasSpecificExpiry}
|
||||
maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1442,17 +1448,22 @@ export default function ClientFormModal({
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={`externalLinks.${index}.expiryTime`}
|
||||
render={({ field: expiryField }) => (
|
||||
<DateTimePicker
|
||||
value={
|
||||
Number(expiryField.value) > 0
|
||||
? dayjs(Number(expiryField.value))
|
||||
: null
|
||||
}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
/>
|
||||
)}
|
||||
render={({ field: expiryField }) => {
|
||||
const displayedExpiry = resolveExternalLinkExpiry(
|
||||
expiryField.value,
|
||||
expiryDate,
|
||||
);
|
||||
const hasSpecificExpiry = Number(expiryField.value) > 0;
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
allowClear={hasSpecificExpiry}
|
||||
maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text
|
||||
|
||||
@@ -20,6 +20,16 @@ function expectRangeWithin(value: string, min: number, max: number): [number, nu
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
/* Parses a plain integer and asserts min <= n <= max (see expectRangeWithin above for the range form). */
|
||||
function expectIntWithin(value: string, min: number, max: number): number {
|
||||
const m = /^(\d+)$/.exec(value);
|
||||
expect(m, `${value} is not a plain integer`).not.toBeNull();
|
||||
const n = Number(m![1]);
|
||||
expect(n).toBeGreaterThanOrEqual(min);
|
||||
expect(n).toBeLessThanOrEqual(max);
|
||||
return n;
|
||||
}
|
||||
|
||||
describe('generateAwgObfuscation', () => {
|
||||
it('stays inside the Go generator ranges and invariants', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
@@ -37,9 +47,11 @@ describe('generateAwgObfuscation', () => {
|
||||
expect(o.s4).toBeGreaterThanOrEqual(12);
|
||||
expect(o.s4).toBeLessThanOrEqual(27);
|
||||
|
||||
const hBounds = [o.h1, o.h2, o.h3, o.h4].map((h) => expectRangeWithin(h, 5, 2147483647));
|
||||
const hValues = [o.h1, o.h2, o.h3, o.h4].map((h) => expectIntWithin(h, 5, 2147483647));
|
||||
for (let j = 1; j < 4; j++) {
|
||||
expect(hBounds[j][0], 'H ranges must not overlap').toBeGreaterThan(hBounds[j - 1][1]);
|
||||
expect(hValues[j], 'H values must be strictly increasing across bands').toBeGreaterThan(
|
||||
hValues[j - 1],
|
||||
);
|
||||
}
|
||||
|
||||
expect(o.i1).toMatch(/^<r \d+>$/);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
|
||||
describe('resolveExternalLinkExpiry', () => {
|
||||
it('uses the client expiry when the external link has no specific expiry', () => {
|
||||
expect(resolveExternalLinkExpiry(0, 1_800_000_000_000)).toBe(1_800_000_000_000);
|
||||
});
|
||||
|
||||
it('keeps an explicit external-link expiry', () => {
|
||||
expect(resolveExternalLinkExpiry(1_700_000_000_000, 1_800_000_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('stays empty when neither expiry is set', () => {
|
||||
expect(resolveExternalLinkExpiry(0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,32 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DateTimePicker from '@/components/form/DateTimePicker';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
vi.mock('persian-calendar-suite', () => ({
|
||||
PersianDateTimePicker: ({
|
||||
maxDate,
|
||||
onChange,
|
||||
}: {
|
||||
maxDate?: Date;
|
||||
onChange?: (value: number) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Persian date time picker"
|
||||
data-testid="persian-date-time-picker"
|
||||
data-max-date={maxDate?.toISOString()}
|
||||
onClick={() => onChange?.((maxDate?.getTime() ?? 0) + 1)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => setDatepicker('gregorian'));
|
||||
|
||||
function openPicker(): void {
|
||||
const input = document.querySelector('.ant-picker input');
|
||||
if (!input) throw new Error('picker input not rendered');
|
||||
@@ -40,4 +61,34 @@ describe('DateTimePicker', () => {
|
||||
|
||||
expect(document.querySelector('.ant-picker-ok')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the Gregorian clear control and disables dates after maxDate', () => {
|
||||
const maxDate = dayjs().add(1, 'day').startOf('day');
|
||||
renderWithProviders(
|
||||
<DateTimePicker value={maxDate} onChange={vi.fn()} allowClear={false} maxDate={maxDate} />,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-picker-clear')).toBeNull();
|
||||
openPicker();
|
||||
const blockedCell = document.querySelector(
|
||||
`.ant-picker-cell[title="${maxDate.add(1, 'day').format('YYYY-MM-DD')}"]`,
|
||||
);
|
||||
expect(blockedCell?.classList.contains('ant-picker-cell-disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies clear and max-date constraints to the Jalali picker', () => {
|
||||
setDatepicker('jalalian');
|
||||
const maxDate = dayjs('2030-01-02T03:04:05');
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateTimePicker value={maxDate} onChange={onChange} allowClear={false} maxDate={maxDate} />,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.jdp-clear')).toBeNull();
|
||||
expect(screen.getByTestId('persian-date-time-picker').dataset.maxDate).toBe(
|
||||
maxDate.toDate().toISOString(),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('persian-date-time-picker'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSpec } from '../../scripts/build-openapi.mjs';
|
||||
|
||||
interface OpenApiSchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
description?: string;
|
||||
default?: string | number | boolean;
|
||||
minLength?: number;
|
||||
pattern?: string;
|
||||
properties?: Record<string, OpenApiSchema>;
|
||||
required?: string[];
|
||||
items?: OpenApiSchema;
|
||||
anyOf?: OpenApiSchema[];
|
||||
}
|
||||
|
||||
interface OpenApiRequestBody {
|
||||
required?: boolean;
|
||||
content: Record<
|
||||
string,
|
||||
{
|
||||
schema: OpenApiSchema;
|
||||
encoding?: Record<string, { style?: string; explode?: boolean; contentType?: string }>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
interface OpenApiOperation {
|
||||
requestBody?: OpenApiRequestBody;
|
||||
}
|
||||
|
||||
const paths = buildSpec().paths as Record<string, Record<string, OpenApiOperation>>;
|
||||
|
||||
function requestBody(path: string): OpenApiRequestBody {
|
||||
const body = paths[path]?.post?.requestBody;
|
||||
if (!body) throw new Error(`${path} has no POST request body`);
|
||||
return body;
|
||||
}
|
||||
|
||||
describe('generated OpenAPI request bodies', () => {
|
||||
it('preserves JSON, form, and multipart parameter declarations', () => {
|
||||
const login = requestBody('/login').content['application/json'];
|
||||
expect(login.schema.properties).toHaveProperty('username');
|
||||
expect(login.schema.required).toEqual(['username', 'password']);
|
||||
|
||||
const json = requestBody('/panel/api/inbounds/pushClientTraffics').content['application/json'];
|
||||
expect(json.schema.properties).toHaveProperty('traffics');
|
||||
|
||||
const form = requestBody('/panel/api/inbounds/import').content[
|
||||
'application/x-www-form-urlencoded'
|
||||
];
|
||||
expect(form.schema.properties).toHaveProperty('data');
|
||||
|
||||
const logs = requestBody('/panel/api/server/logs/{count}');
|
||||
expect(logs.content).toHaveProperty('application/x-www-form-urlencoded');
|
||||
expect(logs.content['application/x-www-form-urlencoded'].schema.properties).toHaveProperty(
|
||||
'syslog',
|
||||
);
|
||||
|
||||
const outboundTest = requestBody('/panel/api/xray/testOutbound').content[
|
||||
'application/x-www-form-urlencoded'
|
||||
];
|
||||
expect(outboundTest.schema.required).toEqual(['outbound']);
|
||||
|
||||
const outboundUpdate = requestBody('/panel/api/xray/outbound-subs/{id}').content[
|
||||
'application/x-www-form-urlencoded'
|
||||
];
|
||||
expect(outboundUpdate.schema.required).toEqual(['url']);
|
||||
expect(outboundUpdate.schema.properties).toHaveProperty('allowInsecure');
|
||||
|
||||
const balancerUpdate = requestBody('/panel/api/sub-balancers/{id}').content[
|
||||
'application/x-www-form-urlencoded'
|
||||
];
|
||||
expect(balancerUpdate.schema.required).toEqual(['remark', 'inboundIds']);
|
||||
expect(balancerUpdate.encoding?.inboundIds).toEqual({ style: 'form', explode: true });
|
||||
expect(balancerUpdate.encoding?.memberWeights).toEqual({ contentType: 'application/json' });
|
||||
|
||||
const inboundUpdate = requestBody('/panel/api/inbounds/update/{id}').content[
|
||||
'application/json'
|
||||
];
|
||||
expect(inboundUpdate.schema).toEqual({ type: 'object' });
|
||||
|
||||
const multipart = requestBody('/panel/api/server/importDB').content['multipart/form-data'];
|
||||
expect(multipart.schema.properties?.db).toEqual({
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'Database backup or migration file to upload.',
|
||||
});
|
||||
expect(multipart.schema.properties).toHaveProperty('keepHostSettings');
|
||||
expect(multipart.schema.properties?.keepHostSettings?.default).toBe(true);
|
||||
|
||||
const array = requestBody('/panel/api/server/clientIps').content['application/json'];
|
||||
expect(array.schema).toEqual({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
clientEmail: { type: 'string' },
|
||||
ips: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { ip: { type: 'string' }, timestamp: { type: 'integer' } },
|
||||
required: ['ip', 'timestamp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['clientEmail', 'ips'],
|
||||
},
|
||||
});
|
||||
|
||||
const certHash = requestBody('/panel/api/server/getCertHash');
|
||||
expect(certHash.required).toBe(true);
|
||||
const certSchema = certHash.content['application/x-www-form-urlencoded'].schema;
|
||||
expect(certSchema.anyOf?.map((branch) => branch.required)).toEqual([
|
||||
['certFile'],
|
||||
['certContent'],
|
||||
]);
|
||||
expect(certSchema.anyOf?.[0].properties?.certFile.pattern).toBe('.*\\S.*');
|
||||
expect(certSchema.anyOf?.[0].properties?.certContent).not.toHaveProperty('pattern');
|
||||
expect(certSchema.anyOf?.[1].properties?.certFile).not.toHaveProperty('pattern');
|
||||
expect(certSchema.anyOf?.[1].properties?.certContent.pattern).toBe('.*\\S.*');
|
||||
|
||||
const routeSchema = requestBody('/panel/api/xray/routeTest').content[
|
||||
'application/x-www-form-urlencoded'
|
||||
].schema;
|
||||
expect(routeSchema.anyOf?.map((branch) => branch.required)).toEqual([['domain'], ['ip']]);
|
||||
expect(routeSchema.anyOf?.[0].properties?.domain?.minLength).toBe(1);
|
||||
expect(routeSchema.anyOf?.[0].properties?.ip).not.toHaveProperty('minLength');
|
||||
expect(routeSchema.anyOf?.[1].properties?.domain).not.toHaveProperty('minLength');
|
||||
expect(routeSchema.anyOf?.[1].properties?.ip?.minLength).toBe(1);
|
||||
|
||||
const bulkAttach = requestBody('/panel/api/clients/bulkAttach').content['application/json'];
|
||||
expect(bulkAttach.schema.properties?.emails?.items).toEqual({ type: 'string' });
|
||||
|
||||
expect(paths['/panel/api/server/updateGeofile'].post).not.toHaveProperty('requestBody');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/mhsanaei/3x-ui/v3
|
||||
|
||||
go 1.27.0
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/amnezia-vpn/amneziawg-go/v3 v3.1.20260828
|
||||
|
||||
+30
@@ -1433,6 +1433,34 @@ resolve_latest_tag() {
|
||||
curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'
|
||||
}
|
||||
|
||||
# Releases publish <asset>.sha256 next to each archive. A mismatch or a failed
|
||||
# sidecar download aborts the install; only a 404 (releases predating the
|
||||
# sidecar) is tolerated with a warning.
|
||||
verify_release_checksum() {
|
||||
local url="$1" file="$2" sums="$2.sha256" code expected actual
|
||||
rm -f "${sums}"
|
||||
code=$(curl -sL --retry 3 --retry-delay 3 --connect-timeout 15 --max-time 60 -o "${sums}" -w '%{http_code}' "${url}.sha256")
|
||||
if [[ "${code}" == "404" ]]; then
|
||||
rm -f "${sums}"
|
||||
echo -e "${yellow}No checksum published for this release, skipping verification${plain}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "${code}" != "200" ]]; then
|
||||
rm -f "${sums}" "${file}"
|
||||
echo -e "${red}Failed to download the checksum for $(basename "${file}") (HTTP ${code})${plain}"
|
||||
exit 1
|
||||
fi
|
||||
expected=$(awk 'NR == 1 {print $1}' "${sums}")
|
||||
actual=$(sha256sum "${file}" | awk '{print $1}')
|
||||
rm -f "${sums}"
|
||||
if [[ ! "${expected}" =~ ^[0-9a-f]{64}$ || "${expected}" != "${actual}" ]]; then
|
||||
rm -f "${file}"
|
||||
echo -e "${red}Checksum mismatch for $(basename "${file}"): expected ${expected:-<none>}, got ${actual}${plain}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${green}Checksum verified: ${actual}${plain}"
|
||||
}
|
||||
|
||||
# Older tags predate some of these files (x-ui.rc arrived in v2.8.4). Serving
|
||||
# main's copy against an old binary is the mismatch this pinning exists to
|
||||
# prevent, so probe before anything is stopped or removed and refuse the tag.
|
||||
@@ -1471,6 +1499,7 @@ install_x-ui() {
|
||||
echo -e "${red}Downloaded x-ui release archive is empty${plain}"
|
||||
exit 1
|
||||
fi
|
||||
verify_release_checksum "https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz" "${xui_folder}-linux-$(arch).tar.gz"
|
||||
else
|
||||
tag_version=$1
|
||||
# The rolling dev channel ships under a fixed, non-semver tag that is
|
||||
@@ -1501,6 +1530,7 @@ install_x-ui() {
|
||||
echo -e "${red}Downloaded x-ui release archive is empty${plain}"
|
||||
exit 1
|
||||
fi
|
||||
verify_release_checksum "${url}" "${xui_folder}-linux-$(arch).tar.gz"
|
||||
fi
|
||||
# x-ui.sh, x-ui.rc and the unit files must come from the same release as
|
||||
# the binary; only the rolling dev build tracks main.
|
||||
|
||||
@@ -15,9 +15,6 @@ import (
|
||||
// but the amneziawg-windows-client config editor rejects anything above.
|
||||
const awgHMax = 2147483647
|
||||
|
||||
// hMinWidth is the minimum width of each generated H1-H4 range.
|
||||
const hMinWidth = 1000
|
||||
|
||||
// hMaxValid is the largest value ValidateObfuscation accepts for an H
|
||||
// parameter: uint32 max, the kernel's own limit.
|
||||
const hMaxValid int64 = 4294967295
|
||||
@@ -56,7 +53,7 @@ func GenerateObfuscation31() Obfuscation31 {
|
||||
o.S3 = randInt(12, 55) // cookie padding (max 64)
|
||||
o.S4 = randInt(12, 27) // transport padding (max 32)
|
||||
|
||||
h := generateHRanges()
|
||||
h := generateHValues()
|
||||
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
|
||||
|
||||
// CPS signature packet, N random bytes before each handshake. I2-I5 stay
|
||||
@@ -109,19 +106,16 @@ func generateHeaderProtectionKey() string {
|
||||
return base64.StdEncoding.EncodeToString(key)
|
||||
}
|
||||
|
||||
// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4,
|
||||
// one per band of the space so non-overlap needs no retries. The low bound is
|
||||
// >= 5: values 1-4 are reserved for vanilla WireGuard message types.
|
||||
func generateHRanges() [4]string {
|
||||
// generateHValues returns one distinct value per H1-H4 band; low bound >= 5 (1-4 are vanilla WG message types).
|
||||
// Single values, not ranges: with RandomTrailers on, a wide range misclassifies transport packets as handshakes (amnezia-vpn/amneziawg-go#183).
|
||||
func generateHValues() [4]string {
|
||||
const lo = 5
|
||||
bandSize := (awgHMax - lo + 1) / 4
|
||||
var out [4]string
|
||||
for i := 0; i < 4; i++ {
|
||||
bandLo := lo + i*bandSize
|
||||
bandHi := bandLo + bandSize - 1
|
||||
start := randInt(bandLo, bandHi-hMinWidth-1)
|
||||
end := randInt(start+hMinWidth, bandHi-1)
|
||||
out[i] = fmt.Sprintf("%d-%d", start, end)
|
||||
out[i] = fmt.Sprintf("%d", randInt(bandLo, bandHi))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -95,24 +95,19 @@ func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
func TestGenerateHRangesNonOverlapping(t *testing.T) {
|
||||
func TestGenerateHValuesDistinct(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
h := generateHRanges()
|
||||
var prevHi int64
|
||||
for i, r := range h {
|
||||
lo, hi, ok := strings.Cut(r, "-")
|
||||
if !ok {
|
||||
t.Fatalf("H%d = %q is not a range", i+1, r)
|
||||
h := generateHValues()
|
||||
var prev int64
|
||||
for i, v := range h {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("H%d = %q is not a plain integer: %v", i+1, v, err)
|
||||
}
|
||||
loN, _ := strconv.ParseInt(lo, 10, 64)
|
||||
hiN, _ := strconv.ParseInt(hi, 10, 64)
|
||||
if loN <= prevHi {
|
||||
t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi)
|
||||
if n <= prev {
|
||||
t.Fatalf("H%d = %q is not strictly greater than the previous value (%d)", i+1, v, prev)
|
||||
}
|
||||
if hiN-loN < hMinWidth {
|
||||
t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth)
|
||||
}
|
||||
prevHi = hiN
|
||||
prev = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import (
|
||||
)
|
||||
|
||||
// tunQueueDepth is the outbound queue depth for channel endpoint and handoff.
|
||||
const tunQueueDepth = 1024
|
||||
// 1024 starved simultaneous TCP slow-starts; channel.Endpoint drops silently when full.
|
||||
const tunQueueDepth = 8192
|
||||
|
||||
// stackTun implements amneziawg-go tun.Device over a gVisor channel endpoint,
|
||||
// exposing *stack.Stack for forwarder attachment.
|
||||
|
||||
@@ -39,6 +39,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
|
||||
var proxies []map[string]any
|
||||
var hasInactiveExternal bool
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
for _, inbound := range inbounds {
|
||||
@@ -56,6 +57,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
if !ext.Active {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
hasInactiveExternal = true
|
||||
continue
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
name := el.Name
|
||||
if name == "" {
|
||||
@@ -68,17 +74,21 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) == 0 {
|
||||
if len(proxies) == 0 && !hasInactiveExternal {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
if len(proxies) == 0 {
|
||||
return "", header, nil
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
proxyNames := make([]string, 0, len(proxies)+1)
|
||||
for _, proxy := range proxies {
|
||||
@@ -116,7 +126,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
return string(finalYAML), header, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -345,7 +345,7 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
|
||||
subReq := a.subService.ForRequest(host)
|
||||
subReq.subscriptionBody = false
|
||||
subs, emails, lastOnline, traffic, err := subReq.getSubs(subId)
|
||||
if err != nil || len(subs) == 0 {
|
||||
if err != nil || subs == nil {
|
||||
writeSubError(c, err)
|
||||
return PageData{}, false
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
subReq := a.subService.ForRequest(host)
|
||||
subReq.subscriptionBody = true
|
||||
subs, _, _, traffic, err := subReq.getSubs(subId)
|
||||
if err != nil || len(subs) == 0 {
|
||||
if err != nil || subs == nil {
|
||||
writeSubError(c, err)
|
||||
} else {
|
||||
var result strings.Builder
|
||||
@@ -742,7 +742,7 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
writeSubError(c, err)
|
||||
return true
|
||||
}
|
||||
if len(jsonSub) == 0 {
|
||||
if len(jsonSub) == 0 && header == "" {
|
||||
return false
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
@@ -793,7 +793,7 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
||||
writeSubError(c, err)
|
||||
return true
|
||||
}
|
||||
if len(clashSub) == 0 {
|
||||
if len(clashSub) == 0 && header == "" {
|
||||
return false
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
)
|
||||
|
||||
// externalLinkEntry is one client Ă— external-link row, resolved for a
|
||||
// subscription request. Email/Enable come from the owning client.
|
||||
// externalLinkEntry is one client Ă— external-link row resolved for a request.
|
||||
// Active applies the owning client's enabled and expiry state.
|
||||
type externalLinkEntry struct {
|
||||
Kind string
|
||||
Value string
|
||||
@@ -23,6 +23,7 @@ type externalLinkEntry struct {
|
||||
NamePrefix string
|
||||
Email string
|
||||
Enable bool
|
||||
Active bool
|
||||
}
|
||||
|
||||
// expandedLink is a single share link contributed by an entry, with the display
|
||||
@@ -32,9 +33,8 @@ type expandedLink struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// getClientExternalLinksBySubId returns every external-link row attached to a
|
||||
// client that carries the given subId, in stable order. Stays inside
|
||||
// internal/sub + database + util/link — no dependency on the panel service layer.
|
||||
// getClientExternalLinksBySubId returns active rows with owner state attached.
|
||||
// Consumers keep inactive owners as metadata but omit their link values.
|
||||
func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
|
||||
db := database.GetDB()
|
||||
var recs []model.ClientRecord
|
||||
@@ -74,6 +74,7 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
|
||||
NamePrefix: r.NamePrefix,
|
||||
Email: rec.Email,
|
||||
Enable: rec.Enable,
|
||||
Active: rec.Enable && (rec.ExpiryTime <= 0 || rec.ExpiryTime > now),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func seedInactiveExternalOnlySub(t *testing.T, subID, email string, enabled bool, expiry int64) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
rec := &model.ClientRecord{Email: email, SubID: subID, UUID: subID + "-uuid", Enable: true, ExpiryTime: expiry}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if !enabled {
|
||||
if err := db.Model(rec).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("disable client: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{Email: email, Up: 11, Down: 22, Total: 1024, ExpiryTime: expiry}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
link := "vless://11111111-1111-1111-1111-111111111111@example.com:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#external"
|
||||
if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: link, SortIndex: 1}).Error; err != nil {
|
||||
t.Fatalf("seed external link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInactiveExternalOnlySubRemainsKnownWithoutExposingLinks(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
states := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
expiry int64
|
||||
}{
|
||||
{name: "disabled", enabled: false, expiry: time.Now().Add(time.Hour).UnixMilli()},
|
||||
{name: "expired", enabled: true, expiry: time.Now().Add(-time.Hour).UnixMilli()},
|
||||
}
|
||||
|
||||
for _, state := range states {
|
||||
t.Run(state.name, func(t *testing.T) {
|
||||
initSubDB(t)
|
||||
subID := "external-" + state.name
|
||||
email := state.name + "@example.com"
|
||||
seedInactiveExternalOnlySub(t, subID, email, state.enabled, state.expiry)
|
||||
|
||||
oldDistFS := distFS
|
||||
distFS = testDistFS
|
||||
t.Cleanup(func() { distFS = oldDistFS })
|
||||
|
||||
router := gin.New()
|
||||
NewSUBController(
|
||||
router.Group("/"),
|
||||
WithSUBJsonEnabled(true),
|
||||
WithSUBClashEnabled(true),
|
||||
WithSUBEncryption(false),
|
||||
)
|
||||
|
||||
wantHeader := fmt.Sprintf("upload=11; download=22; total=1024; expire=%d", state.expiry/1000)
|
||||
for _, path := range []string{"/sub/" + subID, "/json/" + subID + "?view=raw", "/clash/" + subID + "?view=raw"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("last_sub_fetch", 0).Error; err != nil {
|
||||
t.Fatalf("reset last_sub_fetch: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Host = "sub.example.com"
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatalf("inactive external link leaked in body: %s", w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Subscription-Userinfo"); got != wantHeader {
|
||||
t.Fatalf("Subscription-Userinfo = %q, want %q", got, wantHeader)
|
||||
}
|
||||
var traffic xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&traffic).Error; err != nil {
|
||||
t.Fatalf("load traffic: %v", err)
|
||||
}
|
||||
if traffic.LastSubFetch == 0 {
|
||||
t.Fatal("successful empty response did not update last_sub_fetch")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sub/"+subID, nil)
|
||||
req.Host = "sub.example.com"
|
||||
req.Header.Set("Accept", "text/html")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("HTML status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "11111111-1111-1111-1111-111111111111") {
|
||||
t.Fatalf("HTML page exposed inactive external link: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"links":[]`) {
|
||||
t.Fatalf("HTML page did not render an empty links list: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
}
|
||||
|
||||
var header string
|
||||
var hasInactiveExternal bool
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
entries := make([]subConfigEntry, 0, len(inbounds))
|
||||
@@ -127,6 +128,11 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
configArray = append(configArray, entry.configs...)
|
||||
}
|
||||
for _, ext := range externalLinks {
|
||||
if !ext.Active {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
hasInactiveExternal = true
|
||||
continue
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
outbound := parsedExternalOutbound(el.Link)
|
||||
if outbound == nil {
|
||||
@@ -148,7 +154,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
}
|
||||
}
|
||||
|
||||
if len(configArray) == 0 {
|
||||
if len(configArray) == 0 && !hasInactiveExternal {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
@@ -157,6 +163,10 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := subReq.AggregateTrafficByEmails(emails)
|
||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
if len(configArray) == 0 {
|
||||
return "", header, nil
|
||||
}
|
||||
|
||||
var finalJson []byte
|
||||
if len(configArray) == 1 && !alwaysReturnArray {
|
||||
@@ -165,7 +175,6 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
|
||||
finalJson, _ = json.MarshalIndent(configArray, "", " ")
|
||||
}
|
||||
|
||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
return string(finalJson), header, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
||||
|
||||
// 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}
|
||||
rec := &model.ClientRecord{Email: "owner@x", SubID: "sub-ok", UUID: "u2", Enable: true, ExpiryTime: time.Now().Add(time.Hour).UnixMilli()}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
@@ -331,10 +331,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
||||
if out[0].Email != "owner@x" || out[0].Enable != true {
|
||||
t.Fatalf("attribution wrong: email=%q enable=%v", out[0].Email, out[0].Enable)
|
||||
}
|
||||
if !out[0].Active {
|
||||
t.Fatal("active owner marked inactive")
|
||||
}
|
||||
|
||||
// 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).
|
||||
// A disabled owner stays visible as metadata but cannot expose its link.
|
||||
// Enable has a gorm default:true, so update it after insertion.
|
||||
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)
|
||||
@@ -352,8 +354,26 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
|
||||
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)
|
||||
if offOut[0].Enable || offOut[0].Active {
|
||||
t.Fatalf("disabled owner state = enable:%v active:%v", offOut[0].Enable, offOut[0].Active)
|
||||
}
|
||||
|
||||
expired := &model.ClientRecord{Email: "expired@x", SubID: "sub-expired", UUID: "u4", Enable: true, ExpiryTime: time.Now().Add(-time.Hour).UnixMilli()}
|
||||
if err := db.Create(expired).Error; err != nil {
|
||||
t.Fatalf("seed expired client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientExternalLink{ClientId: expired.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://d", SortIndex: 1}).Error; err != nil {
|
||||
t.Fatalf("seed expired client link: %v", err)
|
||||
}
|
||||
expiredOut, err := s.getClientExternalLinksBySubId("sub-expired")
|
||||
if err != nil {
|
||||
t.Fatalf("expired subId err = %v", err)
|
||||
}
|
||||
if len(expiredOut) != 1 {
|
||||
t.Fatalf("expired client entries = %d, want 1", len(expiredOut))
|
||||
}
|
||||
if !expiredOut[0].Enable || expiredOut[0].Active {
|
||||
t.Fatalf("expired owner state = enable:%v active:%v", expiredOut[0].Enable, expiredOut[0].Active)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,13 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
|
||||
if ext.Enable {
|
||||
hasEnabledClient = true
|
||||
}
|
||||
if !ext.Active {
|
||||
seenEmails[ext.Email] = struct{}{}
|
||||
if result == nil {
|
||||
result = []string{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, el := range expandEntry(ext) {
|
||||
if link := applyRemarkToLink(el.Link, el.Name); link != "" {
|
||||
result = append(result, link)
|
||||
|
||||
@@ -186,15 +186,21 @@ func (a *ClientController) create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
|
||||
// Flagged before the error check: a partly-applied create leaves clients
|
||||
// committed on the inbounds that succeeded, and those still need the restart.
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// A partly-applied call committed real clients; a rejected one touched
|
||||
// nothing, and broadcasting those would refetch every panel for nothing.
|
||||
if needRestart || err == nil {
|
||||
notifyClientsChanged()
|
||||
}
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
@@ -251,15 +257,19 @@ func (a *ClientController) attach(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// A partly-applied call committed real clients; a rejected one touched
|
||||
// nothing, and broadcasting those would refetch every panel for nothing.
|
||||
if needRestart || err == nil {
|
||||
notifyClientsChanged()
|
||||
}
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) setExternalLinks(c *gin.Context) {
|
||||
|
||||
@@ -237,22 +237,24 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int,
|
||||
restartNeeded := false
|
||||
for _, c := range newClients {
|
||||
nr, err := j.clientService.Create(&j.inboundService, &service.ClientCreatePayload{Client: c, InboundIds: inboundIds})
|
||||
// Read before the error check: a partly-applied create still committed
|
||||
// clients on the inbounds that succeeded, and those need the restart.
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to add client %s for tags %s: %v", c.Email, tagList, err)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
if created == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
||||
|
||||
@@ -91,3 +91,42 @@ func TestLdapCreateClients_AttachesToAllConfiguredInbounds(t *testing.T) {
|
||||
t.Error("vless inbound client must get a generated uuid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLdapCreateClients_FlagsRestartWhenEveryClientPartlyApplies pins that the
|
||||
// restart survives created == 0, the case a partly-applied batch always hits.
|
||||
func TestLdapCreateClients_FlagsRestartWhenEveryClientPartlyApplies(t *testing.T) {
|
||||
initLdapJobDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
healthy := &model.Inbound{
|
||||
UserId: 1, Tag: "in-42180-tcp", Enable: true, Port: 42180,
|
||||
Protocol: model.VLESS, Settings: `{"clients": []}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
broken := &model.Inbound{
|
||||
UserId: 1, Tag: "in-42181-tcp", Enable: true, Port: 42181,
|
||||
Protocol: model.VLESS, Settings: `{"clients":`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
for _, ib := range []*model.Inbound{healthy, broken} {
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
j := NewLdapSyncJob()
|
||||
j.xrayService.IsNeedRestartAndSetFalse()
|
||||
j.createClients([]model.Client{j.buildClient("partial@example.com", 0, 0, 0)},
|
||||
[]int{healthy.Id, broken.Id}, []string{healthy.Tag, broken.Tag})
|
||||
|
||||
clients, err := (&service.ClientService{}).ListForInbound(nil, healthy.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(%s): %v", healthy.Tag, err)
|
||||
}
|
||||
if len(clients) != 1 {
|
||||
t.Fatalf("healthy inbound holds %d clients, want the partly-applied 1", len(clients))
|
||||
}
|
||||
if !j.xrayService.IsNeedRestartAndSetFalse() {
|
||||
t.Fatal("a partly-applied LDAP batch left Xray unflagged for restart")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func TestCreateAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
|
||||
@@ -82,3 +89,252 @@ func TestAttachAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
|
||||
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
// barrierNodeRuntime holds every AddClient until fanout of them are inside it at
|
||||
// once, recording the peak overlap; a sequential caller only ever reaches one.
|
||||
type barrierNodeRuntime struct {
|
||||
fakeNodeRuntime
|
||||
fanout int32
|
||||
inFlight atomic.Int32
|
||||
maxPar atomic.Int32
|
||||
release chan struct{}
|
||||
freed atomic.Bool
|
||||
expired atomic.Bool
|
||||
}
|
||||
|
||||
func (b *barrierNodeRuntime) free() {
|
||||
if b.freed.CompareAndSwap(false, true) {
|
||||
close(b.release)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *barrierNodeRuntime) AddClient(ctx context.Context, ib *model.Inbound, c model.Client) error {
|
||||
n := b.inFlight.Add(1)
|
||||
for {
|
||||
peak := b.maxPar.Load()
|
||||
if n <= peak || b.maxPar.CompareAndSwap(peak, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n == b.fanout {
|
||||
b.free()
|
||||
}
|
||||
select {
|
||||
case <-b.release:
|
||||
case <-time.After(5 * time.Second):
|
||||
// Release everyone on the first timeout so a sequential regression
|
||||
// fails once instead of stalling for fanout x the wait.
|
||||
b.expired.Store(true)
|
||||
b.free()
|
||||
}
|
||||
b.inFlight.Add(-1)
|
||||
return b.fakeNodeRuntime.AddClient(ctx, ib, c)
|
||||
}
|
||||
|
||||
func fanoutNodeInbounds(t *testing.T, mgr *runtime.Manager, rt runtime.Runtime, n int, basePort int) []int {
|
||||
t.Helper()
|
||||
ids := make([]int, 0, n)
|
||||
for i := range n {
|
||||
node := &model.Node{
|
||||
Name: fmt.Sprintf("%s-%d", t.Name(), i), Address: "127.0.0.1", Port: 2096 + i,
|
||||
ApiToken: "tok", Enable: true, Status: "online",
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node %d: %v", i, err)
|
||||
}
|
||||
mgr.SetRuntimeOverride(node.Id, rt)
|
||||
ids = append(ids, nodeInbound(t, node.Id, basePort+i, nil).Id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestCreateAcrossNodesPushesConcurrently pins that a client spanning several
|
||||
// node inbounds pushes to them at once, up to inboundFanoutConcurrency at a time.
|
||||
func TestCreateAcrossNodesPushesConcurrently(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
startSerializedWriter(t)
|
||||
mgr := useTestRuntimeManager(t)
|
||||
|
||||
const nodes = inboundFanoutConcurrency + 1
|
||||
bar := &barrierNodeRuntime{fanout: inboundFanoutConcurrency, release: make(chan struct{})}
|
||||
ids := fanoutNodeInbounds(t, mgr, bar, nodes, 40101)
|
||||
|
||||
if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "fanout@x", ID: "11111111-2222-3333-4444-555555555555", SubID: "sub-fanout", Enable: true},
|
||||
InboundIds: ids,
|
||||
}); err != nil {
|
||||
t.Fatalf("Create across %d node inbounds: %v", nodes, err)
|
||||
}
|
||||
|
||||
if got := bar.addClient.Load(); got != nodes {
|
||||
t.Fatalf("AddClient pushes = %d, want %d", got, nodes)
|
||||
}
|
||||
if got := bar.maxPar.Load(); got < 2 || got != inboundFanoutConcurrency {
|
||||
t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
|
||||
got, inboundFanoutConcurrency, bar.expired.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateRecoversPanicInOneInbound pins that a panicking inbound fails only
|
||||
// itself: off the request goroutine nothing else would catch it.
|
||||
func TestCreateRecoversPanicInOneInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
startSerializedWriter(t)
|
||||
mgr := useTestRuntimeManager(t)
|
||||
|
||||
node := &model.Node{
|
||||
Name: t.Name(), Address: "127.0.0.1", Port: 2096,
|
||||
ApiToken: "tok", Enable: true, Status: "online",
|
||||
}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
mgr.SetRuntimeOverride(node.Id, &panicNodeRuntime{})
|
||||
boom := nodeInbound(t, node.Id, 40201, nil)
|
||||
healthy := mkInbound(t, 40202, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
const uuid = "33333333-4444-5555-6666-777777777777"
|
||||
_, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "panic@x", ID: uuid, SubID: "sub-panic", Enable: true},
|
||||
InboundIds: []int{boom.Id, healthy.Id},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a panicking node runtime produced no error")
|
||||
}
|
||||
if want := fmt.Sprintf("inbound %d: panic:", boom.Id); !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not report %q", err, want)
|
||||
}
|
||||
if !settingsHoldUUID(t, &InboundService{}, healthy.Id, uuid) {
|
||||
t.Fatalf("healthy inbound %d did not get the client", healthy.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateLeavesHwidLimitAloneWhenCreateFails pins that a create the panel
|
||||
// reported as failed never rewrites a device cap, so it can never retrim one.
|
||||
func TestCreateLeavesHwidLimitAloneWhenCreateFails(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
startSerializedWriter(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
const vipUUID = "44444444-5555-6666-7777-888888888888"
|
||||
seed := mkInbound(t, 41401, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "vip@x", ID: vipUUID, SubID: "sub-vip", Enable: true},
|
||||
InboundIds: []int{seed.Id},
|
||||
LimitHwid: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed Create: %v", err)
|
||||
}
|
||||
|
||||
broken := mkInbound(t, 41402, model.VLESS, `{"clients":`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "vip@x", ID: vipUUID, SubID: "sub-vip", Enable: true},
|
||||
InboundIds: []int{broken.Id},
|
||||
LimitHwid: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("re-adding to an unparsable inbound returned no error")
|
||||
}
|
||||
|
||||
if rec := lookupClientRecord(t, "vip@x"); rec.LimitHwid != 3 {
|
||||
t.Fatalf("limit_hwid = %d, want the untouched 3: a failed create retrimmed a live client", rec.LimitHwid)
|
||||
}
|
||||
|
||||
// Same failure with the seeded inbound alongside it: that one is a dedup
|
||||
// no-op returning no error, which must not read as "an inbound took it".
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "vip@x", ID: vipUUID, SubID: "sub-vip", Enable: true},
|
||||
InboundIds: []int{seed.Id, broken.Id},
|
||||
LimitHwid: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("re-adding over a no-op and an unparsable inbound returned no error")
|
||||
}
|
||||
if rec := lookupClientRecord(t, "vip@x"); rec.LimitHwid != 3 {
|
||||
t.Fatalf("limit_hwid = %d, want the untouched 3: a no-op inbound counted as applied", rec.LimitHwid)
|
||||
}
|
||||
|
||||
// A brand new identity that only partly applies is left uncapped rather than
|
||||
// capped, the deliberate safe side: the operator saw the error and retries.
|
||||
healthy := mkInbound(t, 41403, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "fresh@x", ID: "55555555-6666-7777-8888-999999999999", SubID: "sub-fresh", Enable: true},
|
||||
InboundIds: []int{healthy.Id, broken.Id},
|
||||
LimitHwid: 5,
|
||||
}); err == nil {
|
||||
t.Fatal("creating over an unparsable inbound returned no error")
|
||||
}
|
||||
if rec := lookupClientRecord(t, "fresh@x"); rec.LimitHwid != 0 {
|
||||
t.Fatalf("limit_hwid = %d, want 0 on a create that failed", rec.LimitHwid)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNamesFailedInbounds(t *testing.T, err error, broken []*model.Inbound, healthy *model.Inbound) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("applying %d unparsable inbounds returned no error", len(broken))
|
||||
}
|
||||
for _, ib := range broken {
|
||||
if want := fmt.Sprintf("inbound %d:", ib.Id); !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not name the failing %s", err, want)
|
||||
}
|
||||
}
|
||||
if blamed := fmt.Sprintf("inbound %d:", healthy.Id); strings.Contains(err.Error(), blamed) {
|
||||
t.Fatalf("error %q blames the healthy %s", err, blamed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanoutReportsEveryFailingInbound pins that no inbound aborts the others:
|
||||
// each failure names its own inbound, and the healthy ones still get the client.
|
||||
func TestFanoutReportsEveryFailingInbound(t *testing.T) {
|
||||
const halfBadUUID = "22222222-3333-4444-5555-666666666666"
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
startSerializedWriter(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
broken := []*model.Inbound{
|
||||
mkInbound(t, 41201, model.VLESS, `{"clients":`),
|
||||
mkInbound(t, 41202, model.VLESS, `{"clients":`),
|
||||
}
|
||||
healthy := mkInbound(t, 41203, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
_, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "halfbad@x", ID: halfBadUUID, SubID: "sub-halfbad", Enable: true},
|
||||
InboundIds: []int{broken[0].Id, broken[1].Id, healthy.Id},
|
||||
})
|
||||
assertNamesFailedInbounds(t, err, broken, healthy)
|
||||
if !settingsHoldUUID(t, inboundSvc, healthy.Id, halfBadUUID) {
|
||||
t.Fatalf("healthy inbound %d did not get the client", healthy.Id)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attach", func(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
startSerializedWriter(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
seed := mkInbound(t, 41301, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "halfbad@x", ID: halfBadUUID, SubID: "sub-halfbad", Enable: true},
|
||||
InboundIds: []int{seed.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed Create: %v", err)
|
||||
}
|
||||
|
||||
broken := []*model.Inbound{
|
||||
mkInbound(t, 41302, model.VLESS, `{"clients":`),
|
||||
mkInbound(t, 41303, model.VLESS, `{"clients":`),
|
||||
}
|
||||
healthy := mkInbound(t, 41304, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
rec := lookupClientRecord(t, "halfbad@x")
|
||||
_, err := svc.Attach(inboundSvc, rec.Id, []int{broken[0].Id, broken[1].Id, healthy.Id})
|
||||
assertNamesFailedInbounds(t, err, broken, healthy)
|
||||
if !settingsHoldUUID(t, inboundSvc, healthy.Id, halfBadUUID) {
|
||||
t.Fatalf("healthy inbound %d did not get the client", healthy.Id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -116,6 +120,8 @@ func (s *ClientService) GetClientsByTrafficReset(period string) ([]ClientResetCy
|
||||
return cycles, nil
|
||||
}
|
||||
|
||||
// Create applies the client to every requested inbound: one failing inbound no
|
||||
// longer aborts the others, so the error can name several and needRestart holds.
|
||||
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||
if payload == nil {
|
||||
return false, common.NewError("empty payload")
|
||||
@@ -194,14 +200,16 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
// Prepared before any inbound is written: fillProtocolDefaults mints the
|
||||
// shared credentials on the first inbound and every later one reuses them.
|
||||
adds := make([]*model.Inbound, 0, len(payload.InboundIds))
|
||||
for _, ibId := range payload.InboundIds {
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, getErr)
|
||||
}
|
||||
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
||||
return needRestart, err
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, err)
|
||||
}
|
||||
clientForInbound := client
|
||||
if ips, ok := client.AllowedIPsByInbound[ibId]; ok {
|
||||
@@ -217,23 +225,59 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
|
||||
}
|
||||
adds = append(adds, &model.Inbound{Id: ibId, Settings: string(settingsPayload)})
|
||||
}
|
||||
if err := s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid); err != nil {
|
||||
return needRestart, err
|
||||
needRestart, fanoutErr := s.fanoutInboundClientAdds(inboundSvc, adds)
|
||||
if fanoutErr != nil {
|
||||
// Never on a failed create: this retrims the devices of an email that
|
||||
// already existed, and a create the panel reported as failed must not.
|
||||
return needRestart, fanoutErr
|
||||
}
|
||||
return needRestart, nil
|
||||
return needRestart, s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid)
|
||||
}
|
||||
|
||||
// inboundFanoutConcurrency caps how many inbounds one create/attach applies at
|
||||
// once, so a client spanning many of them can't start an unbounded RPC burst.
|
||||
const inboundFanoutConcurrency = 4
|
||||
|
||||
// fanoutInboundClientAdds applies one payload per inbound with the node pushes
|
||||
// overlapping; unlike the sequential loop, one failure no longer stops the rest.
|
||||
func (s *ClientService) fanoutInboundClientAdds(inboundSvc *InboundService, adds []*model.Inbound) (bool, error) {
|
||||
var needRestart atomic.Bool
|
||||
errs := make([]error, len(adds))
|
||||
sem := make(chan struct{}, inboundFanoutConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i := range adds {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
// Off the request goroutine gin's Recovery no longer covers this,
|
||||
// so an unrecovered panic here would take the whole panel down.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// The apply may already have committed, so ask for the
|
||||
// restart the lost return value can no longer report.
|
||||
needRestart.Store(true)
|
||||
errs[i] = fmt.Errorf("inbound %d: panic: %v", adds[i].Id, r)
|
||||
logger.Errorf("panic adding client to inbound %d: %v\n%s", adds[i].Id, r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
nr, err := s.AddInboundClient(inboundSvc, adds[i])
|
||||
if nr {
|
||||
needRestart.Store(true)
|
||||
}
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("inbound %d: %w", adds[i].Id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return needRestart.Load(), errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
|
||||
@@ -792,6 +836,8 @@ func addressesFitAmneziaWGInbound(addrs []string, ib *model.Inbound) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Attach applies the client to every requested inbound: one failing inbound no
|
||||
// longer aborts the others, so the error can name several and needRestart holds.
|
||||
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
@@ -826,38 +872,29 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
|
||||
clientWire.AllowedIPs = nil
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
adds := make([]*model.Inbound, 0, len(inboundIds))
|
||||
for _, ibId := range inboundIds {
|
||||
if _, attached := have[ibId]; attached {
|
||||
continue
|
||||
}
|
||||
inbound, getErr := inboundSvc.GetInbound(ibId)
|
||||
if getErr != nil {
|
||||
return needRestart, getErr
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, getErr)
|
||||
}
|
||||
copyClient := *clientWire
|
||||
if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) {
|
||||
copyClient.AllowedIPs = nil
|
||||
}
|
||||
if err := s.fillProtocolDefaults(©Client, inbound); err != nil {
|
||||
return needRestart, err
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, err)
|
||||
}
|
||||
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}})
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ibId,
|
||||
Settings: string(settingsPayload),
|
||||
})
|
||||
if addErr != nil {
|
||||
return needRestart, addErr
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
|
||||
}
|
||||
adds = append(adds, &model.Inbound{Id: ibId, Settings: string(settingsPayload)})
|
||||
}
|
||||
return needRestart, nil
|
||||
return s.fanoutInboundClientAdds(inboundSvc, adds)
|
||||
}
|
||||
|
||||
func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
|
||||
|
||||
@@ -80,15 +80,38 @@ func (f *fakeNodeRuntime) ResetClientTraffic(context.Context, *model.Inbound, st
|
||||
func (f *fakeNodeRuntime) ResetInboundTraffic(context.Context, *model.Inbound) error { return nil }
|
||||
func (f *fakeNodeRuntime) ResetAllTraffics(context.Context) error { return nil }
|
||||
|
||||
// setupNodeRuntime wires an online node + a fake runtime override and returns the
|
||||
// node id and the fake so a test can drive the service node-dispatch path without
|
||||
// a network node.
|
||||
func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
|
||||
// startSerializedWriter runs the single traffic-writer goroutine for the test, so
|
||||
// concurrent service writes take the serialized path production uses.
|
||||
func startSerializedWriter(t *testing.T) {
|
||||
t.Helper()
|
||||
resetTrafficWriterForTest(t)
|
||||
StartTrafficWriter()
|
||||
}
|
||||
|
||||
// useTestRuntimeManager swaps in a fresh runtime.Manager for the test and puts
|
||||
// the previous one back afterwards, so overrides can't leak between tests.
|
||||
func useTestRuntimeManager(t *testing.T) *runtime.Manager {
|
||||
t.Helper()
|
||||
prev := runtime.GetManager()
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(prev) })
|
||||
return mgr
|
||||
}
|
||||
|
||||
// panicNodeRuntime panics on the per-client push, standing in for a bug in the
|
||||
// apply path that would otherwise unwind straight out of a fanout goroutine.
|
||||
type panicNodeRuntime struct{ fakeNodeRuntime }
|
||||
|
||||
func (p *panicNodeRuntime) AddClient(context.Context, *model.Inbound, model.Client) error {
|
||||
panic("boom from node runtime")
|
||||
}
|
||||
|
||||
// setupNodeRuntime wires an online node + a fake runtime override so a test can
|
||||
// drive the service node-dispatch path without a network node.
|
||||
func setupNodeRuntime(t *testing.T) (int, *fakeNodeRuntime) {
|
||||
t.Helper()
|
||||
mgr := useTestRuntimeManager(t)
|
||||
|
||||
node := &model.Node{Name: "n1-" + t.Name(), Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := database.GetDB().Create(node).Error; err != nil {
|
||||
|
||||
@@ -1034,6 +1034,28 @@ update_x-ui() {
|
||||
rm ${xui_folder}-linux-$(arch).tar.gz -f > /dev/null 2>&1
|
||||
_fail "ERROR: Downloaded x-ui release archive is empty, please be sure that your server can access GitHub"
|
||||
fi
|
||||
# Releases publish <asset>.sha256 next to each archive. A mismatch or a
|
||||
# failed sidecar download aborts the update; only a 404 (releases
|
||||
# predating the sidecar) is tolerated with a warning.
|
||||
archive="${xui_folder}-linux-$(arch).tar.gz"
|
||||
rm -f "${archive}.sha256"
|
||||
sidecar_code=$(${curl_bin} -sL --retry 3 --retry-delay 3 --connect-timeout 15 --max-time 60 -o "${archive}.sha256" -w '%{http_code}' "https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz.sha256" 2> /dev/null)
|
||||
if [[ "${sidecar_code}" == "200" ]]; then
|
||||
expected_sha256=$(awk 'NR == 1 {print $1}' "${archive}.sha256")
|
||||
actual_sha256=$(sha256sum "${archive}" | awk '{print $1}')
|
||||
rm -f "${archive}.sha256"
|
||||
if [[ ! "${expected_sha256}" =~ ^[0-9a-f]{64}$ || "${expected_sha256}" != "${actual_sha256}" ]]; then
|
||||
rm -f "${archive}"
|
||||
_fail "ERROR: Checksum mismatch for $(basename "${archive}"): expected ${expected_sha256:-<none>}, got ${actual_sha256}"
|
||||
fi
|
||||
echo -e "${green}Checksum verified: ${actual_sha256}${plain}"
|
||||
elif [[ "${sidecar_code}" == "404" ]]; then
|
||||
rm -f "${archive}.sha256"
|
||||
echo -e "${yellow}No checksum published for this release, skipping verification${plain}"
|
||||
else
|
||||
rm -f "${archive}.sha256" "${archive}"
|
||||
_fail "ERROR: Failed to download the checksum for x-ui-linux-$(arch).tar.gz (HTTP ${sidecar_code})"
|
||||
fi
|
||||
|
||||
if [[ -e ${xui_folder}/ ]]; then
|
||||
echo -e "${green}Stopping x-ui...${plain}"
|
||||
|
||||
@@ -2505,9 +2505,14 @@ create_iplimit_jails() {
|
||||
# Uncomment 'allowipv6 = auto' in fail2ban.conf
|
||||
sed -i 's/#allowipv6 = auto/allowipv6 = auto/g' /etc/fail2ban/fail2ban.conf
|
||||
|
||||
# On Debian 12+ and Ubuntu 22.04+ fail2ban's default backend should be changed to systemd
|
||||
if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]]; then
|
||||
sed -i '0,/action =/s/backend = auto/backend = systemd/' /etc/fail2ban/jail.conf
|
||||
# Debian 12+ / Ubuntu 22.04+ log sshd to the journal only; a jail.d override
|
||||
# survives package upgrades. Only the stock 'backend = auto' is overridden.
|
||||
if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]] &&
|
||||
sed -n '0,/action =/p' /etc/fail2ban/jail.conf | grep -q '^backend = auto'; then
|
||||
cat << EOF > /etc/fail2ban/jail.d/3x-ipl-backend.conf
|
||||
[DEFAULT]
|
||||
backend = systemd
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat << EOF > /etc/fail2ban/jail.d/3x-ipl.conf
|
||||
|
||||
Reference in New Issue
Block a user