Compare commits

...

8 Commits

Author SHA1 Message Date
Sanaei 63b46cd612 perf(clients): apply a multi-inbound client create concurrently
Creating or attaching a client across N inbounds called AddInboundClient
once per inbound, strictly one after another. When those inbounds live on
different nodes each call is a full node round-trip bounded by the 10s
remote timeout, so the request cost the SUM of every node's latency: two
nodes felt instant, three took ~13s and timed out bot callers, which is
how it surfaced as "two out of four account creations fail".

Split the per-inbound preparation from the apply. Preparation stays
ordered and single-threaded because fillProtocolDefaults mints the shared
credentials on the first inbound and every later one reuses them; the
applies then run concurrently, capped at inboundFanoutConcurrency. A
4-node create measured 1.205s -> 0.307s with peak overlap 1 -> 4.

Consequences of no longer aborting at the first failing inbound:

- Every apply error is tagged with its inbound and the failures are
  joined, so all of them reach the caller instead of just the first.
- The fanout goroutines recover their own panics. Off the request
  goroutine gin's Recovery no longer covers them, and an unrecovered
  panic would kill the panel rather than fail one inbound.
- A partly-applied call commits clients on the inbounds that succeeded,
  so the controller and the LDAP job now read needRestart before the
  error check; otherwise Xray was never flagged for the work that landed.
- limitHwid is applied only when every inbound succeeded. Applying it
  after a failure rewrites limit_hwid and trims the registered devices of
  an email that already existed, which is silent data loss on an
  operation the panel reported as failed.

Update the API docs for the new partial-application contract and the
inbound-tagged error strings.
2026-09-04 01:01:20 +02:00
duqigit 2ddcf53020 Feature/fix external subscription client expiry (#6333)
* fix(sub): honor client expiry for external links

* fix(ui): show client expiry on external links

* fix(sub): address external expiry review
2026-09-03 22:32:05 +02:00
Sanaei 13e87a18c8 chore(ci): give the race job a 25m test timeout
The race job failed with "panic: test timed out after 10m0s" in
internal/web/service (FAIL at 600.106s) while every other package passed
and the non-race go-test job ran the same package in 57s.

Nothing hung. The race detector costs this repo ~8.5-10x (internal/database
8.4s -> 73s, internal/sub 17.8s -> 149s), and internal/web/service has 671
tests, ~40 of which each pay a full InitDB + AutoMigrate. That puts it right
on go test's 10-minute default per-package timeout: the last four race jobs
finished in 10m10s-10m28s before this one crossed the line.

Pass -timeout 25m in ci.yml and `make race` so the largest package has real
headroom while a genuine deadlock is still bounded. Verified locally:
ok internal/web/service 265.425s, 658 tests, no data races.
2026-09-03 21:57:38 +02:00
kuzzrus bd1c27b03d fix(amneziawg): H1-H4 generator + queue-depth throughput fixes (#6330)
* fix(amneziawg): stop H1-H4 generator misclassifying transport packets

Both the Go generator and its frontend mirror picked a random *range*
per H1-H4 field with only a minimum width enforced (no maximum).
amneziawg-go's packet classifier only ever compares a fixed-size
ciphertext prefix against these bounds, so a wide range buys no DPI
resistance -- the boundaries themselves are never observable on the
wire. It does cost real throughput: with randomTrailers on (the
default here), the handshake-size checks relax from == to >, so a
wide H-range misclassifies a proportional fraction of ordinary
transport packets as handshakes and silently drops them
(amnezia-vpn/amneziawg-go#183). A single value per field is strictly
safer than any range, with no obfuscation trade-off.

Live-tested: narrowing H1-H4 alone took AmneziaWG upload from
2-3 Mbit/s to 200+ Mbit/s on one box, and ~20 Mbit/s to 120-156 Mbit/s
on another, single-variable, no other change.

* fix(amneziawgnet): raise tunQueueDepth to absorb slow-start bursts

1024 was sized for a single-connection buffering problem (the
gVisor-to-amneziawg-go TUN handoff channel needing slack for the
download direction). tcpip.Stack.Stats() during a real many-connection
download (20-28 concurrent TCP flows, e.g. a segmented speed test)
showed SlowStartRetransmits jump by ~770 in a single second the moment
CurrentEstablished crossed ~20 -- consistent with many connections'
simultaneous slow-start growth briefly exceeding 1024 outstanding
packets and gVisor treating the resulting silent drops as real network
loss.

* fix(amneziawg): trim comment blocks to the repo's 2-line cap

Review feedback: four comment blocks in the previous commits exceeded
CLAUDE.md's 2-line-per-block hard rule (up to 13 lines). Trimmed each to
the one non-obvious fact plus the amneziawg-go#183 reference; the fuller
rationale already lives in the commit message. Also refreshed the stale
H1-H4 range example in docs/content/docs/en/config/amneziawg.mdx to match
the new single-value generator output.
2026-09-03 21:50:23 +02:00
ilyusha 0ff3c23948 fix(api-docs): generate request bodies for all encodings (#6296)
* fix(api-docs): generate request bodies for all encodings

The OpenAPI generator only recognized generic body parameters, so JSON, form, and multipart declarations disappeared into empty application/json objects. Generate the declared media type and schema, preserve optionality and conditional requirements, and encode repeated form arrays the way Gin expects. Correct the request metadata exposed by the complete schemas and keep the panel and docs specifications synchronized.

* fix(api-docs): align alternative request schemas

Keep non-empty constraints on the selected request-body alternative without rejecting empty values for the alternatives that panel requests also include. Allow null client IP lists because model serialization emits them while cleared rows await pruning.

* fix(api-docs): send object urlencoded fields as JSON, document the inbound update body

Four defects the request-body rework exposed or left behind:

- An object-typed field in an x-www-form-urlencoded body got no encoding
  entry, so OpenAPI 3.0 serialized it form-style. Swagger "Try it out"
  and generated clients sent memberWeights=3&memberWeights=0.2 to
  /panel/api/sub-balancers, and parseSubBalancerForm json.Unmarshals the
  raw field, so every such call failed with "invalid memberWeights".
  Emit encoding.<name>.contentType = application/json instead.
- bodyRequiredOneOf names were never checked against the declared body
  params: a typo emitted an anyOf branch requiring a property that does
  not exist — unsatisfiable — and make gen still passed. Throw now, and
  extend the requestSchema guard to reject bodyRequiredOneOf as well.
- /panel/api/inbounds/update/:id advertised no request body although its
  own summary says the shape mirrors /add and updateInbound binds one.
  Both entries now share an inboundBody const so they cannot drift.
- The mixed-locations error was the only buildOperation throw without
  the method and path, aborting make gen without naming the offender.

Regenerated frontend/public/openapi.json and copied it to
docs/public/openapi.json. No MDX regeneration: no summary changed.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-03 21:20:38 +02:00
ilyusha f294e1806d feat(release): publish SHA-256 sums and verify them in install.sh/update.sh (#6393)
* feat(release): publish SHA-256 sums and verify them in install.sh/update.sh

The installer and updater fetched the release archive and extracted it
after checking only that the file is not empty, and the release workflow
published no checksums. TLS protects the transport, not the bytes: a
truncated or swapped asset, a bad mirror or a TLS-terminating proxy was
installed as root. #5396 added this verification for the Xray archive;
the panel's own archive was the remaining unverified download.

Publish <asset>.sha256 next to every release archive (Linux and Windows)
and verify it before extracting. A mismatch aborts the install; a missing
sidecar, which every release before this change has, only warns, so
installing older tags keeps working.

Assisted-by: Claude Code:claude-fable-5-1

* fix(install): fail closed when the checksum sidecar cannot be fetched

Review follow-up. Any curl failure on the sidecar (5xx, reset, DNS) was
treated as "no checksum published", so whoever can swap the archive
could also drop the 90-byte sidecar request and skip the check. Only a
404, which every release before the sidecar existed returns, is still
tolerated with a warning; every other outcome aborts and removes the
downloaded archive.

Assisted-by: Claude Code:claude-fable-5-1

* fix(install): restore the closing brace lost in the main merge
2026-09-03 20:44:00 +02:00
ilyusha 4019f47de2 fix(x-ui.sh): put the fail2ban backend override in jail.d, not jail.conf (#6392)
* fix(x-ui.sh): put the fail2ban backend override in jail.d, not jail.conf

create_iplimit_jails switched the global fail2ban backend to systemd on
Debian 12+ and Ubuntu 22.04+ with sed on /etc/fail2ban/jail.conf. That
file is the package's conffile: the next fail2ban upgrade either drops
the edit or keeps a stale jail.conf, depending on the conffile prompt.

Write the same override to /etc/fail2ban/jail.d/3x-ipl-backend.conf,
which fail2ban reads after jail.conf and which upgrades leave alone, and
remove it together with the other 3x-ipl files on uninstall. The 3x-ipl
jail itself keeps its explicit backend=auto.

Assisted-by: Claude Code:claude-fable-5-1

* fix(x-ui.sh): only override the stock fail2ban backend, keep it on partial removal

Review follow-ups. The old sed only rewrote a literal 'backend = auto'
in jail.conf's [DEFAULT], so an operator's own backend survived it; the
override file was written unconditionally. Write it only when jail.conf
still carries the stock value. And keep the file when only the IP-limit
jail is removed: the sed was never reverted either, and deleting a
[DEFAULT] override there would flip every inheriting jail back to auto
on the restart in the same branch. The full /etc/fail2ban removal path
still deletes it.

Assisted-by: Claude Code:claude-fable-5-1
2026-09-03 20:42:14 +02:00
Sanaei 8411b1dd9e chore: upgrade Vitest to v5
Update frontend dev tooling to Vitest 5 by bumping `vitest`, `@vitest/browser-playwright`, and `@vitest/coverage-v8`, plus `@types/react-dom`. Add an override for `@storybook/addon-vitest` to pin Vitest-related packages to compatible versions and avoid dependency mismatch issues. Also bump the Go toolchain patch version from `1.27.0` to `1.27.1` in `go.mod`.
2026-09-03 20:37:10 +02:00
41 changed files with 3467 additions and 712 deletions
+2 -1
View File
@@ -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.
+16 -8
View File
@@ -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
+2 -1
View File
@@ -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)
+4 -4
View File
@@ -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
+35 -16
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+152 -248
View File
@@ -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",
+9 -4
View File
@@ -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": {
File diff suppressed because it is too large Load Diff
+109 -27
View File
@@ -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%' }}
/>
);
+10
View File
@@ -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
+274 -110
View File
@@ -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',
},
{
+33 -22
View File
@@ -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);
});
});
+53 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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.
+5 -11
View File
@@ -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
}
+10 -15
View File
@@ -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
}
}
}
+2 -1
View File
@@ -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.
+13 -4
View File
@@ -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
}
+4 -4
View File
@@ -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)
+6 -5
View File
@@ -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
+115
View File
@@ -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())
}
})
}
}
+11 -2
View File
@@ -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
}
+26 -6
View File
@@ -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)
}
}
+7
View File
@@ -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)
+18 -8
View File
@@ -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) {
+8 -6
View File
@@ -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) {
+39
View File
@@ -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)
}
})
}
+69 -32
View File
@@ -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(&copyClient, 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 {
+22
View File
@@ -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}"
+8 -3
View File
@@ -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