feat(sub): client-side balancers for the JSON subscription (#6243)

* feat(sub): add SubBalancer model and migration

Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up.

* feat(sub): add SubBalancer service

List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart.

* feat(sub): add SubBalancer API controller and routes

GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it.

* feat(sub): emit client-side balancers in JSON subscription

For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present.

* test(sub): cover SubBalancer service and JSON output

Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup.

* feat(sub): add sub-balancers i18n keys

pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales.

* feat(sub): add SubBalancer schema and API queries

Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key.

* feat(sub): add subscription balancers settings tab

SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled.

* test(sub): add SubBalancer form modal test

Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled).

* feat(sub): register sub-balancers in API docs and OpenAPI

Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it.

* docs: sync openapi.json with frontend

docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API.

* docs: add subscription balancers API reference

Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations.

* feat(sub): accept roundRobin balancer strategy

Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist.

* feat(sub): add roundRobin strategy label

pages.settings.subBalancers.strategyRoundRobin added to all 13 locales.

* feat(sub): expose roundRobin in balancer form

Zod strategy enum, form modal label key, and table strategy colour for roundRobin.

* docs(sub): list roundRobin in strategy description

The create/update strategy param description now mentions roundRobin alongside the other three.

* feat(sub): add subJsonObservatory setting

Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask.

* feat(sub): wire observatory config through sub controller

WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction.

* feat(sub): emit observatory conditionally with configurable probes

burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override.

* feat(sub): add subJsonObservatory to AllSetting model

Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string.

* feat(sub): add balancer observatory config card

New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults.

* fix(sub): hide save/restart header on sub-balancers tab

Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly.

* feat(sub): add observatory config i18n keys

pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales.

* feat(sub): regenerate openapi for subJsonObservatory

openapigen picks up the new AllSetting field; openapi.json synced into docs.

* feat(sub): add observatory tab to sub-balancers

Mirrors the Xray Balancers page: two tabs (Balancers + Observatory).
Wires allSetting/updateSetting into the tab and adds tabBalancers /
tabObservatory labels to all locales. The page Save header is shown
again on this tab so the observatory config can be saved.

* refactor(sub): drop observatory tab from sub-formats

Now that the observatory config lives under sub-balancers, remove the
duplicate tab plus its state and defaults from sub-formats.

* fix(sub): add missing inboundsCount i18n key

The sub-balancers table rendered the raw key path in the Inbounds
column because pages.settings.subBalancers.inboundsCount was not
defined. Added it to all 13 locales.

* test(sub): pin disabled-inbound exclusion from balancer

The balancer builds its members from the subscriber's already-filtered
entry set, so an inbound disabled for that user can never surface as a
member. Adds tests for both shapes (one of several disabled, and the
only selected one disabled).

* fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback

Three coupled defects on the balancer observatory surface, flagged in PR review:

- The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this.

- Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value.

- routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking.

Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it.

* fix(sub-balancer): localize controller toasts and reject malformed ids

Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success.

* fix(sub-balancer): enforce remark length cap server-side

The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap.

* fix(sub-balancer): exclude mtproto from balancer member picker

SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment.

* docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope

- Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only).

- Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX.

- Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror.

* style(model): trim SubBalancer comment to 2-line cap

CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3.

* fix(sub-balancer): parse enabled explicitly and preserve it on partial update

parseSubBalancerForm treated any non-"false" value as true (so "bogus"
silently enabled) and always overwrote Enabled on update, so a PATCH that
omitted the toggle reset a disabled balancer back to enabled. Parse the
field with strconv.ParseBool and return *bool: absent means "no change"
on update and "true" on create; a malformed value is rejected as 400.
Update keeps the stored Enabled when the pointer is nil.

* fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds

DelInbound cascaded hosts but left the deleted inbound id in every
sub_balancers.InboundIds, so the balancer kept emitting a member no
subscriber could resolve — a dangling outbound tag with no proxy behind it.
Strip the id inside the existing delete transaction (same shape as the hosts
cascade, #5648); with the last member gone the balancer stops emitting.

* fix(sub-balancer): return not-found when deleting a missing balancer

Delete returned the gorm result error only, which is nil when no row matched,
so the controller reported success:true for an id that never existed — a stale
UI row looked like a clean delete. Check RowsAffected and return a not-found
error on 0 so the toast reflects reality.

* style(sub): shorten leastPing/leastLoad observatory comments

The observatory-emission guard comment and its test comment ran a few
lines long; trim them to a couple of lines each without dropping the
invariant that leastPing/leastLoad require a burst observatory.

* fix(sub): validate observatory setting instead of silently dropping it

SetObservatoryConfig applied whatever survived json.Unmarshal with no checks,
so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even
unparseable JSON was either silently applied or silently ignored. Validate
each field: parse durations with time.ParseDuration, require http(s) URLs for
destination/connectivity, and log a warning naming the field and the bad value
on every fallback — including the unmarshal error, which was a quiet return.
Bad values now keep the built-in defaults instead of leaking into the emitted
burstObservatory.

* fix(sub): deduplicate burst-observatory defaults across Go and frontend

The burst-observatory ping defaults lived in three places that had drifted:
Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod
PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity
pre-check URL). Align them to one set: https probe destination, sampling 2,
and empty connectivity (skip the direct pre-check). The settings tab now
parses the stored JSON through PingConfigSchema and seeds its default from
DEFAULT_BURST_OBSERVATORY instead of carrying its own literal.

* refactor(sub): extract proxy outbounds once before the balancer loop

buildBalancerConfig unmarshalled every inbound document and re-extracted its
first outbound on each balancer, so with B balancers and N inbound docs the
same document was parsed B*N times. Pull each doc's proxy outbound in a single
pre-pass over the entries and cache it per entry; buildBalancerConfig now
clones the cached map before retagging, so one parse serves every balancer.
Output is byte-for-byte unchanged.

* fix(sub): form balancer member tags from the inbound protocol, not tcp→vless

balancerTransport derived the bal-N tag suffix from the outbound's transport
network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was
mislabelled "vless" in every client config — the tag lied about the proxy
type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless,
bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and
dedup suffix are unchanged. Update the existing tag assertions and add a vmess
case that fails under the old mapping.

* fix(sub-balancer): default strategy to random in the create form

The create-balancer form seeded strategy to 'leastLoad', but the service
validate() defaults an empty strategy to 'random' and the API docs say the
default is 'random' — so a freshly opened form showed leastLoad while saving
without touching the field silently stored random. Align the form default to
'random' so what the admin sees is what gets persisted.

* feat(api-docs): document the SubBalancer response schema

The five sub-balancer endpoints carried no responseSchema, so the API docs
page rendered them without a typed example. Add example: tags to every
SubBalancer field, allow the struct through openapigen, and point the list
(responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate
the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/.

* style(sub-balancer): drop whitespace-only separator lines, add final newline

subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as
separators between statements and had no trailing newline. Replace them with
clean empty blank lines and end each file with a newline.

* fix(i18n): translate sub-balancer toasts and observatory note

The sub-balancer toast messages (list/create/update/delete/invalidId) and
the observatory note were left in English across 11 non-English locales
(ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key
in the subBalancers block was already translated. Translate them to match the
meaning and terminology of the surrounding keys in each file; the JSON
structure and keys are unchanged.

* fix(sub-balancer): hide disabled inbounds from the member picker

The picker offered every protocol-eligible inbound regardless of its enable
flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled
member is therefore dropped from every subscriber's entries, and when it was
the balancer's only member the balancer document silently stops being emitted
— with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll
MembersDisabled already documents that backend behavior.

Filter the way the sibling client picker has since #5645: hide disabled
inbounds, but keep one that is already selected so editing an existing
balancer cannot silently drop a member.

Drop the `?? []` on the useWatch result so the new useMemo dependency stays
referentially stable.

* style(sub): trim the balancerMemberSuffix comment to the 2-line cap

Comment blocks in committed Go are capped at 2 lines; the name already carries
what the function picks, so keep only the why.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
DIMFLIX
2026-08-23 23:34:20 +03:00
committed by GitHub
parent 81fcacab11
commit da01b7637d
65 changed files with 6587 additions and 792 deletions
+2 -3
View File
@@ -125,7 +125,7 @@ file locations when it can answer in one hop.
## Frontend conventions (summary; full version in frontend/CLAUDE.md) ## Frontend conventions (summary; full version in frontend/CLAUDE.md)
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites. - Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
- TS strict; oxlint's `typescript/no-explicit-any` is an error. Zod schemas in - TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
`src/schemas/` are the source of truth; infer types with `z.infer`, never `src/schemas/` are the source of truth; infer types with `z.infer`, never
hand-write. Do not edit `src/generated/`. hand-write. Do not edit `src/generated/`.
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type - Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
@@ -147,8 +147,7 @@ reads as a broken repo, not a missing step. Run `make dist-stub` once; every
`make` Go target already depends on it, which is why `make test-go` beats `make` Go target already depends on it, which is why `make test-go` beats
`go test ./...`. Run `make help` for all targets. The local gate: `go test ./...`. Run `make help` for all targets. The local gate:
make verify # gen-check + lint + format-check + typecheck + test + build make verify # gen-check + lint + typecheck + test + build + build-storybook
# + build-storybook
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`, That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a `make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
@@ -1,12 +1,8 @@
--- ---
title: API Tokens title: API Tokens
description: 'Manage Bearer tokens used for programmatic auth (bots, central description: Manage scoped Bearer tokens for programmatic auth. Tokens grant
panels acting on this node, CI). Each token has a unique name and an enabled admin, monitor, or node-sync access, may expire, and are stored as SHA-256
flag — disable to revoke without deleting, delete to revoke permanently. hashes. The plaintext is returned only once at creation.
Tokens are stored as SHA-256 hashes and the plaintext is returned only once,
in the create response — it cannot be retrieved afterwards, so copy it then.
Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any
/panel/api/* request — the token is a full-admin credential.'
full: true full: true
_openapi: _openapi:
preload: preload:
+90 -57
View File
@@ -14,18 +14,24 @@ _openapi:
JSON-encoded-string form is still accepted on write). JSON-encoded-string form is still accepted on write).
url: '#list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write' url: '#list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write'
- depth: 2 - depth: 2
title: Filter, sort, and paginate clients on the server. Each item is a slim row title: 'Filter, sort, and paginate clients on the server. Each item is a slim
(no uuid/password/auth/flow/security/reverse/tgId) so the clients page row (no uuid/password/auth/flow/security/reverse/tgId) so the clients
can ship 25-ish rows in a few KB instead of the full table. The response page can ship 25-ish rows in a few KB instead of the full table. The
also includes a summary computed across the full DB row set so dashboard response also includes a summary computed across the full DB row set so
counters stay stable as the user paginates or filters. Page size capped dashboard counters stay stable as the user paginates or filters: the
at 200; fetch /get/:email to obtain the full per-client payload for an *Count fields are exact, while the email arrays beside them stop at 200
edit/info modal. entries so the payload does not grow with the panel. Page size capped at
url: '#filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal' 200; fetch /get/:email to obtain the full per-client payload for an
edit/info modal.'
url: '#filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-the-count-fields-are-exact-while-the-email-arrays-beside-them-stop-at-200-entries-so-the-payload-does-not-grow-with-the-panel-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal'
- depth: 2 - depth: 2
title: Fetch one client by email, including the inbound IDs and external config title: Fetch one client by email, including the inbound IDs and external config
IDs it is attached to. IDs it is attached to.
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to' url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
- depth: 2
title: Fetch clients by Telegram user ID. Returns an array since multiple
clients can share the same Telegram ID.
url: '#fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id'
- depth: 2 - depth: 2
title: Create a new client and attach it to one or more inbounds in a single title: 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 call. Body is JSON. Per-protocol secrets are generated server-side when
@@ -48,10 +54,10 @@ _openapi:
title: Detach a client from one or more inbounds without deleting the client. title: Detach a client from one or more inbounds without deleting the client.
url: '#detach-a-client-from-one-or-more-inbounds-without-deleting-the-client' url: '#detach-a-client-from-one-or-more-inbounds-without-deleting-the-client'
- depth: 2 - depth: 2
title: Replace a client's external links (per-client share links and remote title: Replace a client's external links and external subscriptions. Sends the
subscription URLs surfaced in their subscription). Sends the full set; full set; the server replaces all rows. Disabled rows stay saved for
the server replaces all rows. editing but are not emitted in generated subscriptions.
url: '#replace-a-clients-external-links-per-client-share-links-and-remote-subscription-urls-surfaced-in-their-subscription-sends-the-full-set-the-server-replaces-all-rows' 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'
- depth: 2 - depth: 2
title: Reset the up/down counters for every client globally. Quotas and expiry title: Reset the up/down counters for every client globally. Quotas and expiry
are not affected. Triggers an Xray restart if any counter actually are not affected. Triggers an Xray restart if any counter actually
@@ -64,10 +70,10 @@ _openapi:
url: '#delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound' url: '#delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound'
- depth: 2 - depth: 2
title: Delete every client that is not attached to any inbound, along with its title: Delete every client that is not attached to any inbound, along with its
traffic record, IP log, and external links. Useful for clearing clients traffic record, IP log, HWID devices, and external links. Useful for
left unattached after their inbounds were removed. Returns the deleted clearing clients left unattached after their inbounds were removed.
count. Cannot be undone. Returns the deleted count. Cannot be undone.
url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone' url: '#delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone'
- depth: 2 - depth: 2
title: Return every client as a {client, inboundIds} array — the same shape title: Return every client as a {client, inboundIds} array — the same shape
/bulkCreate and /import accept — so the payload round-trips straight /bulkCreate and /import accept — so the payload round-trips straight
@@ -88,12 +94,16 @@ _openapi:
title: 'Shift expiry and/or traffic quota for many clients in one call. title: 'Shift expiry and/or traffic quota for many clients in one call.
addDays/addBytes may be negative. Clients with unlimited expiry addDays/addBytes may be negative. Clients with unlimited expiry
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
corresponding field — bulk extend never converts unlimited to limited. corresponding field — bulk extend never converts unlimited to limited. A
The optional flow directive sets the XTLS flow on every client: "none" client that was auto-disabled solely because it was depleted (expired or
clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the over quota) is automatically re-enabled — locally and on its node — when
inbound supports it (omit or "" to leave it unchanged). Returns the the adjustment lifts it out of depletion; a manually-disabled or
adjusted count and per-email skip reasons.' still-depleted client is left disabled. The optional flow directive sets
url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons' the XTLS flow on every client: "none" clears it,
"xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound
supports it (omit or "" to leave it unchanged). Returns the adjusted
count and per-email skip reasons.'
url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons'
- depth: 2 - depth: 2
title: Enable many clients in one call. Emails are grouped by inbound and title: Enable many clients in one call. Emails are grouped by inbound and
applied with a single read-modify-write per inbound; the running Xray applied with a single read-modify-write per inbound; the running Xray
@@ -188,6 +198,13 @@ _openapi:
after filtering by group for that. Returns the count of clients whose after filtering by group for that. Returns the count of clients whose
label was cleared. label was cleared.
url: '#remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared' url: '#remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared'
- depth: 2
title: Reset only the group-level traffic counter shown on the groups page.
Snapshots the current up/down sum of the group's members as a baseline
so the group total reads zero, while leaving each client's own counters
(and their quotas) untouched. No Xray restart is triggered. Creates the
client_groups row if the group exists only as a derived label.
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
- depth: 2 - depth: 2
title: Zero out a single clients up/down counters. Re-enables the client across title: Zero out a single clients up/down counters. Re-enables the client across
every attached inbound and pushes the change to Xray (or the remote every attached inbound and pushes the change to Xray (or the remote
@@ -204,6 +221,17 @@ _openapi:
- depth: 2 - depth: 2
title: Reset the recorded IP list for a client. title: Reset the recorded IP list for a client.
url: '#reset-the-recorded-ip-list-for-a-client' url: '#reset-the-recorded-ip-list-for-a-client'
- depth: 2
title: List registered HWID devices for a client. Hashes are not exposed.
url: '#list-registered-hwid-devices-for-a-client-hashes-are-not-exposed'
- depth: 2
title: Clear all registered HWID devices for a client so new devices can
register again.
url: '#clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again'
- depth: 2
title: Remove a single registered HWID device by its id, freeing one slot under
the HWID limit.
url: '#remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit'
- depth: 2 - depth: 2
title: List the emails of currently connected clients (last seen within the title: List the emails of currently connected clients (last seen within the
heartbeat window), deduped across every node. heartbeat window), deduped across every node.
@@ -248,34 +276,28 @@ _openapi:
Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
tunnel) contribute nothing.' tunnel) contribute nothing.'
url: '#return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing' url: '#return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
- depth: 2
title: List registered HWID devices for a client. Hashes are not exposed.
url: '#list-registered-hwid-devices-for-a-client-hashes-are-not-exposed'
- depth: 2
title: Clear all registered HWID devices for a client so new devices can
register again.
url: '#clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again'
- depth: 2
title: Remove a single registered HWID device by its id, freeing one slot under
the HWID limit.
url: '#remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit'
structuredData: structuredData:
headings: headings:
- content: List every client with its attached inbound IDs and traffic record. The - content: List every client with its attached inbound IDs and traffic record. The
reverse field, if set, is returned as a nested JSON object (legacy reverse field, if set, is returned as a nested JSON object (legacy
JSON-encoded-string form is still accepted on write). JSON-encoded-string form is still accepted on write).
id: list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write id: list-every-client-with-its-attached-inbound-ids-and-traffic-record-the-reverse-field-if-set-is-returned-as-a-nested-json-object-legacy-json-encoded-string-form-is-still-accepted-on-write
- content: Filter, sort, and paginate clients on the server. Each item is a slim - content: 'Filter, sort, and paginate clients on the server. Each item is a slim
row (no uuid/password/auth/flow/security/reverse/tgId) so the clients row (no uuid/password/auth/flow/security/reverse/tgId) so the clients
page can ship 25-ish rows in a few KB instead of the full table. The page can ship 25-ish rows in a few KB instead of the full table. The
response also includes a summary computed across the full DB row set response also includes a summary computed across the full DB row set
so dashboard counters stay stable as the user paginates or filters. so dashboard counters stay stable as the user paginates or filters:
Page size capped at 200; fetch /get/:email to obtain the full the *Count fields are exact, while the email arrays beside them stop
per-client payload for an edit/info modal. at 200 entries so the payload does not grow with the panel. Page size
id: filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal capped at 200; fetch /get/:email to obtain the full per-client payload
for an edit/info modal.'
id: filter-sort-and-paginate-clients-on-the-server-each-item-is-a-slim-row-no-uuidpasswordauthflowsecurityreversetgid-so-the-clients-page-can-ship-25-ish-rows-in-a-few-kb-instead-of-the-full-table-the-response-also-includes-a-summary-computed-across-the-full-db-row-set-so-dashboard-counters-stay-stable-as-the-user-paginates-or-filters-the-count-fields-are-exact-while-the-email-arrays-beside-them-stop-at-200-entries-so-the-payload-does-not-grow-with-the-panel-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal
- content: Fetch one client by email, including the inbound IDs and external - content: Fetch one client by email, including the inbound IDs and external
config IDs it is attached to. config IDs it is attached to.
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
- content: Fetch clients by Telegram user ID. Returns an array since multiple
clients can share the same Telegram ID.
id: fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id
- content: Create a new client and attach it to one or more inbounds in a single - content: 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 call. Body is JSON. Per-protocol secrets are generated server-side
when omitted, so callers can send only the universal fields. when omitted, so callers can send only the universal fields.
@@ -293,10 +315,10 @@ _openapi:
id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
- content: Detach a client from one or more inbounds without deleting the client. - content: Detach a client from one or more inbounds without deleting the client.
id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client id: detach-a-client-from-one-or-more-inbounds-without-deleting-the-client
- content: Replace a client's external links (per-client share links and remote - content: Replace a client's external links and external subscriptions. Sends the
subscription URLs surfaced in their subscription). Sends the full set; full set; the server replaces all rows. Disabled rows stay saved for
the server replaces all rows. editing but are not emitted in generated subscriptions.
id: replace-a-clients-external-links-per-client-share-links-and-remote-subscription-urls-surfaced-in-their-subscription-sends-the-full-set-the-server-replaces-all-rows 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
- content: Reset the up/down counters for every client globally. Quotas and expiry - content: Reset the up/down counters for every client globally. Quotas and expiry
are not affected. Triggers an Xray restart if any counter actually are not affected. Triggers an Xray restart if any counter actually
moved. moved.
@@ -307,10 +329,10 @@ _openapi:
running inbound. running inbound.
id: delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound id: delete-every-client-whose-traffic-quota-is-exhausted-used--total-when-reset-is-disabled-or-whose-expiry-has-passed-returns-the-deleted-count-and-triggers-an-xray-restart-when-any-client-was-on-a-running-inbound
- content: Delete every client that is not attached to any inbound, along with its - content: Delete every client that is not attached to any inbound, along with its
traffic record, IP log, and external links. Useful for clearing traffic record, IP log, HWID devices, and external links. Useful for
clients left unattached after their inbounds were removed. Returns the clearing clients left unattached after their inbounds were removed.
deleted count. Cannot be undone. Returns the deleted count. Cannot be undone.
id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone id: delete-every-client-that-is-not-attached-to-any-inbound-along-with-its-traffic-record-ip-log-hwid-devices-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone
- content: Return every client as a {client, inboundIds} array — the same shape - content: Return every client as a {client, inboundIds} array — the same shape
/bulkCreate and /import accept — so the payload round-trips straight /bulkCreate and /import accept — so the payload round-trips straight
back through /import. Clients with no inbound attachment are included back through /import. Clients with no inbound attachment are included
@@ -329,11 +351,15 @@ _openapi:
addDays/addBytes may be negative. Clients with unlimited expiry addDays/addBytes may be negative. Clients with unlimited expiry
(expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the
corresponding field — bulk extend never converts unlimited to limited. corresponding field — bulk extend never converts unlimited to limited.
The optional flow directive sets the XTLS flow on every client: "none" A client that was auto-disabled solely because it was depleted
(expired or over quota) is automatically re-enabled — locally and on
its node — when the adjustment lifts it out of depletion; a
manually-disabled or still-depleted client is left disabled. The
optional flow directive sets the XTLS flow on every client: "none"
clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where
the inbound supports it (omit or "" to leave it unchanged). Returns the inbound supports it (omit or "" to leave it unchanged). Returns
the adjusted count and per-email skip reasons.' the adjusted count and per-email skip reasons.'
id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons
- content: Enable many clients in one call. Emails are grouped by inbound and - content: Enable many clients in one call. Emails are grouped by inbound and
applied with a single read-modify-write per inbound; the running Xray applied with a single read-modify-write per inbound; the running Xray
(local or remote node) is updated to add each user. Note that enabling (local or remote node) is updated to add each user. Note that enabling
@@ -417,6 +443,13 @@ _openapi:
/bulkDel after filtering by group for that. Returns the count of /bulkDel after filtering by group for that. Returns the count of
clients whose label was cleared. clients whose label was cleared.
id: remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared id: remove-a-group-deletes-the-client_groups-row-and-clears-the-group-label-from-every-matching-client-both-clientsgroup_name-and-the-inbound-settings-json-the-clients-themselves-are-not-deleted--use-bulkdel-after-filtering-by-group-for-that-returns-the-count-of-clients-whose-label-was-cleared
- content: Reset only the group-level traffic counter shown on the groups page.
Snapshots the current up/down sum of the group's members as a baseline
so the group total reads zero, while leaving each client's own
counters (and their quotas) untouched. No Xray restart is triggered.
Creates the client_groups row if the group exists only as a derived
label.
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
- content: Zero out a single clients up/down counters. Re-enables the client - content: Zero out a single clients up/down counters. Re-enables the client
across every attached inbound and pushes the change to Xray (or the across every attached inbound and pushes the change to Xray (or the
remote node) so depleted users can connect again immediately. remote node) so depleted users can connect again immediately.
@@ -429,6 +462,14 @@ _openapi:
id: list-source-ips-that-have-connected-with-the-given-clients-credentials-returns-an-array-of-ip-timestamp-strings id: list-source-ips-that-have-connected-with-the-given-clients-credentials-returns-an-array-of-ip-timestamp-strings
- content: Reset the recorded IP list for a client. - content: Reset the recorded IP list for a client.
id: reset-the-recorded-ip-list-for-a-client id: reset-the-recorded-ip-list-for-a-client
- content: List registered HWID devices for a client. Hashes are not exposed.
id: list-registered-hwid-devices-for-a-client-hashes-are-not-exposed
- content: Clear all registered HWID devices for a client so new devices can
register again.
id: clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again
- content: Remove a single registered HWID device by its id, freeing one slot
under the HWID limit.
id: remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit
- content: List the emails of currently connected clients (last seen within the - content: List the emails of currently connected clients (last seen within the
heartbeat window), deduped across every node. heartbeat window), deduped across every node.
id: list-the-emails-of-currently-connected-clients-last-seen-within-the-heartbeat-window-deduped-across-every-node id: list-the-emails-of-currently-connected-clients-last-seen-within-the-heartbeat-window-deduped-across-every-node
@@ -466,14 +507,6 @@ _openapi:
proxy. Protocols without a URL form (socks, http, mixed, wireguard, proxy. Protocols without a URL form (socks, http, mixed, wireguard,
dokodemo, tunnel) contribute nothing.' dokodemo, tunnel) contribute nothing.'
id: return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing id: return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
- content: List registered HWID devices for a client. Hashes are not exposed.
id: list-registered-hwid-devices-for-a-client-hashes-are-not-exposed
- content: Clear all registered HWID devices for a client so new devices can
register again.
id: clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again
- content: Remove a single registered HWID device by its id, freeing one slot
under the HWID limit.
id: remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit
contents: contents:
- content: >- - content: >-
Fields the server fills in when they are omitted — a valid value sent Fields the server fills in when they are omitted — a valid value sent
@@ -536,7 +569,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/links/{email}","method":"get"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
</> </>
); );
} }
+42 -43
View File
@@ -13,66 +13,65 @@ _openapi:
sort order. sort order.
url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order' url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
- depth: 2 - depth: 2
title: Fetch a single host by ID. title: Fetch a single host group by Group ID.
url: '#fetch-a-single-host-by-id' url: '#fetch-a-single-host-group-by-group-id'
- depth: 2 - depth: 2
title: Fetch one inbound's hosts, ordered by sort order then id. title: Fetch one inbound's hosts, grouped by host group.
url: '#fetch-one-inbounds-hosts-ordered-by-sort-order-then-id' url: '#fetch-one-inbounds-hosts-grouped-by-host-group'
- depth: 2 - depth: 2
title: Distinct, sorted set of tags used across all hosts. title: Distinct, sorted set of tags used across all hosts.
url: '#distinct-sorted-set-of-tags-used-across-all-hosts' url: '#distinct-sorted-set-of-tags-used-across-all-hosts'
- depth: 2 - depth: 2
title: Create a host on an inbound. inboundId and remark are required; security title: Create a host group on inbounds.
defaults to "same" (inherit the inbound). url: '#create-a-host-group-on-inbounds'
url: '#create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound'
- depth: 2 - depth: 2
title: Replace a hosts content. The inbound and sort order are immutable here title: Replace a host groups content.
(use /reorder for ordering). url: '#replace-a-host-groups-content'
url: '#replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering'
- depth: 2 - depth: 2
title: Delete a host. title: Delete a host group.
url: '#delete-a-host' url: '#delete-a-host-group'
- depth: 2 - depth: 2
title: Enable or disable a single host (disabled hosts are skipped in title: Enable or disable a host group.
subscriptions). url: '#enable-or-disable-a-host-group'
url: '#enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions'
- depth: 2 - depth: 2
title: Set host sort order by the position of each id in the array. title: Set host group sort order by the position of each groupId in the array.
url: '#set-host-sort-order-by-the-position-of-each-id-in-the-array' url: '#set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array'
- depth: 2 - depth: 2
title: Enable or disable many hosts in one call. title: Add a host group to inbounds (same as /add).
url: '#enable-or-disable-many-hosts-in-one-call' url: '#add-a-host-group-to-inbounds-same-as-add'
- depth: 2 - depth: 2
title: Delete many hosts in one call. title: Enable or disable many host groups in one call.
url: '#delete-many-hosts-in-one-call' url: '#enable-or-disable-many-host-groups-in-one-call'
- depth: 2
title: Delete many host groups in one call.
url: '#delete-many-host-groups-in-one-call'
structuredData: structuredData:
headings: headings:
- content: List every host across all inbounds, grouped by inbound then ordered by - content: List every host across all inbounds, grouped by inbound then ordered by
sort order. sort order.
id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order
- content: Fetch a single host by ID. - content: Fetch a single host group by Group ID.
id: fetch-a-single-host-by-id id: fetch-a-single-host-group-by-group-id
- content: Fetch one inbound's hosts, ordered by sort order then id. - content: Fetch one inbound's hosts, grouped by host group.
id: fetch-one-inbounds-hosts-ordered-by-sort-order-then-id id: fetch-one-inbounds-hosts-grouped-by-host-group
- content: Distinct, sorted set of tags used across all hosts. - content: Distinct, sorted set of tags used across all hosts.
id: distinct-sorted-set-of-tags-used-across-all-hosts id: distinct-sorted-set-of-tags-used-across-all-hosts
- content: Create a host on an inbound. inboundId and remark are required; - content: Create a host group on inbounds.
security defaults to "same" (inherit the inbound). id: create-a-host-group-on-inbounds
id: create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound - content: Replace a host groups content.
- content: Replace a hosts content. The inbound and sort order are immutable here id: replace-a-host-groups-content
(use /reorder for ordering). - content: Delete a host group.
id: replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering id: delete-a-host-group
- content: Delete a host. - content: Enable or disable a host group.
id: delete-a-host id: enable-or-disable-a-host-group
- content: Enable or disable a single host (disabled hosts are skipped in - content: Set host group sort order by the position of each groupId in the array.
subscriptions). id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array
id: enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions - content: Add a host group to inbounds (same as /add).
- content: Set host sort order by the position of each id in the array. id: add-a-host-group-to-inbounds-same-as-add
id: set-host-sort-order-by-the-position-of-each-id-in-the-array - content: Enable or disable many host groups in one call.
- content: Enable or disable many hosts in one call. id: enable-or-disable-many-host-groups-in-one-call
id: enable-or-disable-many-hosts-in-one-call - content: Delete many host groups in one call.
- content: Delete many hosts in one call. id: delete-many-host-groups-in-one-call
id: delete-many-hosts-in-one-call
contents: [] contents: []
--- ---
@@ -85,7 +84,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{id}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{id}","method":"post"},{"path":"/panel/api/hosts/del/{id}","method":"post"},{"path":"/panel/api/hosts/setEnable/{id}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/hosts/list","method":"get"},{"path":"/panel/api/hosts/get/{groupId}","method":"get"},{"path":"/panel/api/hosts/byInbound/{inboundId}","method":"get"},{"path":"/panel/api/hosts/tags","method":"get"},{"path":"/panel/api/hosts/add","method":"post"},{"path":"/panel/api/hosts/update/{groupId}","method":"post"},{"path":"/panel/api/hosts/del/{groupId}","method":"post"},{"path":"/panel/api/hosts/setEnable/{groupId}","method":"post"},{"path":"/panel/api/hosts/reorder","method":"post"},{"path":"/panel/api/hosts/bulk/add","method":"post"},{"path":"/panel/api/hosts/bulk/setEnable","method":"post"},{"path":"/panel/api/hosts/bulk/del","method":"post"}]} showTitle />
</> </>
); );
} }
@@ -33,6 +33,15 @@ _openapi:
clientStats so the payload stays small even on panels with thousands of clientStats so the payload stays small even on panels with thousands of
clients. clients.
url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients' url: '#lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients'
- depth: 2
title: Return every protocol URL (vless://, vmess://, trojan://, ss://,
hysteria://, mtproto) across all inbounds and all of their clients.
Links are rendered through the subscription engine, so the configured
remark template (name-only display part) is applied per client — the
same output the client info/QR pages use. Protocols without a URL form
(socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.
Used by the panels "Export all inbound links" action.
url: '#return-every-protocol-url-vless-vmess-trojan-ss-hysteria-mtproto-across-all-inbounds-and-all-of-their-clients-links-are-rendered-through-the-subscription-engine-so-the-configured-remark-template-name-only-display-part-is-applied-per-client--the-same-output-the-client-infoqr-pages-use-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing-used-by-the-panels-export-all-inbound-links-action'
- depth: 2 - depth: 2
title: Fetch a single inbound by numeric ID. title: Fetch a single inbound by numeric ID.
url: '#fetch-a-single-inbound-by-numeric-id' url: '#fetch-a-single-inbound-by-numeric-id'
@@ -59,6 +68,10 @@ _openapi:
title: Toggle only the enable flag without serialising the whole settings JSON. title: Toggle only the enable flag without serialising the whole settings JSON.
Recommended for UI switches on large inbounds. Recommended for UI switches on large inbounds.
url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds' url: '#toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds'
- depth: 2
title: Set only the subscription sort order. Reads the stored inbound, so a
reorder cannot carry a stale client list over a concurrent edit.
url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
- depth: 2 - depth: 2
title: Zero out upload + download counters for a single inbound. Does not touch title: Zero out upload + download counters for a single inbound. Does not touch
per-client counters. per-client counters.
@@ -94,10 +107,6 @@ _openapi:
title: Replace the entire fallback list for a master inbound. Body is JSON. title: Replace the entire fallback list for a master inbound. Body is JSON.
Triggers an Xray restart. Triggers an Xray restart.
url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart' url: '#replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart'
- depth: 2
title: Set only the subscription sort order. Reads the stored inbound, so a
reorder cannot carry a stale client list over a concurrent edit.
url: '#set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit'
structuredData: structuredData:
headings: headings:
- content: List every inbound owned by the authenticated user, including each - content: List every inbound owned by the authenticated user, including each
@@ -121,6 +130,14 @@ _openapi:
clientStats so the payload stays small even on panels with thousands clientStats so the payload stays small even on panels with thousands
of clients. of clients.
id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients id: lightweight-picker-projection-of-the-authenticated-users-inbounds-returns-id-remark-tag-protocol-port-a-server-computed-tlsflowcapable-flag-true-for-vless-on-tcp-with-tls-or-reality-or-on-xhttp-with-vless-encryption--vlessenc-enabled-and-ssmethod-the-shadowsocks-cipher-empty-for-non-shadowsocks-inbounds--used-by-the-client-ui-to-generate-a-valid-shadowsocks-2022-psk-use-this-for-dropdowns-and-attach-pickers--it-skips-settings-streamsettings-and-clientstats-so-the-payload-stays-small-even-on-panels-with-thousands-of-clients
- content: Return every protocol URL (vless://, vmess://, trojan://, ss://,
hysteria://, mtproto) across all inbounds and all of their clients.
Links are rendered through the subscription engine, so the configured
remark template (name-only display part) is applied per client — the
same output the client info/QR pages use. Protocols without a URL form
(socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.
Used by the panels "Export all inbound links" action.
id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-mtproto-across-all-inbounds-and-all-of-their-clients-links-are-rendered-through-the-subscription-engine-so-the-configured-remark-template-name-only-display-part-is-applied-per-client--the-same-output-the-client-infoqr-pages-use-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing-used-by-the-panels-export-all-inbound-links-action
- content: Fetch a single inbound by numeric ID. - content: Fetch a single inbound by numeric ID.
id: fetch-a-single-inbound-by-numeric-id id: fetch-a-single-inbound-by-numeric-id
- content: Create a new inbound. Send the full inbound payload (protocol, port, - content: Create a new inbound. Send the full inbound payload (protocol, port,
@@ -141,6 +158,9 @@ _openapi:
- content: Toggle only the enable flag without serialising the whole settings - content: Toggle only the enable flag without serialising the whole settings
JSON. Recommended for UI switches on large inbounds. JSON. Recommended for UI switches on large inbounds.
id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds id: toggle-only-the-enable-flag-without-serialising-the-whole-settings-json-recommended-for-ui-switches-on-large-inbounds
- content: Set only the subscription sort order. Reads the stored inbound, so a
reorder cannot carry a stale client list over a concurrent edit.
id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
- content: Zero out upload + download counters for a single inbound. Does not - content: Zero out upload + download counters for a single inbound. Does not
touch per-client counters. touch per-client counters.
id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters id: zero-out-upload--download-counters-for-a-single-inbound-does-not-touch-per-client-counters
@@ -169,9 +189,6 @@ _openapi:
- content: Replace the entire fallback list for a master inbound. Body is JSON. - content: Replace the entire fallback list for a master inbound. Body is JSON.
Triggers an Xray restart. Triggers an Xray restart.
id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart id: replace-the-entire-fallback-list-for-a-master-inbound-body-is-json-triggers-an-xray-restart
- content: Set only the subscription sort order. Reads the stored inbound, so a
reorder cannot carry a stale client list over a concurrent edit.
id: set-only-the-subscription-sort-order-reads-the-stored-inbound-so-a-reorder-cannot-carry-a-stale-client-list-over-a-concurrent-edit
contents: [] contents: []
--- ---
@@ -184,7 +201,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/inbounds/list","method":"get"},{"path":"/panel/api/inbounds/list/slim","method":"get"},{"path":"/panel/api/inbounds/options","method":"get"},{"path":"/panel/api/inbounds/allLinks","method":"get"},{"path":"/panel/api/inbounds/get/{id}","method":"get"},{"path":"/panel/api/inbounds/add","method":"post"},{"path":"/panel/api/inbounds/del/{id}","method":"post"},{"path":"/panel/api/inbounds/bulkDel","method":"post"},{"path":"/panel/api/inbounds/update/{id}","method":"post"},{"path":"/panel/api/inbounds/setEnable/{id}","method":"post"},{"path":"/panel/api/inbounds/{id}/subSortIndex","method":"post"},{"path":"/panel/api/inbounds/{id}/resetTraffic","method":"post"},{"path":"/panel/api/inbounds/{id}/delAllClients","method":"post"},{"path":"/panel/api/inbounds/resetAllTraffics","method":"post"},{"path":"/panel/api/inbounds/import","method":"post"},{"path":"/panel/api/inbounds/pushClientTraffics","method":"post"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"get"},{"path":"/panel/api/inbounds/{id}/fallbacks","method":"post"}]} showTitle />
</> </>
); );
} }
@@ -11,6 +11,7 @@
"settings", "settings",
"xray-settings", "xray-settings",
"subscription-server", "subscription-server",
"subscription-balancers",
"hosts", "hosts",
"nodes", "nodes",
"backup", "backup",
+26 -20
View File
@@ -22,6 +22,11 @@ _openapi:
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value
must be a PEM certificate. Applied on the next panel restart. must be a PEM certificate. Applied on the next panel restart.
url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart' url: '#set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart'
- depth: 2
title: Validate the stored master mTLS client credential and invalidate cached
transports. Each transport closes its old idle pool and rebuilds with
the rotated certificate before its next request.
url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
- depth: 2 - depth: 2
title: Fetch a single node by ID. title: Fetch a single node by ID.
url: '#fetch-a-single-node-by-id' url: '#fetch-a-single-node-by-id'
@@ -32,12 +37,15 @@ _openapi:
panel. panel.
url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel' url: '#fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel'
- depth: 2 - depth: 2
title: Register a new remote node. Provide its URL, apiToken, and optional title: Register a new remote node. Provide its URL, write-only apiToken, and
remark / allowPrivateAddress flag. optional remark / allowPrivateAddress flag. Responses expose hasApiToken
url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag' only.
url: '#register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only'
- depth: 2 - depth: 2
title: Replace a nodes connection details. Same body shape as /add. title: 'Replace a nodes connection details. apiToken is write-only: omit it or
url: '#replace-a-nodes-connection-details-same-body-shape-as-add' send an empty string to keep the stored token; set clearApiToken=true to
clear it.'
url: '#replace-a-nodes-connection-details-apitoken-is-write-only-omit-it-or-send-an-empty-string-to-keep-the-stored-token-set-clearapitokentrue-to-clear-it'
- depth: 2 - depth: 2
title: Delete a node. Inbounds bound to it are not auto-migrated. title: Delete a node. Inbounds bound to it are not auto-migrated.
url: '#delete-a-node-inbounds-bound-to-it-are-not-auto-migrated' url: '#delete-a-node-inbounds-bound-to-it-are-not-auto-migrated'
@@ -72,11 +80,6 @@ _openapi:
title: Aggregated metric history for a node — same shape as /server/history, title: Aggregated metric history for a node — same shape as /server/history,
scoped to one node. scoped to one node.
url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node' url: '#aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node'
- depth: 2
title: Validate the stored master mTLS client credential and invalidate cached
transports. Each transport closes its old idle pool and rebuilds with
the rotated certificate before its next request.
url: '#validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request'
structuredData: structuredData:
headings: headings:
- content: List every configured node with its connection details, health, and - content: List every configured node with its connection details, health, and
@@ -91,6 +94,10 @@ _openapi:
CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty
value must be a PEM certificate. Applied on the next panel restart. value must be a PEM certificate. Applied on the next panel restart.
id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart id: set-the-ca-certificate-this-panel-trusts-for-incoming-node-api-client-certificates-this-panel-acting-as-a-node-paste-the-managing-panels-ca-from-nodesmtlsca-an-empty-cacert-disables-it-a-non-empty-value-must-be-a-pem-certificate-applied-on-the-next-panel-restart
- content: Validate the stored master mTLS client credential and invalidate cached
transports. Each transport closes its old idle pool and rebuilds with
the rotated certificate before its next request.
id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
- content: Fetch a single node by ID. - content: Fetch a single node by ID.
id: fetch-a-single-node-by-id id: fetch-a-single-node-by-id
- content: Fetch a node's own web TLS certificate/key file paths (proxied to the - content: Fetch a node's own web TLS certificate/key file paths (proxied to the
@@ -98,11 +105,14 @@ _openapi:
node-assigned inbound gets paths that exist on the node, not the node-assigned inbound gets paths that exist on the node, not the
central panel. central panel.
id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel id: fetch-a-nodes-own-web-tls-certificatekey-file-paths-proxied-to-the-node-used-by-the-inbound-forms-set-cert-from-panel-so-a-node-assigned-inbound-gets-paths-that-exist-on-the-node-not-the-central-panel
- content: Register a new remote node. Provide its URL, apiToken, and optional - content: Register a new remote node. Provide its URL, write-only apiToken, and
remark / allowPrivateAddress flag. optional remark / allowPrivateAddress flag. Responses expose
id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag hasApiToken only.
- content: Replace a nodes connection details. Same body shape as /add. id: register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only
id: replace-a-nodes-connection-details-same-body-shape-as-add - content: 'Replace a nodes connection details. apiToken is write-only: omit it
or send an empty string to keep the stored token; set
clearApiToken=true to clear it.'
id: replace-a-nodes-connection-details-apitoken-is-write-only-omit-it-or-send-an-empty-string-to-keep-the-stored-token-set-clearapitokentrue-to-clear-it
- content: Delete a node. Inbounds bound to it are not auto-migrated. - content: Delete a node. Inbounds bound to it are not auto-migrated.
id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated
- content: Pause or resume traffic sync with this node. - content: Pause or resume traffic sync with this node.
@@ -129,10 +139,6 @@ _openapi:
- content: Aggregated metric history for a node — same shape as /server/history, - content: Aggregated metric history for a node — same shape as /server/history,
scoped to one node. scoped to one node.
id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node id: aggregated-metric-history-for-a-node--same-shape-as-serverhistory-scoped-to-one-node
- content: Validate the stored master mTLS client credential and invalidate cached
transports. Each transport closes its old idle pool and rebuilds with
the rotated certificate before its next request.
id: validate-the-stored-master-mtls-client-credential-and-invalidate-cached-transports-each-transport-closes-its-old-idle-pool-and-rebuilds-with-the-rotated-certificate-before-its-next-request
contents: [] contents: []
--- ---
@@ -145,7 +151,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/nodes/list","method":"get"},{"path":"/panel/api/nodes/mtls/ca","method":"post"},{"path":"/panel/api/nodes/mtls/trustCA","method":"post"},{"path":"/panel/api/nodes/mtls/reloadClient","method":"post"},{"path":"/panel/api/nodes/get/{id}","method":"get"},{"path":"/panel/api/nodes/webCert/{id}","method":"get"},{"path":"/panel/api/nodes/add","method":"post"},{"path":"/panel/api/nodes/update/{id}","method":"post"},{"path":"/panel/api/nodes/del/{id}","method":"post"},{"path":"/panel/api/nodes/setEnable/{id}","method":"post"},{"path":"/panel/api/nodes/test","method":"post"},{"path":"/panel/api/nodes/certFingerprint","method":"post"},{"path":"/panel/api/nodes/inbounds","method":"post"},{"path":"/panel/api/nodes/probe/{id}","method":"post"},{"path":"/panel/api/nodes/updatePanel","method":"post"},{"path":"/panel/api/nodes/history/{id}/{metric}/{bucket}","method":"get"}]} showTitle />
</> </>
); );
} }
+67 -12
View File
@@ -7,6 +7,12 @@ _openapi:
preload: preload:
- ./public/openapi.json - ./public/openapi.json
toc: toc:
- depth: 2
title: Serve this API description as an OpenAPI 3 document — the same file that
powers the API Docs page. Requires a session or Bearer token like the
rest of /panel/api. Useful for generating clients or importing into API
tooling.
url: '#serve-this-api-description-as-an-openapi-3-document--the-same-file-that-powers-the-api-docs-page-requires-a-session-or-bearer-token-like-the-rest-of-panelapi-useful-for-generating-clients-or-importing-into-api-tooling'
- depth: 2 - depth: 2
title: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load title: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
averages, open connections, Xray state. Cached and refreshed every 2 averages, open connections, Xray state. Cached and refreshed every 2
@@ -49,12 +55,19 @@ _openapi:
- depth: 2 - depth: 2
title: Check whether a newer 3x-ui release is available on GitHub. title: Check whether a newer 3x-ui release is available on GitHub.
url: '#check-whether-a-newer-3x-ui-release-is-available-on-github' url: '#check-whether-a-newer-3x-ui-release-is-available-on-github'
- depth: 2
title: Report the outcome of the most recently launched panel self-update (see
POST updatePanel). Compare the returned runId against the one
updatePanel returned to tell this run apart from a stale result.
url: '#report-the-outcome-of-the-most-recently-launched-panel-self-update-see-post-updatepanel-compare-the-returned-runid-against-the-one-updatepanel-returned-to-tell-this-run-apart-from-a-stale-result'
- depth: 2 - depth: 2
title: Return the assembled Xray config thats currently running on this host. title: Return the assembled Xray config thats currently running on this host.
url: '#return-the-assembled-xray-config-thats-currently-running-on-this-host' url: '#return-the-assembled-xray-config-thats-currently-running-on-this-host'
- depth: 2 - depth: 2
title: Stream the SQLite database file as an attachment. Use as a manual backup. title: 'Stream a full database backup as an attachment: the SQLite .db file on
url: '#stream-the-sqlite-database-file-as-an-attachment-use-as-a-manual-backup' SQLite panels, or a pg_dump custom-format archive (.dump) on PostgreSQL
panels. Use as a manual backup.'
url: '#stream-a-full-database-backup-as-an-attachment-the-sqlite-db-file-on-sqlite-panels-or-a-pg_dump-custom-format-archive-dump-on-postgresql-panels-use-as-a-manual-backup'
- depth: 2 - depth: 2
title: 'Stream a cross-engine migration file as an attachment: a .dump (SQL title: 'Stream a cross-engine migration file as an attachment: a .dump (SQL
text) on SQLite, or a .db SQLite database built from the live data on text) on SQLite, or a .db SQLite database built from the live data on
@@ -123,9 +136,12 @@ _openapi:
title: Return the last N lines of the Xray process log. title: Return the last N lines of the Xray process log.
url: '#return-the-last-n-lines-of-the-xray-process-log' url: '#return-the-last-n-lines-of-the-xray-process-log'
- depth: 2 - depth: 2
title: Restore the panel DB from an uploaded SQLite file (multipart form, field title: Restore the panel DB from an uploaded backup (multipart form, field name
name "db"). The panel restarts after restore. Destructive. "db"). SQLite panels accept a SQLite database (.db) or a SQLite
url: '#restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive' migration dump (.dump); PostgreSQL panels accept a pg_dump archive
(.dump), a SQLite database (.db), or a SQLite migration dump. The panel
restarts after restore. Destructive.
url: '#restore-the-panel-db-from-an-uploaded-backup-multipart-form-field-name-db-sqlite-panels-accept-a-sqlite-database-db-or-a-sqlite-migration-dump-dump-postgresql-panels-accept-a-pg_dump-archive-dump-a-sqlite-database-db-or-a-sqlite-migration-dump-the-panel-restarts-after-restore-destructive'
- depth: 2 - depth: 2
title: Generate a new ECH (Encrypted Client Hello) keypair and config list for title: Generate a new ECH (Encrypted Client Hello) keypair and config list for
the given SNI. the given SNI.
@@ -139,6 +155,20 @@ _openapi:
title: Run `xray tls ping` against a remote server and return its live title: Run `xray tls ping` against a remote server and return its live
leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256). leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).
url: '#run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256' url: '#run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256'
- depth: 2
title: Run a live TLS 1.3 probe against a candidate REALITY target and return a
feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus
the certificate SAN DNS names. A target on a private/loopback address is
reported with privateTarget=true and probed only when allowPrivate is
set.
url: '#run-a-live-tls-13-probe-against-a-candidate-reality-target-and-return-a-feasibility-verdict-tls-13--h2--x25519--trusted-certificate-plus-the-certificate-san-dns-names-a-target-on-a-privateloopback-address-is-reported-with-privatetargettrue-and-probed-only-when-allowprivate-is-set'
- depth: 2
title: Probe/discover REALITY targets and return each verdict ranked by
feasibility then latency. Each comma-separated token may be a domain
(validated with SNI), a bare IP, or a CIDR range (discovered without SNI
by reading the certificate domain). When empty, a built-in seed list is
probed.
url: '#probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-a-built-in-seed-list-is-probed'
- depth: 2 - depth: 2
title: Fetch the fully aggregated inbound_client_ips database table. Used by title: Fetch the fully aggregated inbound_client_ips database table. Used by
nodes to sync recently active IPs across the cluster. nodes to sync recently active IPs across the cluster.
@@ -149,6 +179,11 @@ _openapi:
url: '#submit-a-list-of-recently-active-ip-timestamps-the-panel-merges-them-with-the-existing-database-to-maintain-a-unified-global-ip-limit-view' url: '#submit-a-list-of-recently-active-ip-timestamps-the-panel-merges-them-with-the-existing-database-to-maintain-a-unified-global-ip-limit-view'
structuredData: structuredData:
headings: headings:
- content: Serve this API description as an OpenAPI 3 document — the same file
that powers the API Docs page. Requires a session or Bearer token like
the rest of /panel/api. Useful for generating clients or importing
into API tooling.
id: serve-this-api-description-as-an-openapi-3-document--the-same-file-that-powers-the-api-docs-page-requires-a-session-or-bearer-token-like-the-rest-of-panelapi-useful-for-generating-clients-or-importing-into-api-tooling
- content: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load - content: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load
averages, open connections, Xray state. Cached and refreshed every 2 averages, open connections, Xray state. Cached and refreshed every 2
seconds in the background.' seconds in the background.'
@@ -181,11 +216,16 @@ _openapi:
id: list-xray-binary-versions-available-for-install-on-this-host id: list-xray-binary-versions-available-for-install-on-this-host
- content: Check whether a newer 3x-ui release is available on GitHub. - content: Check whether a newer 3x-ui release is available on GitHub.
id: check-whether-a-newer-3x-ui-release-is-available-on-github id: check-whether-a-newer-3x-ui-release-is-available-on-github
- content: Report the outcome of the most recently launched panel self-update (see
POST updatePanel). Compare the returned runId against the one
updatePanel returned to tell this run apart from a stale result.
id: report-the-outcome-of-the-most-recently-launched-panel-self-update-see-post-updatepanel-compare-the-returned-runid-against-the-one-updatepanel-returned-to-tell-this-run-apart-from-a-stale-result
- content: Return the assembled Xray config thats currently running on this host. - content: Return the assembled Xray config thats currently running on this host.
id: return-the-assembled-xray-config-thats-currently-running-on-this-host id: return-the-assembled-xray-config-thats-currently-running-on-this-host
- content: Stream the SQLite database file as an attachment. Use as a manual - content: 'Stream a full database backup as an attachment: the SQLite .db file on
backup. SQLite panels, or a pg_dump custom-format archive (.dump) on
id: stream-the-sqlite-database-file-as-an-attachment-use-as-a-manual-backup PostgreSQL panels. Use as a manual backup.'
id: stream-a-full-database-backup-as-an-attachment-the-sqlite-db-file-on-sqlite-panels-or-a-pg_dump-custom-format-archive-dump-on-postgresql-panels-use-as-a-manual-backup
- content: 'Stream a cross-engine migration file as an attachment: a .dump (SQL - content: 'Stream a cross-engine migration file as an attachment: a .dump (SQL
text) on SQLite, or a .db SQLite database built from the live data on text) on SQLite, or a .db SQLite database built from the live data on
PostgreSQL.' PostgreSQL.'
@@ -236,9 +276,12 @@ _openapi:
id: return-the-last-n-lines-of-the-panels-own-log id: return-the-last-n-lines-of-the-panels-own-log
- content: Return the last N lines of the Xray process log. - content: Return the last N lines of the Xray process log.
id: return-the-last-n-lines-of-the-xray-process-log id: return-the-last-n-lines-of-the-xray-process-log
- content: Restore the panel DB from an uploaded SQLite file (multipart form, - content: Restore the panel DB from an uploaded backup (multipart form, field
field name "db"). The panel restarts after restore. Destructive. name "db"). SQLite panels accept a SQLite database (.db) or a SQLite
id: restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive migration dump (.dump); PostgreSQL panels accept a pg_dump archive
(.dump), a SQLite database (.db), or a SQLite migration dump. The
panel restarts after restore. Destructive.
id: restore-the-panel-db-from-an-uploaded-backup-multipart-form-field-name-db-sqlite-panels-accept-a-sqlite-database-db-or-a-sqlite-migration-dump-dump-postgresql-panels-accept-a-pg_dump-archive-dump-a-sqlite-database-db-or-a-sqlite-migration-dump-the-panel-restarts-after-restore-destructive
- content: Generate a new ECH (Encrypted Client Hello) keypair and config list for - content: Generate a new ECH (Encrypted Client Hello) keypair and config list for
the given SNI. the given SNI.
id: generate-a-new-ech-encrypted-client-hello-keypair-and-config-list-for-the-given-sni id: generate-a-new-ech-encrypted-client-hello-keypair-and-config-list-for-the-given-sni
@@ -249,6 +292,18 @@ _openapi:
- content: Run `xray tls ping` against a remote server and return its live - content: Run `xray tls ping` against a remote server and return its live
leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256). leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).
id: run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256 id: run-xray-tls-ping-against-a-remote-server-and-return-its-live-leaf-certificate-sha-256-hashes-for-pinning-pinnedpeercertsha256
- content: Run a live TLS 1.3 probe against a candidate REALITY target and return
a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate)
plus the certificate SAN DNS names. A target on a private/loopback
address is reported with privateTarget=true and probed only when
allowPrivate is set.
id: run-a-live-tls-13-probe-against-a-candidate-reality-target-and-return-a-feasibility-verdict-tls-13--h2--x25519--trusted-certificate-plus-the-certificate-san-dns-names-a-target-on-a-privateloopback-address-is-reported-with-privatetargettrue-and-probed-only-when-allowprivate-is-set
- content: Probe/discover REALITY targets and return each verdict ranked by
feasibility then latency. Each comma-separated token may be a domain
(validated with SNI), a bare IP, or a CIDR range (discovered without
SNI by reading the certificate domain). When empty, a built-in seed
list is probed.
id: probediscover-reality-targets-and-return-each-verdict-ranked-by-feasibility-then-latency-each-comma-separated-token-may-be-a-domain-validated-with-sni-a-bare-ip-or-a-cidr-range-discovered-without-sni-by-reading-the-certificate-domain-when-empty-a-built-in-seed-list-is-probed
- content: Fetch the fully aggregated inbound_client_ips database table. Used by - content: Fetch the fully aggregated inbound_client_ips database table. Used by
nodes to sync recently active IPs across the cluster. nodes to sync recently active IPs across the cluster.
id: fetch-the-fully-aggregated-inbound_client_ips-database-table-used-by-nodes-to-sync-recently-active-ips-across-the-cluster id: fetch-the-fully-aggregated-inbound_client_ips-database-table-used-by-nodes-to-sync-recently-active-ips-across-the-cluster
@@ -267,7 +322,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/openapi.json","method":"get"},{"path":"/panel/api/server/status","method":"get"},{"path":"/panel/api/server/fail2banStatus","method":"get"},{"path":"/panel/api/server/cpuHistory/{bucket}","method":"get"},{"path":"/panel/api/server/history/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayMetricsState","method":"get"},{"path":"/panel/api/server/xrayMetricsHistory/{metric}/{bucket}","method":"get"},{"path":"/panel/api/server/xrayObservatory","method":"get"},{"path":"/panel/api/server/xrayObservatoryHistory/{tag}/{bucket}","method":"get"},{"path":"/panel/api/server/getXrayVersion","method":"get"},{"path":"/panel/api/server/getPanelUpdateInfo","method":"get"},{"path":"/panel/api/server/getUpdateStatus","method":"get"},{"path":"/panel/api/server/getConfigJson","method":"get"},{"path":"/panel/api/server/getDb","method":"get"},{"path":"/panel/api/server/getMigration","method":"get"},{"path":"/panel/api/server/getNewUUID","method":"get"},{"path":"/panel/api/server/getWebCertFiles","method":"get"},{"path":"/panel/api/server/descendants","method":"get"},{"path":"/panel/api/server/getNewX25519Cert","method":"get"},{"path":"/panel/api/server/getNewmldsa65","method":"get"},{"path":"/panel/api/server/getNewmlkem768","method":"get"},{"path":"/panel/api/server/getNewVlessEnc","method":"get"},{"path":"/panel/api/server/stopXrayService","method":"post"},{"path":"/panel/api/server/restartXrayService","method":"post"},{"path":"/panel/api/server/installXray/{version}","method":"post"},{"path":"/panel/api/server/updatePanel","method":"post"},{"path":"/panel/api/server/setUpdateChannel","method":"post"},{"path":"/panel/api/server/updateGeofile","method":"post"},{"path":"/panel/api/server/updateGeofile/{fileName}","method":"post"},{"path":"/panel/api/server/logs/{count}","method":"post"},{"path":"/panel/api/server/xraylogs/{count}","method":"post"},{"path":"/panel/api/server/importDB","method":"post"},{"path":"/panel/api/server/getNewEchCert","method":"post"},{"path":"/panel/api/server/getCertHash","method":"post"},{"path":"/panel/api/server/getRemoteCertHash","method":"post"},{"path":"/panel/api/server/scanRealityTarget","method":"post"},{"path":"/panel/api/server/scanRealityTargets","method":"post"},{"path":"/panel/api/server/clientIps","method":"get"},{"path":"/panel/api/server/clientIps","method":"post"}]} showTitle />
</> </>
); );
} }
@@ -15,11 +15,21 @@ _openapi:
title: Return the computed default settings based on the request host. Useful to title: Return the computed default settings based on the request host. Useful to
preview what a fresh install would use. preview what a fresh install would use.
url: '#return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use' url: '#return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use'
- depth: 2
title: Return the shipped (factory) default value per browser-safe setting key,
so clients can tell a stored value apart from the default it would fall
back to. Per-install material (secret, panelGuid, mTLS keys) and
credential fields are never included.
url: '#return-the-shipped-factory-default-value-per-browser-safe-setting-key-so-clients-can-tell-a-stored-value-apart-from-the-default-it-would-fall-back-to-per-install-material-secret-panelguid-mtls-keys-and-credential-fields-are-never-included'
- depth: 2 - depth: 2
title: Persist every setting at once. The body mirrors the shape returned by title: Persist every setting at once. The body mirrors the shape returned by
/all. Invalid values (bad ports, missing cert pairs, etc.) are rejected /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected
before write. before write.
url: '#persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write' url: '#persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write'
- depth: 2
title: Validate any regular expression with the backend Go RE2 compiler without
saving it.
url: '#validate-any-regular-expression-with-the-backend-go-re2-compiler-without-saving-it'
- depth: 2 - depth: 2
title: Change the panel admin username and password. Requires the current title: Change the panel admin username and password. Requires the current
credentials for verification. The session is refreshed with the new credentials for verification. The session is refreshed with the new
@@ -50,10 +60,18 @@ _openapi:
- content: Return the computed default settings based on the request host. Useful - content: Return the computed default settings based on the request host. Useful
to preview what a fresh install would use. to preview what a fresh install would use.
id: return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use id: return-the-computed-default-settings-based-on-the-request-host-useful-to-preview-what-a-fresh-install-would-use
- content: Return the shipped (factory) default value per browser-safe setting
key, so clients can tell a stored value apart from the default it
would fall back to. Per-install material (secret, panelGuid, mTLS
keys) and credential fields are never included.
id: return-the-shipped-factory-default-value-per-browser-safe-setting-key-so-clients-can-tell-a-stored-value-apart-from-the-default-it-would-fall-back-to-per-install-material-secret-panelguid-mtls-keys-and-credential-fields-are-never-included
- content: Persist every setting at once. The body mirrors the shape returned by - content: Persist every setting at once. The body mirrors the shape returned by
/all. Invalid values (bad ports, missing cert pairs, etc.) are /all. Invalid values (bad ports, missing cert pairs, etc.) are
rejected before write. rejected before write.
id: persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write id: persist-every-setting-at-once-the-body-mirrors-the-shape-returned-by-all-invalid-values-bad-ports-missing-cert-pairs-etc-are-rejected-before-write
- content: Validate any regular expression with the backend Go RE2 compiler
without saving it.
id: validate-any-regular-expression-with-the-backend-go-re2-compiler-without-saving-it
- content: Change the panel admin username and password. Requires the current - content: Change the panel admin username and password. Requires the current
credentials for verification. The session is refreshed with the new credentials for verification. The session is refreshed with the new
values on success. values on success.
@@ -83,7 +101,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/setting/all","method":"post"},{"path":"/panel/api/setting/defaultSettings","method":"post"},{"path":"/panel/api/setting/factoryDefaults","method":"post"},{"path":"/panel/api/setting/update","method":"post"},{"path":"/panel/api/setting/validateRegex","method":"post"},{"path":"/panel/api/setting/updateUser","method":"post"},{"path":"/panel/api/setting/restartPanel","method":"post"},{"path":"/panel/api/setting/testSmtp","method":"post"},{"path":"/panel/api/setting/testTgBot","method":"post"},{"path":"/panel/api/setting/getDefaultJsonConfig","method":"get"}]} showTitle />
</> </>
); );
} }
@@ -0,0 +1,60 @@
---
title: Subscription Balancers
description: 'Client-side balancers for the JSON subscription: each enabled
balancer is emitted as one extra config document whose members are the proxy
outbounds of the selected inbounds (routing.balancers + burstObservatory).
Managed in Settings → Sub Balancers.'
full: true
_openapi:
preload:
- ./public/openapi.json
toc:
- depth: 2
title: List all subscription balancers in sort order (sort_order asc, id asc).
url: '#list-all-subscription-balancers-in-sort-order-sort_order-asc-id-asc'
- depth: 2
title: Create a subscription balancer. It appears in the JSON subscription of
every client that sits on at least one selected inbound.
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).
url: '#update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle'
- depth: 2
title: Delete a balancer by id.
url: '#delete-a-balancer-by-id'
- depth: 2
title: Delete a balancer by id (POST alias of DELETE for clients that cannot
send DELETE).
url: '#delete-a-balancer-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
structuredData:
headings:
- content: List all subscription balancers in sort order (sort_order asc, id asc).
id: list-all-subscription-balancers-in-sort-order-sort_order-asc-id-asc
- content: Create a subscription balancer. It appears in the JSON subscription of
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).
id: update-a-balancer-by-id-accepts-the-same-form-fields-as-create-full-row-update-including-the-enabled-toggle
- 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
send DELETE).
id: delete-a-balancer-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete
contents: []
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
export default function Layout(props) {
const { APIPage, OpenAPIPage } = props.components ?? {};
// "APIPage" is the old name from v10, this allows both for backward compatibility
const Comp = OpenAPIPage ?? APIPage;
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/sub-balancers","method":"get"},{"path":"/panel/api/sub-balancers","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"post"},{"path":"/panel/api/sub-balancers/{id}","method":"delete"},{"path":"/panel/api/sub-balancers/{id}/del","method":"post"}]} showTitle />
</>
);
}
@@ -13,9 +13,10 @@ _openapi:
- depth: 2 - depth: 2
title: 'Return base64-encoded subscription links for all enabled clients title: 'Return base64-encoded subscription links for all enabled clients
matching the subscription ID. When the request has an Accept: text/html matching the subscription ID. When the request has an Accept: text/html
header or ?html=1, renders a styled info page instead. Default path: header or ?html=1, renders a styled info page instead. With
/sub/:subid.' ?format=info, returns the page view-model as JSON (traffic, expiry,
url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid' online status; no links) for live polling. Default path: /sub/:subid.'
url: '#return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid'
- depth: 2 - depth: 2
title: 'Return subscription as a JSON array of proxy configs (one per enabled title: 'Return subscription as a JSON array of proxy configs (one per enabled
client). Only when JSON subscription is enabled in settings. Default client). Only when JSON subscription is enabled in settings. Default
@@ -30,9 +31,10 @@ _openapi:
headings: headings:
- content: 'Return base64-encoded subscription links for all enabled clients - content: 'Return base64-encoded subscription links for all enabled clients
matching the subscription ID. When the request has an Accept: matching the subscription ID. When the request has an Accept:
text/html header or ?html=1, renders a styled info page instead. text/html header or ?html=1, renders a styled info page instead. With
Default path: /sub/:subid.' ?format=info, returns the page view-model as JSON (traffic, expiry,
id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-default-path-subsubid online status; no links) for live polling. Default path: /sub/:subid.'
id: return-base64-encoded-subscription-links-for-all-enabled-clients-matching-the-subscription-id-when-the-request-has-an-accept-texthtml-header-or-html1-renders-a-styled-info-page-instead-with-formatinfo-returns-the-page-view-model-as-json-traffic-expiry-online-status-no-links-for-live-polling-default-path-subsubid
- content: 'Return subscription as a JSON array of proxy configs (one per enabled - content: 'Return subscription as a JSON array of proxy configs (one per enabled
client). Only when JSON subscription is enabled in settings. Default client). Only when JSON subscription is enabled in settings. Default
path: /json/:subid.' path: /json/:subid.'
@@ -1,8 +1,7 @@
--- ---
title: Xray Settings title: Xray Settings
description: >- description: Xray configuration template, outbound management, Warp/Nord/PIA
Xray configuration template, outbound management, Warp/Nord/PIA integration, and integration, and config testing. All endpoints under /panel/api/xray.
config testing. All endpoints under /panel/api/xray.
full: true full: true
_openapi: _openapi:
preload: preload:
@@ -37,7 +36,8 @@ _openapi:
title: Manage NordVPN integration. The action parameter selects the operation. title: Manage NordVPN integration. The action parameter selects the operation.
url: '#manage-nordvpn-integration-the-action-parameter-selects-the-operation' url: '#manage-nordvpn-integration-the-action-parameter-selects-the-operation'
- depth: 2 - depth: 2
title: Manage PIA WireGuard integration. The action parameter selects the operation. title: Manage PIA WireGuard integration. The action parameter selects the
operation.
url: '#manage-pia-wireguard-integration-the-action-parameter-selects-the-operation' url: '#manage-pia-wireguard-integration-the-action-parameter-selects-the-operation'
- depth: 2 - depth: 2
title: Reset traffic counters for a specific outbound by tag. title: Reset traffic counters for a specific outbound by tag.
@@ -66,6 +66,25 @@ _openapi:
title: Ask the running core which outbound its router would pick for a synthetic title: Ask the running core which outbound its router would pick for a synthetic
connection (RoutingService.TestRoute). No traffic is sent. connection (RoutingService.TestRoute). No traffic is sent.
url: '#ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent' url: '#ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent'
- depth: 2
title: List the geo databases (.dat files) in the Xray asset folder, with the
layout detected from their contents, size, modification time and
category count. A database that fails to parse is still listed, with the
reason in "error".
url: '#list-the-geo-databases-dat-files-in-the-xray-asset-folder-with-the-layout-detected-from-their-contents-size-modification-time-and-category-count-a-database-that-fails-to-parse-is-still-listed-with-the-reason-in-error'
- depth: 2
title: One page of a database's categories, each with its entry count and the
attributes its domains carry (e.g. "ads", "cn").
url: '#one-page-of-a-databases-categories-each-with-its-entry-count-and-the-attributes-its-domains-carry-eg-ads-cn'
- depth: 2
title: One page of the rules inside a category — domain rules typed as
domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.
url: '#one-page-of-the-rules-inside-a-category--domain-rules-typed-as-domainfullkeywordregexp-for-geosite-databases-cidrs-for-geoip-ones'
- depth: 2
title: 'Check routing tokens against the databases on disk and return only the
ones that do not resolve. Plain domains and CIDRs are ignored. Each
issue carries a reason: syntax, fileMissing or categoryMissing.'
url: '#check-routing-tokens-against-the-databases-on-disk-and-return-only-the-ones-that-do-not-resolve-plain-domains-and-cidrs-are-ignored-each-issue-carries-a-reason-syntax-filemissing-or-categorymissing'
- depth: 2 - depth: 2
title: List all outbound subscriptions (remote URLs that supply additional title: List all outbound subscriptions (remote URLs that supply additional
outbounds), newest first. outbounds), newest first.
@@ -83,9 +102,9 @@ _openapi:
title: Delete an outbound subscription by id. title: Delete an outbound subscription by id.
url: '#delete-an-outbound-subscription-by-id' url: '#delete-an-outbound-subscription-by-id'
- depth: 2 - depth: 2
title: Delete an outbound subscription by id (POST alias of DELETE for title: Delete an outbound subscription by id (POST alias of DELETE for clients
axios-friendly clients). that cannot send DELETE).
url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients' url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete'
- depth: 2 - depth: 2
title: Force an immediate re-fetch of the subscription and return the parsed title: Force an immediate re-fetch of the subscription and return the parsed
outbounds. Signals Xray to reload. outbounds. Signals Xray to reload.
@@ -121,8 +140,7 @@ _openapi:
id: manage-cloudflare-warp-integration-the-action-parameter-selects-the-operation id: manage-cloudflare-warp-integration-the-action-parameter-selects-the-operation
- content: Manage NordVPN integration. The action parameter selects the operation. - content: Manage NordVPN integration. The action parameter selects the operation.
id: manage-nordvpn-integration-the-action-parameter-selects-the-operation id: manage-nordvpn-integration-the-action-parameter-selects-the-operation
- content: >- - content: Manage PIA WireGuard integration. The action parameter selects the
Manage PIA WireGuard integration. The action parameter selects the
operation. operation.
id: manage-pia-wireguard-integration-the-action-parameter-selects-the-operation id: manage-pia-wireguard-integration-the-action-parameter-selects-the-operation
- content: Reset traffic counters for a specific outbound by tag. - content: Reset traffic counters for a specific outbound by tag.
@@ -147,6 +165,22 @@ _openapi:
- content: Ask the running core which outbound its router would pick for a - content: Ask the running core which outbound its router would pick for a
synthetic connection (RoutingService.TestRoute). No traffic is sent. synthetic connection (RoutingService.TestRoute). No traffic is sent.
id: ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent id: ask-the-running-core-which-outbound-its-router-would-pick-for-a-synthetic-connection-routingservicetestroute-no-traffic-is-sent
- content: List the geo databases (.dat files) in the Xray asset folder, with the
layout detected from their contents, size, modification time and
category count. A database that fails to parse is still listed, with
the reason in "error".
id: list-the-geo-databases-dat-files-in-the-xray-asset-folder-with-the-layout-detected-from-their-contents-size-modification-time-and-category-count-a-database-that-fails-to-parse-is-still-listed-with-the-reason-in-error
- content: One page of a database's categories, each with its entry count and the
attributes its domains carry (e.g. "ads", "cn").
id: one-page-of-a-databases-categories-each-with-its-entry-count-and-the-attributes-its-domains-carry-eg-ads-cn
- content: One page of the rules inside a category — domain rules typed as
domain/full/keyword/regexp for geosite databases, CIDRs for geoip
ones.
id: one-page-of-the-rules-inside-a-category--domain-rules-typed-as-domainfullkeywordregexp-for-geosite-databases-cidrs-for-geoip-ones
- content: 'Check routing tokens against the databases on disk and return only the
ones that do not resolve. Plain domains and CIDRs are ignored. Each
issue carries a reason: syntax, fileMissing or categoryMissing.'
id: check-routing-tokens-against-the-databases-on-disk-and-return-only-the-ones-that-do-not-resolve-plain-domains-and-cidrs-are-ignored-each-issue-carries-a-reason-syntax-filemissing-or-categorymissing
- content: List all outbound subscriptions (remote URLs that supply additional - content: List all outbound subscriptions (remote URLs that supply additional
outbounds), newest first. outbounds), newest first.
id: list-all-outbound-subscriptions-remote-urls-that-supply-additional-outbounds-newest-first id: list-all-outbound-subscriptions-remote-urls-that-supply-additional-outbounds-newest-first
@@ -159,9 +193,9 @@ _openapi:
id: update-an-existing-outbound-subscription-by-id-accepts-the-same-form-fields-as-create id: update-an-existing-outbound-subscription-by-id-accepts-the-same-form-fields-as-create
- content: Delete an outbound subscription by id. - content: Delete an outbound subscription by id.
id: delete-an-outbound-subscription-by-id id: delete-an-outbound-subscription-by-id
- content: Delete an outbound subscription by id (POST alias of DELETE for - content: Delete an outbound subscription by id (POST alias of DELETE for clients
axios-friendly clients). that cannot send DELETE).
id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete
- content: Force an immediate re-fetch of the subscription and return the parsed - content: Force an immediate re-fetch of the subscription and return the parsed
outbounds. Signals Xray to reload. outbounds. Signals Xray to reload.
id: force-an-immediate-re-fetch-of-the-subscription-and-return-the-parsed-outbounds-signals-xray-to-reload id: force-an-immediate-re-fetch-of-the-subscription-and-return-the-parsed-outbounds-signals-xray-to-reload
@@ -183,7 +217,7 @@ export default function Layout(props) {
return ( return (
<> <>
{props.children} {props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle /> <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/xray/","method":"post"},{"path":"/panel/api/xray/getDefaultJsonConfig","method":"get"},{"path":"/panel/api/xray/getOutboundsTraffic","method":"get"},{"path":"/panel/api/xray/getXrayResult","method":"get"},{"path":"/panel/api/xray/update","method":"post"},{"path":"/panel/api/xray/warp/{action}","method":"post"},{"path":"/panel/api/xray/nord/{action}","method":"post"},{"path":"/panel/api/xray/pia/{action}","method":"post"},{"path":"/panel/api/xray/resetOutboundsTraffic","method":"post"},{"path":"/panel/api/xray/testOutbound","method":"post"},{"path":"/panel/api/xray/testOutbounds","method":"post"},{"path":"/panel/api/xray/balancerStatus","method":"post"},{"path":"/panel/api/xray/balancerOverride","method":"post"},{"path":"/panel/api/xray/routeTest","method":"post"},{"path":"/panel/api/xray/geodata/files","method":"get"},{"path":"/panel/api/xray/geodata/categories","method":"get"},{"path":"/panel/api/xray/geodata/entries","method":"get"},{"path":"/panel/api/xray/geodata/validate","method":"post"},{"path":"/panel/api/xray/outbound-subs","method":"get"},{"path":"/panel/api/xray/outbound-subs","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}","method":"delete"},{"path":"/panel/api/xray/outbound-subs/{id}/del","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/refresh","method":"post"},{"path":"/panel/api/xray/outbound-subs/{id}/move","method":"post"},{"path":"/panel/api/xray/outbound-subs/parse","method":"post"}]} showTitle />
</> </>
); );
} }
+2
View File
@@ -241,6 +241,8 @@ function proxyOutbound(c: SubClient): Record<string, unknown> {
}; };
} }
// Mirrors the one-document-per-client model only; the panel also emits
// balancer documents (sub_balancers) that are intentionally out of scope here.
function jsonConfig(c: SubClient): Record<string, unknown> { function jsonConfig(c: SubClient): Record<string, unknown> {
return { return {
remarks: c.remark, remarks: c.remark,
+2778 -603
View File
File diff suppressed because it is too large Load Diff
+351
View File
@@ -241,6 +241,9 @@
"subJsonMux": { "subJsonMux": {
"type": "string" "type": "string"
}, },
"subJsonObservatory": {
"type": "string"
},
"subJsonPath": { "subJsonPath": {
"type": "string" "type": "string"
}, },
@@ -438,6 +441,7 @@
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
"subJsonObservatory",
"subJsonPath", "subJsonPath",
"subJsonRules", "subJsonRules",
"subJsonURI", "subJsonURI",
@@ -716,6 +720,9 @@
"subJsonMux": { "subJsonMux": {
"type": "string" "type": "string"
}, },
"subJsonObservatory": {
"type": "string"
},
"subJsonPath": { "subJsonPath": {
"type": "string" "type": "string"
}, },
@@ -920,6 +927,7 @@
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
"subJsonObservatory",
"subJsonPath", "subJsonPath",
"subJsonRules", "subJsonRules",
"subJsonURI", "subJsonURI",
@@ -3091,6 +3099,71 @@
], ],
"type": "object" "type": "object"
}, },
"SubBalancer": {
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
"properties": {
"createdAt": {
"example": 1710000000000,
"format": "int64",
"type": "integer"
},
"enabled": {
"description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
"example": true,
"type": "boolean"
},
"id": {
"example": 1,
"type": "integer"
},
"inboundIds": {
"example": [
1,
3
],
"items": {
"type": "integer"
},
"type": "array"
},
"remark": {
"example": "auto-fastest",
"maxLength": 256,
"type": "string"
},
"sortOrder": {
"example": 1,
"minimum": 1,
"type": "integer"
},
"strategy": {
"enum": [
"leastLoad",
"leastPing",
"random",
"roundRobin"
],
"example": "random",
"type": "string"
},
"updatedAt": {
"example": 1710000000000,
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"enabled",
"id",
"inboundIds",
"remark",
"sortOrder",
"strategy",
"updatedAt"
],
"type": "object"
},
"User": { "User": {
"description": "User represents a user account in the 3x-ui panel.", "description": "User represents a user account in the 3x-ui panel.",
"properties": { "properties": {
@@ -3162,6 +3235,10 @@
"name": "Xray Settings", "name": "Xray Settings",
"description": "Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray." "description": "Xray configuration template, outbound management, Warp/Nord/PIA integration, and config testing. All endpoints under /panel/api/xray."
}, },
{
"name": "Subscription Balancers",
"description": "Client-side balancers for the JSON subscription: each enabled balancer is emitted as one extra config document whose members are the proxy outbounds of the selected inbounds (routing.balancers + burstObservatory). Managed in Settings → Sub Balancers."
},
{ {
"name": "Subscription Server", "name": "Subscription Server",
"description": "A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info." "description": "A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info."
@@ -11930,6 +12007,280 @@
} }
} }
}, },
"/panel/api/sub-balancers": {
"get": {
"tags": [
"Subscription Balancers"
],
"summary": "List all subscription balancers in sort order (sort_order asc, id asc).",
"operationId": "get_panel_api_sub_balancers",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SubBalancer"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
}
]
}
}
}
}
}
},
"post": {
"tags": [
"Subscription Balancers"
],
"summary": "Create a subscription balancer. It appears in the JSON subscription of every client that sits on at least one selected inbound.",
"operationId": "post_panel_api_sub_balancers",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/SubBalancer"
}
}
},
"example": {
"success": true,
"obj": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
}
}
}
}
}
}
}
},
"/panel/api/sub-balancers/{id}": {
"post": {
"tags": [
"Subscription Balancers"
],
"summary": "Update a balancer by id. Accepts the same form fields as create (full-row update, including the enabled toggle).",
"operationId": "post_panel_api_sub_balancers_id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Balancer id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/SubBalancer"
}
}
},
"example": {
"success": true,
"obj": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
}
}
}
}
}
}
},
"delete": {
"tags": [
"Subscription Balancers"
],
"summary": "Delete a balancer by id.",
"operationId": "delete_panel_api_sub_balancers_id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Balancer id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/SubBalancer"
}
}
},
"example": {
"success": true,
"obj": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
}
}
}
}
}
}
}
},
"/panel/api/sub-balancers/{id}/del": {
"post": {
"tags": [
"Subscription Balancers"
],
"summary": "Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).",
"operationId": "post_panel_api_sub_balancers_id_del",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Balancer id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/SubBalancer"
}
}
},
"example": {
"success": true,
"obj": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
}
}
}
}
}
}
}
},
"/{subPath}{subid}": { "/{subPath}{subid}": {
"get": { "get": {
"tags": [ "tags": [
@@ -0,0 +1,41 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import type { SubBalancerFormValues } from '@/schemas/subBalancer';
// Deliberately urlencoded (no JSON headers): the Go side binds inboundIds from
// repeated form keys, which is exactly how HttpUtil encodes arrays.
export function useSubBalancerMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subBalancers.root() });
const createMut = useMutation({
mutationFn: (payload: SubBalancerFormValues) =>
HttpUtil.post('/panel/api/sub-balancers', payload),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) =>
HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const removeMut = useMutation({
mutationFn: (id: number) => HttpUtil.post(`/panel/api/sub-balancers/${id}/del`),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
return {
create: (payload: SubBalancerFormValues) => createMut.mutateAsync(payload),
update: (id: number, payload: SubBalancerFormValues) => updateMut.mutateAsync({ id, payload }),
remove: (id: number) => removeMut.mutateAsync(id),
};
}
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import { SubBalancerListSchema, type SubBalancer } from '@/schemas/subBalancer';
async function fetchSubBalancers(): Promise<SubBalancer[]> {
const msg = await HttpUtil.get('/panel/api/sub-balancers', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch subscription balancers');
const validated = parseMsg(msg, SubBalancerListSchema, 'sub-balancers');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useSubBalancersQuery() {
const query = useQuery({
queryKey: keys.subBalancers.list(),
queryFn: fetchSubBalancers,
});
const balancers = useMemo(() => query.data ?? [], [query.data]);
return {
balancers,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}
+4
View File
@@ -13,6 +13,10 @@ export const keys = {
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const, byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const, tags: () => ['hosts', 'tags'] as const,
}, },
subBalancers: {
root: () => ['sub-balancers'] as const,
list: () => ['sub-balancers', 'list'] as const,
},
settings: { settings: {
root: () => ['settings'] as const, root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const, all: () => ['settings', 'all'] as const,
+15
View File
@@ -66,6 +66,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subJsonEnable": false, "subJsonEnable": false,
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "", "subJsonPath": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonURI": "", "subJsonURI": "",
@@ -179,6 +180,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subJsonEnable": false, "subJsonEnable": false,
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "", "subJsonPath": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonURI": "", "subJsonURI": "",
@@ -728,6 +730,19 @@ export const EXAMPLES: Record<string, unknown> = {
"key": "", "key": "",
"value": "" "value": ""
}, },
"SubBalancer": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
},
"User": { "User": {
"id": 0, "id": 0,
"password": "", "password": "",
+73
View File
@@ -215,6 +215,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonMux": { "subJsonMux": {
"type": "string" "type": "string"
}, },
"subJsonObservatory": {
"type": "string"
},
"subJsonPath": { "subJsonPath": {
"type": "string" "type": "string"
}, },
@@ -412,6 +415,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
"subJsonObservatory",
"subJsonPath", "subJsonPath",
"subJsonRules", "subJsonRules",
"subJsonURI", "subJsonURI",
@@ -690,6 +694,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonMux": { "subJsonMux": {
"type": "string" "type": "string"
}, },
"subJsonObservatory": {
"type": "string"
},
"subJsonPath": { "subJsonPath": {
"type": "string" "type": "string"
}, },
@@ -894,6 +901,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonEnable", "subJsonEnable",
"subJsonFinalMask", "subJsonFinalMask",
"subJsonMux", "subJsonMux",
"subJsonObservatory",
"subJsonPath", "subJsonPath",
"subJsonRules", "subJsonRules",
"subJsonURI", "subJsonURI",
@@ -3065,6 +3073,71 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"SubBalancer": {
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
"properties": {
"createdAt": {
"example": 1710000000000,
"format": "int64",
"type": "integer"
},
"enabled": {
"description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
"example": true,
"type": "boolean"
},
"id": {
"example": 1,
"type": "integer"
},
"inboundIds": {
"example": [
1,
3
],
"items": {
"type": "integer"
},
"type": "array"
},
"remark": {
"example": "auto-fastest",
"maxLength": 256,
"type": "string"
},
"sortOrder": {
"example": 1,
"minimum": 1,
"type": "integer"
},
"strategy": {
"enum": [
"leastLoad",
"leastPing",
"random",
"roundRobin"
],
"example": "random",
"type": "string"
},
"updatedAt": {
"example": 1710000000000,
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"enabled",
"id",
"inboundIds",
"remark",
"sortOrder",
"strategy",
"updatedAt"
],
"type": "object"
},
"User": { "User": {
"description": "User represents a user account in the 3x-ui panel.", "description": "User represents a user account in the 3x-ui panel.",
"properties": { "properties": {
+13
View File
@@ -74,6 +74,7 @@ export interface AllSetting {
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFinalMask: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
@@ -188,6 +189,7 @@ export interface AllSettingView {
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFinalMask: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
@@ -698,6 +700,17 @@ export interface Setting {
value: string; value: string;
} }
export interface SubBalancer {
createdAt: number;
enabled: boolean;
id: number;
inboundIds: number[];
remark: string;
sortOrder: number;
strategy: string;
updatedAt: number;
}
export interface User { export interface User {
id: number; id: number;
password: string; password: string;
+14
View File
@@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
@@ -205,6 +206,7 @@ export const AllSettingViewSchema = z.object({
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
@@ -746,6 +748,18 @@ export const SettingSchema = z.object({
}); });
export type Setting = z.infer<typeof SettingSchema>; export type Setting = z.infer<typeof SettingSchema>;
export const SubBalancerSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
inboundIds: z.array(z.number().int()),
remark: z.string().max(256),
sortOrder: z.number().int().min(1),
strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']),
updatedAt: z.number().int(),
});
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
export const UserSchema = z.object({ export const UserSchema = z.object({
id: z.number().int(), id: z.number().int(),
password: z.string(), password: z.string(),
+10 -1
View File
@@ -6,6 +6,7 @@ import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
ApiOutlined, ApiOutlined,
ApartmentOutlined,
CloseOutlined, CloseOutlined,
CloudServerOutlined, CloudServerOutlined,
ClusterOutlined, ClusterOutlined,
@@ -177,6 +178,7 @@ export default function AppSidebar() {
const { pathname, hash } = useLocation(); const { pathname, hash } = useLocation();
const { allSetting } = useAllSettings(); const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable); const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const showSubBalancers = !!allSetting.subJsonEnable;
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts); const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned); const [pinned, setPinned] = useState(readSidebarPinned);
@@ -262,8 +264,15 @@ export default function AppSidebar() {
label: t('menu.subFormats'), label: t('menu.subFormats'),
}); });
} }
if (showSubBalancers) {
children.push({
key: '/settings#subscription-balancers',
icon: <ApartmentOutlined />,
label: t('pages.settings.subBalancers.menu'),
});
}
return children; return children;
}, [t, showSubFormats]); }, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>( const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [ () => [
+1
View File
@@ -66,6 +66,7 @@ export class AllSetting {
subJsonMux = ''; subJsonMux = '';
subJsonRules = ''; subJsonRules = '';
subJsonFinalMask = ''; subJsonFinalMask = '';
subJsonObservatory = '';
subThemeDir = ''; subThemeDir = '';
subHideSettings = false; subHideSettings = false;
+78
View File
@@ -2189,6 +2189,84 @@ export const sections: readonly Section[] = [
], ],
}, },
{
id: 'sub-balancers',
title: 'Subscription Balancers',
description:
'Client-side balancers for the JSON subscription: each enabled balancer is emitted as one extra config document whose members are the proxy outbounds of the selected inbounds (routing.balancers + burstObservatory). Managed in Settings → Sub Balancers.',
endpoints: [
{
method: 'GET',
path: '/panel/api/sub-balancers',
summary: 'List all subscription balancers in sort order (sort_order asc, id asc).',
responseSchema: 'SubBalancer',
responseSchemaArray: true,
},
{
method: 'POST',
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: '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.',
},
],
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).',
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
responseSchema: 'SubBalancer',
},
{
method: 'DELETE',
path: '/panel/api/sub-balancers/:id',
summary: 'Delete a balancer by id.',
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
responseSchema: 'SubBalancer',
},
{
method: 'POST',
path: '/panel/api/sub-balancers/:id/del',
summary:
'Delete a balancer by id (POST alias of DELETE for clients that cannot send DELETE).',
params: [{ name: 'id', in: 'path', type: 'integer', desc: 'Balancer id.' }],
responseSchema: 'SubBalancer',
},
],
},
{ {
id: 'subscription', id: 'subscription',
title: 'Subscription Server', title: 'Subscription Server',
@@ -29,6 +29,7 @@ import TelegramTab from './TelegramTab';
import EmailTab from './EmailTab'; import EmailTab from './EmailTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab'; import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab'; import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
import './SettingsPage.css'; import './SettingsPage.css';
interface ApiMsg { interface ApiMsg {
@@ -42,6 +43,7 @@ const tabSlugs = [
'email', 'email',
'subscription', 'subscription',
'subscription-formats', 'subscription-formats',
'subscription-balancers',
]; ];
function isIp(h: string): boolean { function isIp(h: string): boolean {
@@ -219,6 +221,8 @@ export default function SettingsPage() {
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />; return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats': case 'subscription-formats':
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />; return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-balancers':
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
default: default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />; return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
} }
@@ -0,0 +1,172 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { formatInboundLabel } from '@/lib/inbounds/label';
import {
SubBalancerFormSchema,
SubBalancerStrategySchema,
type SubBalancer,
type SubBalancerFormValues,
type SubBalancerStrategy,
} from '@/schemas/subBalancer';
// The JSON subscription only builds proxy outbounds for these protocols;
// mtproto has no proxy-outbound case, so it is excluded from balancer members.
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks',
'vless',
'vmess',
'trojan',
'hysteria',
'wireguard',
]);
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
leastPing: 'pages.settings.subBalancers.strategyLeastPing',
random: 'pages.settings.subBalancers.strategyRandom',
roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
};
function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
return {
remark: balancer?.remark ?? '',
strategy: balancer?.strategy ?? 'random',
inboundIds: [...(balancer?.inboundIds ?? [])],
sortOrder: balancer?.sortOrder ?? 1,
enabled: balancer?.enabled ?? true,
};
}
interface SubBalancerFormModalProps {
open: boolean;
balancer: SubBalancer | null;
onClose: () => void;
onConfirm: (values: SubBalancerFormValues) => void;
}
export default function SubBalancerFormModal({
open,
balancer,
onClose,
onConfirm,
}: SubBalancerFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
const isEdit = balancer != null;
useEffect(() => {
if (open) methods.reset(initialState(balancer));
}, [open, balancer, methods]);
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
const { data: inboundOptionsRaw } = useInboundOptions();
const inboundOptions = useMemo(
() =>
(inboundOptionsRaw ?? [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
.map((ib) => ({
label: formatInboundLabel(ib.tag, ib.remark),
value: ib.id,
title: formatInboundLabel(ib.tag, ib.remark),
})),
[inboundOptionsRaw, inboundIds],
);
function onFinish(values: SubBalancerFormValues) {
const parsed = SubBalancerFormSchema.safeParse(values);
if (!parsed.success) {
messageApi.error(
t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
);
return;
}
onConfirm(parsed.data);
}
const strategies = SubBalancerStrategySchema.options.map((value) => ({
value,
label: t(STRATEGY_LABEL_KEYS[value]),
}));
return (
<Modal
open={open}
title={
isEdit
? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
: `+ ${t('pages.settings.subBalancers.add')}`
}
okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
cancelText={t('close')}
mask={{ closable: false }}
width="640px"
onOk={methods.handleSubmit(onFinish)}
onCancel={onClose}
>
{messageContextHolder}
<FormProvider {...methods}>
<Form layout="vertical">
<FormField
label={t('pages.settings.subBalancers.remark')}
name="remark"
required
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
>
<Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
</FormField>
<FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
<Select options={strategies} />
</FormField>
<FormField
label={t('pages.settings.subBalancers.sortOrder')}
name="sortOrder"
required
tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
>
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.settings.subBalancers.inbounds')}
name="inboundIds"
required
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
>
<Select
mode="multiple"
options={inboundOptions}
maxTagCount="responsive"
listHeight={220}
showSearch={{ optionFilterProp: 'label' }}
/>
</FormField>
<SelectAllClearButtons
options={inboundOptions}
value={inboundIds || []}
onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
/>
<FormField
label={t('pages.settings.subBalancers.enabled')}
name="enabled"
valueProp="checked"
>
<Switch />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
@@ -0,0 +1,359 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Input,
InputNumber,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
} from 'antd';
import {
DeleteOutlined,
DeploymentUnitOutlined,
EditOutlined,
PlusOutlined,
RadarChartOutlined,
} from '@ant-design/icons';
import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { AllSetting } from '@/models/setting';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
import SubBalancerFormModal from './SubBalancerFormModal';
import { catTabLabel } from './catTabLabel';
import './SubscriptionFormatsTab.css';
const STRATEGY_COLORS: Record<string, string> = {
leastLoad: 'geekblue',
leastPing: 'green',
random: 'orange',
roundRobin: 'purple',
};
// Single source for the burst-observatory ping defaults: the Zod schema and
// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
function parsePingConfig(raw: string): PingConfigObject {
try {
return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
} catch {
return DEFAULT_PING_CONFIG;
}
}
interface SubscriptionBalancersTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
export default function SubscriptionBalancersTab({
allSetting,
updateSetting,
}: SubscriptionBalancersTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
const { create, update, remove } = useSubBalancerMutations();
const { data: inboundOptionsRaw } = useInboundOptions();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<SubBalancer | null>(null);
const inboundLabels = useMemo(() => {
const map = new Map<number, string>();
for (const ib of inboundOptionsRaw ?? []) {
map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
}
return map;
}, [inboundOptionsRaw]);
async function onConfirm(values: SubBalancerFormValues) {
const msg = editing ? await update(editing.id, values) : await create(values);
if (msg?.success) setModalOpen(false);
}
async function toggleEnabled(balancer: SubBalancer) {
await update(balancer.id, {
remark: balancer.remark,
strategy: balancer.strategy,
inboundIds: balancer.inboundIds,
sortOrder: balancer.sortOrder,
enabled: !balancer.enabled,
});
}
const observatoryEnabled = allSetting.subJsonObservatory !== '';
const observatoryObj = useMemo(
() => parsePingConfig(allSetting.subJsonObservatory),
[allSetting.subJsonObservatory],
);
function setObservatoryEnabled(v: boolean) {
updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
}
function setObservatoryField<K extends keyof PingConfigObject>(
key: K,
value: PingConfigObject[K],
) {
const next = { ...observatoryObj, [key]: value };
updateSetting({ subJsonObservatory: JSON.stringify(next) });
}
const columns = [
{
title: t('pages.settings.subBalancers.sortOrder'),
dataIndex: 'sortOrder',
key: 'sortOrder',
width: 80,
align: 'center' as const,
},
{
title: t('pages.settings.subBalancers.remark'),
dataIndex: 'remark',
key: 'remark',
},
{
title: t('pages.settings.subBalancers.strategy'),
dataIndex: 'strategy',
key: 'strategy',
width: 120,
render: (strategy: string) => (
<Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
),
},
{
title: t('pages.settings.subBalancers.inbounds'),
key: 'inbounds',
render: (_: unknown, r: SubBalancer) => {
const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
return (
<Tooltip title={labels.join(', ')}>
<span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
</Tooltip>
);
},
},
{
title: t('pages.settings.subBalancers.enabled'),
dataIndex: 'enabled',
key: 'enabled',
width: 80,
align: 'center' as const,
render: (_: unknown, r: SubBalancer) => (
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
),
},
{
title: '',
key: 'actions',
width: 96,
render: (_: unknown, r: SubBalancer) => (
<Space>
<Button
aria-label={t('edit')}
size="small"
icon={<EditOutlined />}
title={t('edit')}
onClick={() => {
setEditing(r);
setModalOpen(true);
}}
/>
<Popconfirm
title={t('pages.settings.subBalancers.deleteConfirm')}
okText={t('delete')}
cancelText={t('cancel')}
onConfirm={() => remove(r.id)}
>
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const balancersTab = (
<div>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.settings.subBalancers.desc')}
/>
{fetchError && (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
title={fetchError}
action={
<Button size="small" onClick={() => refetch()}>
{t('refresh')}
</Button>
}
/>
)}
<div style={{ marginBottom: 12 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
setModalOpen(true);
}}
>
{t('pages.settings.subBalancers.add')}
</Button>
</div>
<Table
size="small"
dataSource={balancers}
rowKey={(r) => r.id}
pagination={false}
loading={loading && !fetched}
scroll={{ x: true }}
locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
columns={columns}
/>
</div>
);
const observatoryTab = (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.settings.subBalancers.observatory.note')}
/>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.title')}
description={t('pages.settings.subBalancers.observatory.desc')}
>
<Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
</SettingListItem>
{observatoryEnabled && (
<div className="format-settings">
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.destination')}
description={t('pages.settings.subBalancers.observatory.destinationDesc')}
>
<Input
value={observatoryObj.destination}
placeholder="https://www.google.com/generate_204"
onChange={(e) => setObservatoryField('destination', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.connectivity')}
description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
>
<Input
value={observatoryObj.connectivity}
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
onChange={(e) => setObservatoryField('connectivity', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.interval')}
description={t('pages.settings.subBalancers.observatory.intervalDesc')}
>
<Input
value={observatoryObj.interval}
placeholder="1m"
onChange={(e) => setObservatoryField('interval', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.timeout')}
description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
>
<Input
value={observatoryObj.timeout}
placeholder="5s"
onChange={(e) => setObservatoryField('timeout', e.target.value)}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.sampling')}
description={t('pages.settings.subBalancers.observatory.samplingDesc')}
>
<InputNumber
value={observatoryObj.sampling}
min={1}
style={{ width: '100%' }}
onChange={onNumber((v) => setObservatoryField('sampling', v))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subBalancers.observatory.httpMethod')}
description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
>
<Select
value={observatoryObj.httpMethod}
style={{ width: '100%' }}
onChange={(v) => setObservatoryField('httpMethod', v)}
options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
/>
</SettingListItem>
</div>
)}
</>
);
return (
<>
<Tabs
defaultActiveKey="balancers"
items={[
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.settings.subBalancers.tabBalancers'),
isMobile,
),
children: balancersTab,
},
{
key: 'observatory',
label: catTabLabel(
<RadarChartOutlined />,
t('pages.settings.subBalancers.tabObservatory'),
isMobile,
),
children: observatoryTab,
},
]}
/>
<SubBalancerFormModal
open={modalOpen}
balancer={editing}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}
@@ -13,7 +13,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
pingConfig: { pingConfig: {
destination: 'https://www.google.com/generate_204', destination: 'https://www.google.com/generate_204',
interval: '1m', interval: '1m',
connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204', connectivity: '',
timeout: '5s', timeout: '5s',
sampling: 2, sampling: 2,
httpMethod: 'HEAD', httpMethod: 'HEAD',
+1 -1
View File
@@ -16,7 +16,7 @@ export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
export const PingConfigSchema = z export const PingConfigSchema = z
.object({ .object({
destination: z.string().default('https://www.google.com/generate_204'), destination: z.string().default('https://www.google.com/generate_204'),
connectivity: z.string().default('http://connectivitycheck.platform.hicloud.com/generate_204'), connectivity: z.string().default(''),
interval: z.string().default('1m'), interval: z.string().default('1m'),
timeout: z.string().default('5s'), timeout: z.string().default('5s'),
sampling: z.number().int().min(1).default(2), sampling: z.number().int().min(1).default(2),
+1
View File
@@ -72,6 +72,7 @@ export const AllSettingSchema = z
subJsonMux: z.string().optional(), subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(), subJsonRules: z.string().optional(),
subJsonFinalMask: z.string().optional(), subJsonFinalMask: z.string().optional(),
subJsonObservatory: z.string().optional(),
subHideSettings: z.boolean().optional(), subHideSettings: z.boolean().optional(),
timeLocation: z.string().optional(), timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(), ldapEnable: z.boolean().optional(),
+36
View File
@@ -0,0 +1,36 @@
import { z } from 'zod';
export const SubBalancerStrategySchema = z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']);
export type SubBalancerStrategy = z.infer<typeof SubBalancerStrategySchema>;
export const SubBalancerSchema = z.object({
id: z.number(),
remark: z.string(),
strategy: SubBalancerStrategySchema,
inboundIds: z.array(z.number()),
sortOrder: z.number(),
enabled: z.boolean(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
});
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
export const SubBalancerListSchema = z.array(SubBalancerSchema);
export const SubBalancerFormSchema = z.object({
remark: z
.string()
.trim()
.min(1, 'pages.settings.subBalancers.errRemarkRequired')
.max(256, 'pages.settings.subBalancers.errRemarkRequired'),
strategy: SubBalancerStrategySchema,
inboundIds: z
.array(z.number().int().positive())
.min(1, 'pages.settings.subBalancers.errInboundsRequired'),
sortOrder: z
.number({ message: 'pages.settings.subBalancers.errSortOrder' })
.int('pages.settings.subBalancers.errSortOrder')
.min(1, 'pages.settings.subBalancers.errSortOrder'),
enabled: z.boolean(),
});
export type SubBalancerFormValues = z.infer<typeof SubBalancerFormSchema>;
@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react';
import SubBalancerFormModal from '@/pages/settings/SubBalancerFormModal';
import type { SubBalancer } from '@/schemas/subBalancer';
import { renderWithProviders } from './test-utils';
vi.mock('@/api/queries/useInboundOptions', () => ({
useInboundOptions: () => ({
data: [
{ id: 1, tag: 'inb-vless', remark: 'First', protocol: 'vless', port: 443, enable: true },
{ id: 2, tag: 'inb-ws', remark: 'Second', protocol: 'vmess', port: 8443, enable: true },
{ id: 3, tag: 'inb-off', remark: 'Disabled', protocol: 'vless', port: 8080, enable: false },
],
isLoading: false,
}),
}));
function renderModal(balancer: SubBalancer | null, onConfirm = vi.fn()) {
renderWithProviders(
<SubBalancerFormModal open balancer={balancer} onClose={() => {}} onConfirm={onConfirm} />,
);
return { onConfirm };
}
function primaryButton(): HTMLElement {
const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
if (!btn) throw new Error('Primary button not found');
return btn as HTMLElement;
}
function erroredItemCount(): number {
return document.querySelectorAll('.ant-form-item-has-error').length;
}
function remarkInput(): HTMLInputElement {
const el = Array.from(document.querySelectorAll('.ant-modal input')).find((i) =>
(i as HTMLInputElement).placeholder.includes('Auto'),
);
if (!el) throw new Error('Remark input not found');
return el as HTMLInputElement;
}
function inboundOptionTitles(): string[] {
const multi = document.querySelector('.ant-select-multiple');
if (!multi) throw new Error('Inbound multi-select not found');
fireEvent.mouseDown(multi as HTMLElement);
return Array.from(document.querySelectorAll('.ant-select-item-option')).map((o) =>
(o.getAttribute('title') ?? o.textContent ?? '').trim(),
);
}
function selectInbound(optionTitle: string) {
const multi = document.querySelector('.ant-select-multiple');
if (!multi) throw new Error('Inbound multi-select not found');
// AntD 6 multiple selects have no .ant-select-selector; mousedown on the
// root toggles the dropdown.
fireEvent.mouseDown(multi as HTMLElement);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === optionTitle,
);
if (!option) throw new Error(`Option '${optionTitle}' not found`);
fireEvent.click(option);
fireEvent.keyDown(multi, { key: 'Escape' });
}
describe('SubBalancerFormModal', () => {
it('shows no validation errors when freshly opened in add mode', () => {
renderModal(null);
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(erroredItemCount()).toBe(0);
expect(primaryButton().hasAttribute('disabled')).toBe(false);
});
it('reveals required-field errors after a save attempt, without confirming', async () => {
const { onConfirm } = renderModal(null);
fireEvent.click(primaryButton());
await waitFor(() => expect(erroredItemCount()).toBe(2));
expect(onConfirm).not.toHaveBeenCalled();
});
it('confirms with parsed values once remark and an inbound are set', async () => {
const { onConfirm } = renderModal(null);
fireEvent.change(remarkInput(), { target: { value: ' auto ' } });
selectInbound('First');
fireEvent.click(primaryButton());
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(onConfirm).toHaveBeenCalledWith({
remark: 'auto',
strategy: 'random',
inboundIds: [1],
sortOrder: 1,
enabled: true,
});
});
it('seeds the form from the edited balancer', async () => {
const { onConfirm } = renderModal({
id: 7,
remark: 'existing',
strategy: 'leastPing',
inboundIds: [2],
sortOrder: 3,
enabled: false,
});
expect(remarkInput().value).toBe('existing');
fireEvent.click(primaryButton());
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(onConfirm).toHaveBeenCalledWith({
remark: 'existing',
strategy: 'leastPing',
inboundIds: [2],
sortOrder: 3,
enabled: false,
});
});
// A disabled member is dropped by the sub server, so offering it here would
// silently stop the balancer document from being emitted (#5645).
it('hides disabled inbounds from the member picker', () => {
renderModal(null);
expect(inboundOptionTitles()).toEqual(['First', 'Second']);
});
it('keeps an already-selected disabled inbound visible when editing', () => {
renderModal({
id: 8,
remark: 'existing',
strategy: 'random',
inboundIds: [3],
sortOrder: 1,
enabled: true,
});
expect(inboundOptionTitles()).toContain('Disabled');
});
});
+1
View File
@@ -84,6 +84,7 @@ func allModels() []any {
&model.NodeClientIp{}, &model.NodeClientIp{},
&model.ClientGlobalTraffic{}, &model.ClientGlobalTraffic{},
&model.OutboundSubscription{}, &model.OutboundSubscription{},
&model.SubBalancer{},
} }
} }
+1
View File
@@ -57,6 +57,7 @@ func migrationModels() []any {
&model.NodeClientIp{}, &model.NodeClientIp{},
&model.ClientGlobalTraffic{}, &model.ClientGlobalTraffic{},
&model.OutboundSubscription{}, &model.OutboundSubscription{},
&model.SubBalancer{},
} }
} }
+15
View File
@@ -1227,6 +1227,21 @@ type OutboundSubscription struct {
OutboundCount int `json:"outboundCount" gorm:"-"` OutboundCount int `json:"outboundCount" gorm:"-"`
} }
// SubBalancer is one extra JSON-subscription config document whose members are
// the selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.
type SubBalancer struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
Remark string `json:"remark" form:"remark" validate:"required,max=256" example:"auto-fastest"`
Strategy string `json:"strategy" form:"strategy" validate:"omitempty,oneof=leastLoad leastPing random roundRobin" example:"random"`
InboundIds []int `json:"inboundIds" form:"inboundIds" gorm:"serializer:json;column:inbound_ids" example:"[1,3]"`
SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"column:sort_order" validate:"omitempty,gte=1" example:"1"`
// No gorm default:true — a bool default makes an explicit false at insert
// collapse back to the column default (zero value is skipped).
Enabled bool `json:"enabled" form:"enabled" example:"true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1710000000000"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1710000000000"`
}
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict { func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
var conflicts []ClientMergeConflict var conflicts []ClientMergeConflict
keep := func(field string, oldV, newV, kept any) { keep := func(field string, oldV, newV, kept any) {
+8 -1
View File
@@ -99,6 +99,7 @@ type subControllerConfig struct {
subJsonMux string subJsonMux string
subJsonRules string subJsonRules string
subJsonFinalMask string subJsonFinalMask string
subJsonObservatory string
subClashEnableRouting bool subClashEnableRouting bool
subClashRules string subClashRules string
@@ -180,6 +181,10 @@ func WithSUBJsonFinalMask(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonFinalMask = value } return func(config *subControllerConfig) { config.subJsonFinalMask = value }
} }
func WithSUBJsonObservatory(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonObservatory = value }
}
func WithSUBClashEnableRouting(value bool) SUBControllerOption { func WithSUBClashEnableRouting(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashEnableRouting = value } return func(config *subControllerConfig) { config.subClashEnableRouting = value }
} }
@@ -243,6 +248,8 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
} }
sub := NewSubService(config.remarkTemplate) sub := NewSubService(config.remarkTemplate)
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub)
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
a := &SUBController{ a := &SUBController{
subTitle: config.subTitle, subTitle: config.subTitle,
subSupportUrl: config.subSupportURL, subSupportUrl: config.subSupportURL,
@@ -269,7 +276,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
updateInterval: config.updateInterval, updateInterval: config.updateInterval,
subService: sub, subService: sub,
subJsonService: NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, sub), subJsonService: subJsonSvc,
subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub), subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub),
subTemplateCache: map[string]*cachedSubTemplate{}, subTemplateCache: map[string]*cachedSubTemplate{},
+304 -2
View File
@@ -5,9 +5,15 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps" "maps"
"net/url"
"slices"
"sort"
"strings" "strings"
"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/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/random" "github.com/mhsanaei/3x-ui/v3/internal/util/random"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
@@ -22,6 +28,7 @@ type SubJsonService struct {
defaultOutbounds []json_util.RawMessage defaultOutbounds []json_util.RawMessage
finalMask string finalMask string
mux string mux string
observatory subBalancerObservatoryConfig
SubService *SubService SubService *SubService
} }
@@ -53,6 +60,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, subService *S
defaultOutbounds: defaultOutbounds, defaultOutbounds: defaultOutbounds,
finalMask: finalMask, finalMask: finalMask,
mux: mux, mux: mux,
observatory: defaultSubBalancerObservatoryConfig(),
SubService: subService, SubService: subService,
} }
} }
@@ -74,9 +82,9 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
} }
var header string var header string
var configArray []json_util.RawMessage
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
entries := make([]subConfigEntry, 0, len(inbounds))
// Prepare Inbounds // Prepare Inbounds
for _, inbound := range inbounds { for _, inbound := range inbounds {
clients := subReq.matchingClients(inbound, subId) clients := subReq.matchingClients(inbound, subId)
@@ -88,10 +96,35 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
injectExternalProxy(inbound, hostEps) injectExternalProxy(inbound, hostEps)
} }
var inboundConfigs []json_util.RawMessage
for _, client := range clients { for _, client := range clients {
seenEmails[client.Email] = struct{}{} seenEmails[client.Email] = struct{}{}
configArray = append(configArray, s.getConfig(subReq, inbound, client, host)...) inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
} }
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
sortIndex: inbound.SubSortIndex,
id: inbound.Id,
configs: inboundConfigs,
})
}
}
entries = s.appendBalancerEntries(entries)
// Inbounds arrive sorted by (sub_sort_index, id); balancers interleave by
// the same key and, on an equal number, follow the inbound group.
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].sortIndex != entries[j].sortIndex {
return entries[i].sortIndex < entries[j].sortIndex
}
if entries[i].kind != entries[j].kind {
return entries[i].kind < entries[j].kind
}
return entries[i].id < entries[j].id
})
var configArray []json_util.RawMessage
for _, entry := range entries {
configArray = append(configArray, entry.configs...)
} }
for _, ext := range externalLinks { for _, ext := range externalLinks {
for _, el := range expandEntry(ext) { for _, el := range expandEntry(ext) {
@@ -136,6 +169,275 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
return string(finalJson), header, nil return string(finalJson), header, nil
} }
// subConfigEntry is one ordered block of the JSON subscription: an inbound's
// configs (kind 0) or a balancer config (kind 1).
type subConfigEntry struct {
sortIndex int
kind int
id int
configs []json_util.RawMessage
}
const (
subBalancerTag = "balancer"
subBalancerProbeURL = "https://www.google.com/generate_204"
)
// subBalancerObservatoryConfig is the panel-wide burstObservatory ping config
// emitted into every client-side balancer doc (subJsonObservatory setting).
type subBalancerObservatoryConfig struct {
Destination string `json:"destination"`
Connectivity string `json:"connectivity"`
Interval string `json:"interval"`
Sampling int `json:"sampling"`
Timeout string `json:"timeout"`
HTTPMethod string `json:"httpMethod"`
}
func defaultSubBalancerObservatoryConfig() subBalancerObservatoryConfig {
return subBalancerObservatoryConfig{
Destination: subBalancerProbeURL,
Connectivity: "",
Interval: "1m",
Sampling: 2,
Timeout: "5s",
HTTPMethod: "HEAD",
}
}
// SetObservatoryConfig overrides defaults from the panel JSON setting. An empty
// cfg keeps all defaults; invalid values fall back with a warning, never panic.
func (s *SubJsonService) SetObservatoryConfig(cfg string) {
s.observatory = defaultSubBalancerObservatoryConfig()
if cfg == "" {
return
}
var parsed subBalancerObservatoryConfig
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
logger.Warningf("subJsonObservatory: invalid JSON %q, using defaults: %v", cfg, err)
return
}
if parsed.Destination != "" {
if validProbeURL(parsed.Destination) {
s.observatory.Destination = parsed.Destination
} else {
logger.Warningf("subJsonObservatory: invalid destination %q, keeping default %q", parsed.Destination, s.observatory.Destination)
}
}
if parsed.Connectivity != "" {
if validProbeURL(parsed.Connectivity) {
s.observatory.Connectivity = parsed.Connectivity
} else {
logger.Warningf("subJsonObservatory: invalid connectivity %q, keeping default (skip)", parsed.Connectivity)
}
}
if parsed.Interval != "" {
if _, err := time.ParseDuration(parsed.Interval); err == nil {
s.observatory.Interval = parsed.Interval
} else {
logger.Warningf("subJsonObservatory: invalid interval %q, keeping default %q", parsed.Interval, s.observatory.Interval)
}
}
if parsed.Sampling > 0 {
s.observatory.Sampling = parsed.Sampling
}
if parsed.Timeout != "" {
if _, err := time.ParseDuration(parsed.Timeout); err == nil {
s.observatory.Timeout = parsed.Timeout
} else {
logger.Warningf("subJsonObservatory: invalid timeout %q, keeping default %q", parsed.Timeout, s.observatory.Timeout)
}
}
if parsed.HTTPMethod == "HEAD" || parsed.HTTPMethod == "GET" {
s.observatory.HTTPMethod = parsed.HTTPMethod
}
}
// validProbeURL accepts only absolute http(s) URLs so a malformed probe or
// connectivity value can't slip into the emitted burstObservatory.
func validProbeURL(s string) bool {
u, err := url.Parse(s)
if err != nil || u == nil {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
func (s *SubJsonService) balancerObservatory(prefix string) map[string]any {
o := s.observatory
return map[string]any{
"subjectSelector": []string{prefix},
"pingConfig": map[string]any{
"destination": o.Destination,
"connectivity": o.Connectivity,
"interval": o.Interval,
"sampling": o.Sampling,
"timeout": o.Timeout,
"httpMethod": o.HTTPMethod,
},
}
}
// appendBalancerEntries appends one entry per enabled balancer that has at
// least one member outbound among the inbound entries.
func (s *SubJsonService) appendBalancerEntries(entries []subConfigEntry) []subConfigEntry {
balancers := getEnabledSubBalancers()
if len(balancers) == 0 {
return entries
}
// Pre-pass: pull each inbound doc's proxy outbound once so every balancer
// reuses it instead of re-unmarshalling the whole document per balancer.
entryProxies := make([][]map[string]any, len(entries))
for i, entry := range entries {
if entry.kind != 0 {
continue
}
for _, config := range entry.configs {
if proxy := extractProxyOutbound(config); proxy != nil {
entryProxies[i] = append(entryProxies[i], proxy)
}
}
}
for i := range balancers {
config := s.buildBalancerConfig(&balancers[i], entries, entryProxies)
if config == nil {
continue
}
entries = append(entries, subConfigEntry{
sortIndex: balancers[i].SortOrder,
kind: 1,
id: balancers[i].Id,
configs: []json_util.RawMessage{config},
})
}
return entries
}
// extractProxyOutbound returns the first outbound of a document when it is the
// proxy (tag == "proxy"), else nil — the only member shape a balancer retags.
func extractProxyOutbound(config json_util.RawMessage) map[string]any {
var doc map[string]any
if json.Unmarshal(config, &doc) != nil {
return nil
}
outbounds, _ := doc["outbounds"].([]any)
if len(outbounds) == 0 {
return nil
}
outbound, _ := outbounds[0].(map[string]any)
if outbound == nil || outbound["tag"] != "proxy" {
return nil
}
return outbound
}
func getEnabledSubBalancers() []model.SubBalancer {
var balancers []model.SubBalancer
if err := database.GetDB().Model(&model.SubBalancer{}).
Where("enabled = ?", true).
Order("sort_order asc, id asc").Find(&balancers).Error; err != nil {
logger.Error("SubJsonService - getEnabledSubBalancers:", err)
return nil
}
return balancers
}
// Suffix by proxy protocol, not transport network — a vmess/tcp member used to
// be mislabelled "vless".
func balancerMemberSuffix(protocol string) string {
if protocol == "" {
return "other"
}
return protocol
}
// buildBalancerConfig assembles the balancer profile: members retagged under a
// per-balancer prefix, a routing.balancers entry, and (for leastPing/leastLoad) an observatory.
func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage {
prefix := fmt.Sprintf("bal-%d-", balancer.Id)
usedTags := make(map[string]bool)
var proxies []json_util.RawMessage
var firstTag string
// entryProxies is the pre-extracted proxy outbounds per entry; kind!=0 rows
// have none. Clone before retagging so the cached map stays reusable.
for i, entry := range entries {
if entry.kind != 0 || !slices.Contains(balancer.InboundIds, entry.id) {
continue
}
for _, outbound := range entryProxies[i] {
protocol, _ := outbound["protocol"].(string)
base := prefix + balancerMemberSuffix(protocol)
tag := base
for suffix := 2; usedTags[tag]; suffix++ {
tag = fmt.Sprintf("%s-%d", base, suffix)
}
usedTags[tag] = true
member := maps.Clone(outbound)
member["tag"] = tag
if raw, err := json.MarshalIndent(member, "", " "); err == nil {
if firstTag == "" {
firstTag = tag
}
proxies = append(proxies, raw)
}
}
}
if len(proxies) == 0 {
return nil
}
outbounds := append([]json_util.RawMessage{}, proxies...)
outbounds = append(outbounds, s.defaultOutbounds...)
// The routing subtree in s.configJson is shared by every emitted document;
// clone it (and each rule map) before pointing rules at the balancer.
baseRouting, _ := s.configJson["routing"].(map[string]any)
routing := make(map[string]any, len(baseRouting)+1)
maps.Copy(routing, baseRouting)
baseRules, _ := baseRouting["rules"].([]any)
rules := make([]any, 0, len(baseRules)+1)
for _, rule := range baseRules {
ruleMap, ok := rule.(map[string]any)
if !ok {
rules = append(rules, rule)
continue
}
ruleMap = maps.Clone(ruleMap)
if ruleMap["outboundTag"] == "proxy" {
delete(ruleMap, "outboundTag")
ruleMap["balancerTag"] = subBalancerTag
}
rules = append(rules, ruleMap)
}
routing["rules"] = rules
isObservatory := balancer.Strategy == "leastPing" || balancer.Strategy == "leastLoad"
balancerEntry := map[string]any{
"tag": subBalancerTag,
"selector": []string{prefix},
"strategy": map[string]any{"type": balancer.Strategy},
}
if isObservatory && firstTag != "" {
// With all probes failing, route to the first member instead of
// failing dispatch.
balancerEntry["fallbackTag"] = firstTag
}
routing["balancers"] = []any{balancerEntry}
newConfigJson := make(map[string]any, len(s.configJson)+2)
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = outbounds
newConfigJson["remarks"] = balancer.Remark
newConfigJson["routing"] = routing
// leastPing/leastLoad require a burst observatory (Xray refuses to start
// them without one); fallbackTag above covers the probe-outage case.
if isObservatory {
newConfigJson["burstObservatory"] = s.balancerObservatory(prefix)
}
config, _ := json.MarshalIndent(newConfigJson, "", " ")
return config
}
func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage { func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
var newJsonArray []json_util.RawMessage var newJsonArray []json_util.RawMessage
stream := s.streamData(inbound.StreamSettings, subKey(client)) stream := s.streamData(inbound.StreamSettings, subKey(client))
+6
View File
@@ -155,6 +155,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonFinalMask = "" SubJsonFinalMask = ""
} }
SubJsonObservatory, err := s.settingService.GetSubJsonObservatory()
if err != nil {
SubJsonObservatory = ""
}
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting() SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil { if err != nil {
SubClashEnableRouting = false SubClashEnableRouting = false
@@ -281,6 +286,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBJsonMux(SubJsonMux), WithSUBJsonMux(SubJsonMux),
WithSUBJsonRules(SubJsonRules), WithSUBJsonRules(SubJsonRules),
WithSUBJsonFinalMask(SubJsonFinalMask), WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBJsonObservatory(SubJsonObservatory),
WithSUBClashEnableRouting(SubClashEnableRouting), WithSUBClashEnableRouting(SubClashEnableRouting),
WithSUBClashRules(SubClashRules), WithSUBClashRules(SubClashRules),
WithSUBTitle(SubTitle), WithSUBTitle(SubTitle),
@@ -0,0 +1,60 @@
package sub
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// seedSubProtocolInbound seeds an inbound of the given protocol with one client
// wired into the clients/client_inbounds tables so getInboundsBySubId resolves it.
func seedSubProtocolInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string, protocol model.Protocol) *model.Inbound {
t.Helper()
db := database.GetDB()
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
email := tag + "@e"
settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}]}`, uuid, email, subId)
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
Protocol: protocol, Remark: tag, Settings: settings, StreamSettings: stream,
SubSortIndex: subSortIndex,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", tag, err)
}
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
return ib
}
// The member tag suffix is the inbound's real protocol, not its transport
// network: a vmess/tcp member is tagged bal-N-vmess, not the old bal-N-vless.
func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
seedSubDB(t)
vm := seedSubProtocolInbound(t, "s1", "vm", 4901, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`, model.VMESS)
seedSubBalancer(t, &model.SubBalancer{
Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "proto")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
tags := docOutboundTags(balancerDoc)
if !strings.Contains(strings.Join(tags, ","), "bal-1-vmess") {
t.Fatalf("vmess member tag = %v, want a bal-1-vmess suffix", tags)
}
}
+422
View File
@@ -0,0 +1,422 @@
package sub
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
t.Helper()
if err := database.GetDB().Create(b).Error; err != nil {
t.Fatalf("seed balancer: %v", err)
}
return b
}
func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
t.Helper()
var docs []map[string]any
if err := json.Unmarshal([]byte(out), &docs); err != nil {
t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
}
return docs
}
func docOutboundTags(doc map[string]any) []string {
outbounds, _ := doc["outbounds"].([]any)
tags := make([]string, 0, len(outbounds))
for _, ob := range outbounds {
if m, ok := ob.(map[string]any); ok {
tags = append(tags, m["tag"].(string))
}
}
return tags
}
func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
for _, doc := range docs {
if doc["remarks"] == remarks {
return doc
}
}
return nil
}
// The balancer document retags members under a per-balancer prefix, points
// proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
func TestSubJson_BalancerDocument(t *testing.T) {
seedSubDB(t)
tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
})
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
js := NewSubJsonService("", rules, "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 3 {
t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
}
balancerDoc := findDocByRemarks(docs, "auto")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
t.Fatalf("balancer outbound tags = %v", tags)
}
routing, _ := balancerDoc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
if len(balancers) != 1 {
t.Fatalf("balancers = %d, want 1", len(balancers))
}
balancer, _ := balancers[0].(map[string]any)
if balancer["tag"] != "balancer" {
t.Fatalf("balancer tag = %v", balancer["tag"])
}
if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
t.Fatalf("selector = %v", selector)
}
strategy, _ := balancer["strategy"].(map[string]any)
if strategy["type"] != "leastLoad" {
t.Fatalf("strategy = %v", strategy)
}
if balancer["fallbackTag"] != "bal-1-vless" {
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
}
ruleJSON, _ := json.Marshal(routing["rules"])
if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
}
if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
}
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
if proxyRules != 2 { // custom rule + default catch-all
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
}
observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
t.Fatalf("subjectSelector = %v", selector)
}
ping, _ := observatory["pingConfig"].(map[string]any)
if ping["destination"] != subBalancerProbeURL {
t.Fatalf("pingConfig destination = %v", ping["destination"])
}
// The routing rewrite must not leak into the manual documents: s.configJson
// is shared, so a missing clone would corrupt every other doc.
for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
manual := findDocByRemarks(docs, remarks)
if manual == nil {
t.Fatalf("manual doc %q missing:\n%s", remarks, out)
}
if tags := docOutboundTags(manual); tags[0] != "proxy" {
t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
}
manualRouting, _ := manual["routing"].(map[string]any)
manualRules, _ := json.Marshal(manualRouting["rules"])
if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
}
if _, has := manualRouting["balancers"]; has {
t.Fatalf("manual doc %q must not carry balancers", remarks)
}
}
}
func stringify(values []any) []string {
out := make([]string, 0, len(values))
for _, v := range values {
out = append(out, v.(string))
}
return out
}
// The balancer interleaves with inbounds by the same 1-based number and, on a
// tie, follows the inbound group with that number.
func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
seedSubDB(t)
later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
var remarks []string
for _, doc := range docs {
remarks = append(remarks, doc["remarks"].(string))
}
if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
t.Fatalf("doc order = %v, want [first bal later]", remarks)
}
balancerDoc := findDocByRemarks(docs, "bal")
routing, _ := balancerDoc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
if strategy["type"] != "roundRobin" {
t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
}
}
// A disabled balancer is not emitted; an enabled one whose selected inbounds
// have no configs for this subscriber is skipped rather than emitted empty.
func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
seedSubDB(t)
inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
})
seedSubBalancer(t, &model.SubBalancer{
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 1 {
t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
}
if docs[0]["remarks"] != "only-only@e" {
t.Fatalf("remaining doc = %v", docs[0]["remarks"])
}
}
// Two members sharing a transport get deduplicated tags (…-2 suffix), matching
// the reference makeTag convention.
func TestSubJson_BalancerTagDedup(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
balancerDoc := findDocByRemarks(docs, "dedup")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
t.Fatalf("balancer outbound tags = %v", tags)
}
}
// random/roundRobin have no fallback so they emit no observatory; leastPing
// carries one, with the panel-wide ping config overriding the defaults.
func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
seedSubDB(t)
rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
})
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
rnd := findDocByRemarks(docs, "rnd")
if _, has := rnd["burstObservatory"]; has {
t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
}
pinger := findDocByRemarks(docs, "pinger")
obs, _ := pinger["burstObservatory"].(map[string]any)
if obs == nil {
t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
}
ping, _ := obs["pingConfig"].(map[string]any)
if ping["destination"] != "https://probe.example/204" {
t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
}
if ping["httpMethod"] != "GET" {
t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
}
if ping["sampling"] != float64(5) {
t.Fatalf("sampling = %v, want 5", ping["sampling"])
}
if ping["interval"] != "1m" {
t.Fatalf("interval = %v, want default 1m", ping["interval"])
}
}
// A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
// filters enable=true, so B never reaches entries. Guards the access scoping.
func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable inbound B: %v", err)
}
seedSubBalancer(t, &model.SubBalancer{
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
balancerDoc := findDocByRemarks(docs, "bal")
if balancerDoc == nil {
t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
}
tags := docOutboundTags(balancerDoc)
joined := strings.Join(tags, ",")
if !strings.Contains(joined, "bal-1-vless") {
t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
}
// B's address must not surface anywhere in the balancer doc — not as an
// outbound tag, not as a connection target a client could dial.
balJSON, _ := json.Marshal(balancerDoc)
if strings.Contains(string(balJSON), "203.0.113.5:4752") {
t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
}
}
// A balancer whose only selected inbound is disabled for this subscriber is
// skipped entirely — never emitted as an empty balancer with zero members.
func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
seedSubDB(t)
only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable only inbound: %v", err)
}
seedSubBalancer(t, &model.SubBalancer{
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if strings.TrimSpace(out) == "" {
return
}
docs := parseSubJsonDocs(t, out)
if findDocByRemarks(docs, "empty") != nil {
t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
}
}
// Connectivity defaults to empty (skip the direct pre-check); an explicit empty
// value stays empty instead of restoring the old generate_204 default.
func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
seedSubDB(t)
inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "" {
t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
}
js.SetObservatoryConfig(`{"connectivity":""}`)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "" {
t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
}
js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "http://probe.example/204" {
t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
}
}
// leastPing/leastLoad always emit a burst observatory (Xray won't start them
// without one); a stored {"enabled":false} is ignored as it is mandatory.
func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", NewSubService(""))
js.SetObservatoryConfig(`{"enabled":false}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
if pinger == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if _, has := pinger["burstObservatory"]; !has {
t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
}
routing, _ := pinger["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
balancer, _ := balancers[0].(map[string]any)
if balancer["fallbackTag"] != "bal-1-vless" {
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
}
}
func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string) map[string]any {
t.Helper()
doc := findDocByRemarks(docs, remarks)
if doc == nil {
t.Fatalf("balancer doc %q missing", remarks)
}
obs, _ := doc["burstObservatory"].(map[string]any)
if obs == nil {
t.Fatalf("balancer %q has no burstObservatory", remarks)
}
ping, _ := obs["pingConfig"].(map[string]any)
return ping
}
+53
View File
@@ -0,0 +1,53 @@
package sub
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Bad observatory settings (malformed JSON, non-URL destination, bad duration)
// must not leak into the emitted burstObservatory — each falls back to the
// built-in defaults instead of poisoning the client config.
func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
seedSubDB(t)
inb := seedSubInbound(t, "s1", "lp", 4821, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
})
def := defaultSubBalancerObservatoryConfig()
cases := []struct {
name string
cfg string
}{
{"bad json", `{not-json`},
{"bad destination", `{"destination":"not-a-url"}`},
{"bad interval", `{"interval":"xyz"}`},
{"bad timeout", `{"timeout":"5x"}`},
{"bad connectivity", `{"connectivity":"ftp://bad"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
js := NewSubJsonService("", "", "", NewSubService(""))
js.SetObservatoryConfig(tc.cfg)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["destination"] != def.Destination {
t.Fatalf("destination = %v, want default %q (cfg=%s)", ping["destination"], def.Destination, tc.cfg)
}
if ping["interval"] != def.Interval {
t.Fatalf("interval = %v, want default %q (cfg=%s)", ping["interval"], def.Interval, tc.cfg)
}
if ping["timeout"] != def.Timeout {
t.Fatalf("timeout = %v, want default %q (cfg=%s)", ping["timeout"], def.Timeout, tc.cfg)
}
if ping["connectivity"] != def.Connectivity {
t.Fatalf("connectivity = %v, want default %q (cfg=%s)", ping["connectivity"], def.Connectivity, tc.cfg)
}
})
}
}
+3
View File
@@ -201,6 +201,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
a.settingController = NewSettingController(api) a.settingController = NewSettingController(api)
a.xraySettingController = NewXraySettingController(api) a.xraySettingController = NewXraySettingController(api)
// Subscription balancers — client-side balancers for the JSON sub output
NewSubBalancerController(api)
// Extra routes // Extra routes
api.POST("/backuptotgbot", a.BackuptoTgbot) api.POST("/backuptotgbot", a.BackuptoTgbot)
} }
+126
View File
@@ -0,0 +1,126 @@
package controller
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// SubBalancerController manages client-side JSON-subscription balancers.
type SubBalancerController struct {
SubBalancerService service.SubBalancerService
}
func NewSubBalancerController(g *gin.RouterGroup) *SubBalancerController {
a := &SubBalancerController{}
g = g.Group("/sub-balancers")
g.GET("", a.list)
g.POST("", a.create)
g.POST("/:id", a.update)
g.DELETE("/:id", a.del)
g.POST("/:id/del", a.del)
return a
}
// parseSubBalancerForm reads the urlencoded form (HttpUtil default): scalars
// via ShouldBind, inboundIds as repeated keys. enabled is returned as *bool so
// Update can keep the stored value when the key is absent; a bad value is a 400.
func parseSubBalancerForm(c *gin.Context) (*model.SubBalancer, *bool, error) {
form := struct {
Remark string `form:"remark"`
Strategy string `form:"strategy"`
SortOrder int `form:"sortOrder"`
}{}
if err := c.ShouldBind(&form); err != nil {
return nil, nil, err
}
var enabled *bool
if raw, ok := c.GetPostForm("enabled"); ok {
v, err := strconv.ParseBool(raw)
if err != nil {
return nil, nil, fmt.Errorf("invalid enabled %q: %w", raw, err)
}
enabled = &v
}
balancer := &model.SubBalancer{
Remark: form.Remark,
Strategy: form.Strategy,
SortOrder: form.SortOrder,
}
for _, raw := range c.PostFormArray("inboundIds") {
id, err := strconv.Atoi(raw)
if err != nil {
return nil, nil, fmt.Errorf("invalid inbound id %q: %w", raw, err)
}
balancer.InboundIds = append(balancer.InboundIds, id)
}
return balancer, enabled, nil
}
func (a *SubBalancerController) parseID(c *gin.Context) (int, error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id < 1 {
return 0, fmt.Errorf("invalid id %q", c.Param("id"))
}
return id, nil
}
func (a *SubBalancerController) list(c *gin.Context) {
balancers, err := a.SubBalancerService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.list"), err)
return
}
jsonObj(c, balancers, nil)
}
func (a *SubBalancerController) create(c *gin.Context) {
balancer, enabled, err := parseSubBalancerForm(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
return
}
balancer.Enabled = enabled == nil || *enabled
created, err := a.SubBalancerService.Create(balancer)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
return
}
jsonObj(c, created, nil)
}
func (a *SubBalancerController) update(c *gin.Context) {
id, err := a.parseID(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
return
}
balancer, enabled, err := parseSubBalancerForm(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
return
}
updated, err := a.SubBalancerService.Update(id, balancer, enabled)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
return
}
jsonObj(c, updated, nil)
}
func (a *SubBalancerController) del(c *gin.Context) {
id, err := a.parseID(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
return
}
if err := a.SubBalancerService.Delete(id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.delete"), err)
return
}
jsonObj(c, "", nil)
}
@@ -0,0 +1,105 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
func setupSubBalancerRouter(t *testing.T) *gin.Engine {
t.Helper()
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
gin.SetMode(gin.TestMode)
router := gin.New()
NewSubBalancerController(router.Group("/panel/api"))
return router
}
func subBalancerPost(t *testing.T, router *gin.Engine, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
return resp
}
func responseObj(t *testing.T, body string) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("unmarshal response %q: %v", body, err)
}
return m
}
// enabled absent on create defaults to true; "false" disables; a non-boolean
// value is rejected so a malformed toggle can't silently flip the row.
func TestSubBalancerController_EnabledParsing(t *testing.T) {
router := setupSubBalancerRouter(t)
base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base)
if !strings.Contains(resp.Body.String(), `"success":true`) {
t.Fatalf("create no enabled: %s", resp.Body.String())
}
bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != true {
t.Fatalf("absent enabled = %v, want true", bal["enabled"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != false {
t.Fatalf("enabled=false -> %v, want false", bal["enabled"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=bogus")
if !strings.Contains(resp.Body.String(), `"success":false`) {
t.Fatalf("enabled=bogus should be rejected: %s", resp.Body.String())
}
}
// An update omitting enabled preserves the stored value instead of resetting it
// to the create default — a partial PATCH must not clobber the toggle.
func TestSubBalancerController_UpdatePreservesEnabledWhenAbsent(t *testing.T) {
router := setupSubBalancerRouter(t)
base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
id := strconv.Itoa(int(bal["id"].(float64)))
if bal["enabled"] != false {
t.Fatalf("setup: enabled = %v, want false", bal["enabled"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1")
if !strings.Contains(resp.Body.String(), `"success":true`) {
t.Fatalf("update: %s", resp.Body.String())
}
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != false {
t.Fatalf("update without enabled = %v, want preserved false", bal["enabled"])
}
if bal["remark"] != "renamed" {
t.Fatalf("remark = %v, want renamed", bal["remark"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1&enabled=true")
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != true {
t.Fatalf("enabled=true -> %v, want true", bal["enabled"])
}
}
+1
View File
@@ -105,6 +105,7 @@ type AllSetting struct {
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"` SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
+17
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net" "net"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -1188,6 +1189,22 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil { if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
return err return err
} }
// Drop the deleted inbound from any sub-balancer that selects it; a
// dangling id would emit a member no subscriber can resolve (#5648).
var balancers []model.SubBalancer
if err := tx.Find(&balancers).Error; err != nil {
return err
}
for i := range balancers {
before := balancers[i].InboundIds
balancers[i].InboundIds = slices.DeleteFunc(before, func(b int) bool { return b == id })
if len(balancers[i].InboundIds) == len(before) {
continue
}
if err := tx.Save(&balancers[i]).Error; err != nil {
return err
}
}
if loadErr == nil && ib.NodeID != nil { if loadErr == nil && ib.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID) return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID)
} }
+5
View File
@@ -118,6 +118,7 @@ var defaultValueMap = map[string]string{
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonObservatory": "",
"subThemeDir": "", "subThemeDir": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
@@ -893,6 +894,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask") return s.getString("subJsonFinalMask")
} }
func (s *SettingService) GetSubJsonObservatory() (string, error) {
return s.getString("subJsonObservatory")
}
func (s *SettingService) GetSubThemeDir() (string, error) { func (s *SettingService) GetSubThemeDir() (string, error) {
return s.getString("subThemeDir") return s.getString("subThemeDir")
} }
+101
View File
@@ -0,0 +1,101 @@
package service
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
var subBalancerStrategies = map[string]struct{}{
"leastLoad": {},
"leastPing": {},
"random": {},
"roundRobin": {},
}
// SubBalancerService manages client-side JSON-subscription balancers; rows
// are read per request by internal/sub, so mutations need no xray restart.
type SubBalancerService struct{}
func (s *SubBalancerService) validate(b *model.SubBalancer) error {
b.Remark = strings.TrimSpace(b.Remark)
if b.Remark == "" {
return common.NewError("balancer remark is required")
}
if len(b.Remark) > 256 {
return common.NewError("balancer remark too long (max 256)")
}
if b.Strategy == "" {
b.Strategy = "random"
}
if _, ok := subBalancerStrategies[b.Strategy]; !ok {
return common.NewError("invalid balancer strategy:", b.Strategy)
}
if len(b.InboundIds) == 0 {
return common.NewError("balancer must select at least one inbound")
}
if b.SortOrder < 1 {
b.SortOrder = 1
}
return nil
}
// List returns all balancers in subscription order.
func (s *SubBalancerService) List() ([]*model.SubBalancer, error) {
var balancers []*model.SubBalancer
err := database.GetDB().Model(&model.SubBalancer{}).
Order("sort_order asc, id asc").Find(&balancers).Error
return balancers, err
}
func (s *SubBalancerService) Get(id int) (*model.SubBalancer, error) {
var balancer model.SubBalancer
if err := database.GetDB().First(&balancer, id).Error; err != nil {
return nil, err
}
return &balancer, nil
}
func (s *SubBalancerService) Create(balancer *model.SubBalancer) (*model.SubBalancer, error) {
if err := s.validate(balancer); err != nil {
return nil, err
}
if err := database.GetDB().Create(balancer).Error; err != nil {
return nil, err
}
return balancer, nil
}
func (s *SubBalancerService) Update(id int, balancer *model.SubBalancer, enabled *bool) (*model.SubBalancer, error) {
if err := s.validate(balancer); err != nil {
return nil, err
}
current, err := s.Get(id)
if err != nil {
return nil, err
}
current.Remark = balancer.Remark
current.Strategy = balancer.Strategy
current.InboundIds = balancer.InboundIds
current.SortOrder = balancer.SortOrder
if enabled != nil {
current.Enabled = *enabled
}
if err := database.GetDB().Save(current).Error; err != nil {
return nil, err
}
return current, nil
}
func (s *SubBalancerService) Delete(id int) error {
res := database.GetDB().Delete(&model.SubBalancer{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return common.NewError("sub balancer not found")
}
return nil
}
@@ -0,0 +1,36 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Deleting an inbound that a sub-balancer selects must strip its id from
// InboundIds, leaving no dangling member reference (#5648 mirrors the hosts
// cascade). With the only member gone the balancer stops emitting a doc.
func TestDelInboundClearsSubBalancerInboundIds(t *testing.T) {
setupSubBalancerDB(t)
ib := &model.Inbound{UserId: 1, Tag: "cleanup", Enable: false, Listen: "203.0.113.7", Port: 5001, Protocol: model.VLESS, Remark: "cleanup", Settings: `{}`, StreamSettings: `{}`}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
balSvc := &SubBalancerService{}
bal, err := balSvc.Create(&model.SubBalancer{Remark: "bal", Strategy: "random", InboundIds: []int{ib.Id}, SortOrder: 1, Enabled: true})
if err != nil {
t.Fatalf("create balancer: %v", err)
}
if _, err := (&InboundService{}).DelInbound(ib.Id); err != nil {
t.Fatalf("DelInbound: %v", err)
}
stored, err := balSvc.Get(bal.Id)
if err != nil {
t.Fatalf("get balancer: %v", err)
}
if len(stored.InboundIds) != 0 {
t.Fatalf("InboundIds = %v, want empty (no dangling id)", stored.InboundIds)
}
}
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"path/filepath"
"strings"
"sync"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
)
var subBalancerLoggerOnce sync.Once
func setupSubBalancerDB(t *testing.T) {
t.Helper()
subBalancerLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() {
if err := database.CloseDB(); err != nil {
t.Logf("CloseDB warning: %v", err)
}
})
}
func TestSubBalancerServiceCRUD(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
created, err := svc.Create(&model.SubBalancer{
Remark: "auto", Strategy: "", InboundIds: []int{1, 2}, SortOrder: 0, Enabled: false,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Strategy != "random" {
t.Fatalf("strategy = %q, want normalized random", created.Strategy)
}
if created.SortOrder != 1 {
t.Fatalf("sortOrder = %d, want normalized 1", created.SortOrder)
}
stored, err := svc.Get(created.Id)
if err != nil {
t.Fatalf("get: %v", err)
}
if stored.Enabled {
t.Fatal("explicit disabled balancer must be stored disabled")
}
second, err := svc.Create(&model.SubBalancer{
Remark: "second", Strategy: "leastPing", InboundIds: []int{1}, SortOrder: 3, Enabled: true,
})
if err != nil {
t.Fatalf("create second: %v", err)
}
list, err := svc.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 2 || list[0].Id != created.Id || list[1].Id != second.Id {
t.Fatalf("list order = [%d %d], want [%d %d]", list[0].Id, list[1].Id, created.Id, second.Id)
}
enabledFalse := false
updated, err := svc.Update(second.Id, &model.SubBalancer{
Remark: "renamed", Strategy: "leastLoad", InboundIds: []int{2}, SortOrder: 2,
}, &enabledFalse)
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Remark != "renamed" || updated.Strategy != "leastLoad" || updated.SortOrder != 2 || updated.Enabled {
t.Fatalf("update stored wrong row: %+v", updated)
}
after, err := svc.Get(second.Id)
if err != nil {
t.Fatalf("get after update: %v", err)
}
if after.Enabled || after.Strategy != "leastLoad" || len(after.InboundIds) != 1 || after.InboundIds[0] != 2 {
t.Fatalf("update did not persist: %+v", after)
}
if err := svc.Delete(created.Id); err != nil {
t.Fatalf("delete: %v", err)
}
list, err = svc.List()
if err != nil {
t.Fatalf("list after delete: %v", err)
}
if len(list) != 1 || list[0].Id != second.Id {
t.Fatalf("list after delete = %v", list)
}
}
// roundRobin is a valid xray routing strategy (selects outbounds in order) and
// must pass the same validation as the other three.
func TestSubBalancerServiceRoundRobin(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
created, err := svc.Create(&model.SubBalancer{
Remark: "rr", Strategy: "roundRobin", InboundIds: []int{1, 2}, SortOrder: 1, Enabled: true,
})
if err != nil {
t.Fatalf("create roundRobin: %v", err)
}
if created.Strategy != "roundRobin" {
t.Fatalf("strategy = %q, want roundRobin", created.Strategy)
}
stored, err := svc.Get(created.Id)
if err != nil {
t.Fatalf("get: %v", err)
}
if stored.Strategy != "roundRobin" {
t.Fatalf("stored strategy = %q, want roundRobin", stored.Strategy)
}
}
// Deleting a missing balancer reports not-found instead of success:true, so
// a stale UI row can't claim a delete that touched nothing.
func TestSubBalancerServiceDeleteNotFound(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
if err := svc.Delete(999); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("Delete(999) = %v, want a not-found error", err)
}
}
func TestSubBalancerServiceValidation(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
cases := []struct {
name string
row model.SubBalancer
want string
}{
{"empty remark", model.SubBalancer{Strategy: "random", InboundIds: []int{1}}, "remark is required"},
{"bad strategy", model.SubBalancer{Remark: "x", Strategy: "fastest", InboundIds: []int{1}}, "invalid balancer strategy"},
{"no inbounds", model.SubBalancer{Remark: "x", Strategy: "random"}, "at least one inbound"},
{"long remark", model.SubBalancer{Remark: strings.Repeat("x", 257), Strategy: "random", InboundIds: []int{1}}, "max 256"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.Create(&tc.row)
if err == nil {
t.Fatal("create must fail")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %q, want substring %q", err.Error(), tc.want)
}
})
}
}
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "قائمة سماح حد IP", "ipLimitAllowlist": "قائمة سماح حد IP",
"ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل." "ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل.",
"subBalancers": {
"menu": "موزّعات الاشتراك",
"title": "موزّع الاشتراك",
"add": "إضافة موزّع",
"desc": "كل موزّع مُفعّل يُضاف إلى اشتراك JSON كملف تعريف إضافي يختار تلقائيًا أفضل نقطة نهاية من الإينبوندات المحددة.",
"remark": "ملاحظة",
"remarkPlaceholder": "تلقائي · الأسرع",
"strategy": "الاستراتيجية",
"strategyLeastLoad": "أقل حمل",
"strategyLeastPing": "أقل ping",
"strategyRandom": "عشوائي",
"strategyRoundRobin": "دوران",
"sortOrder": "الترتيب",
"sortOrderHelp": "الموضع في قائمة الاشتراك، متداخل مع ترتيب الإينبوندات؛ عند تساوي الرقم يأتي الموزّع بعد الإينباند.",
"inbounds": "الإينبوندات",
"inboundsCount": "{count} الإينبوندات",
"enabled": "مُفعّل",
"empty": "لا يوجد موزّعات بعد",
"deleteConfirm": "حذف هذا الموزّع؟",
"errRemarkRequired": "الملاحظة مطلوبة",
"errInboundsRequired": "اختر إينبوندًا واحدًا على الأقل",
"errSortOrder": "الترتيب يجب أن يكون عددًا صحيحًا ≥ 1",
"toasts": {
"list": "تعذّر عرض موزّعات الاشتراك",
"create": "تعذّر إنشاء موزّع اشتراك",
"update": "تعذّر تحديث موزّع اشتراك",
"delete": "تعذّر حذف موزّع اشتراك",
"invalidId": "معرّف غير صالح"
},
"tabBalancers": "موازنات التحميل",
"tabObservatory": "المرصد",
"observatory": {
"title": "مرصد الموزّع",
"desc": "معاملات probe لـ burstObservatory المُضمَّن في كل ملف leastPing/leastLoad. random/roundRobin بلا مرصد. يُحفظ كإعداد شامل لاشتراك JSON.",
"destination": "عنوان probe",
"destinationDesc": "العنوان الذي يقيس العميل به كل صادر عضو.",
"connectivity": "عنوان الاتصالية",
"connectivityDesc": "عنوان اختياري للتحقق مرة واحدة من وصول العضو للهدف. اتركه فارغًا للتخطي.",
"interval": "فترة probe",
"intervalDesc": "الزمن بين جولات probe، مثال 1m.",
"timeout": "مهلة probe",
"timeoutDesc": "مهلة probe واحدة، مثال 5s.",
"sampling": "أخذ العينات",
"samplingDesc": "عدد probe المتتالية لقياس الاستقرار.",
"httpMethod": "أسلوب HTTP",
"httpMethodDesc": "الأسلوب المستخدم في طلبات probe.",
"note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
}
}
}, },
"xray": { "xray": {
"save": "احفظ", "save": "احفظ",
+50 -1
View File
@@ -1504,7 +1504,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP limit allowlist", "ipLimitAllowlist": "IP limit allowlist",
"ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR." "ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR.",
"subBalancers": {
"menu": "Sub Balancers",
"title": "Subscription balancer",
"add": "Add balancer",
"desc": "Each enabled balancer is added to the JSON subscription as one extra profile that automatically picks the best of the selected inbounds' endpoints (routing.balancers + burstObservatory in the client config).",
"remark": "Remark",
"remarkPlaceholder": "Auto · fastest",
"strategy": "Strategy",
"strategyLeastLoad": "Least load",
"strategyLeastPing": "Least ping",
"strategyRandom": "Random",
"strategyRoundRobin": "Round robin",
"sortOrder": "Order",
"sortOrderHelp": "Position in the subscription list, interleaved with the inbounds' own order; on equal numbers the balancer comes after the inbound.",
"inbounds": "Inbounds",
"inboundsCount": "{count} Inbounds",
"enabled": "Enabled",
"empty": "No balancers yet",
"deleteConfirm": "Delete this balancer?",
"errRemarkRequired": "Remark is required",
"errInboundsRequired": "Select at least one inbound",
"errSortOrder": "Order must be a whole number ≥ 1",
"toasts": {
"list": "Failed to list subscription balancers",
"create": "Failed to create subscription balancer",
"update": "Failed to update subscription balancer",
"delete": "Failed to delete subscription balancer",
"invalidId": "Invalid id"
},
"tabBalancers": "Balancers",
"tabObservatory": "Observatory",
"observatory": {
"title": "Balancer observatory",
"desc": "Probe parameters for the burst observatory emitted into each leastPing/leastLoad balancer profile. random/roundRobin balancers get no observatory. Stored as a panel-wide JSON-sub setting.",
"destination": "Probe URL",
"destinationDesc": "URL the client pings to measure each member outbound.",
"connectivity": "Connectivity URL",
"connectivityDesc": "Optional URL checked once to confirm the member can reach the probe destination. Leave empty to skip.",
"interval": "Probe interval",
"intervalDesc": "Time between probe rounds, e.g. 1m.",
"timeout": "Probe timeout",
"timeoutDesc": "Per-probe timeout, e.g. 5s.",
"sampling": "Sampling",
"samplingDesc": "Number of consecutive probes averaged for stability.",
"httpMethod": "HTTP method",
"httpMethodDesc": "Method used for probe requests.",
"note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
}
}
}, },
"xray": { "xray": {
"save": "Save", "save": "Save",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Lista de permitidos del límite de IP", "ipLimitAllowlist": "Lista de permitidos del límite de IP",
"ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma." "ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma.",
"subBalancers": {
"menu": "Balanceadores de suscripción",
"title": "Balanceador de suscripción",
"add": "Añadir balanceador",
"desc": "Cada balanceador activo se añade a la suscripción JSON como un perfil adicional que elige automáticamente el mejor de los endpoints de los inbounds seleccionados.",
"remark": "Comentario",
"remarkPlaceholder": "Auto · el más rápido",
"strategy": "Estrategia",
"strategyLeastLoad": "Menor carga",
"strategyLeastPing": "Menor ping",
"strategyRandom": "Aleatorio",
"strategyRoundRobin": "Round robin",
"sortOrder": "Orden",
"sortOrderHelp": "Posición en la lista de la suscripción, intercalada con el orden de los inbounds; con el mismo número, el balanceador va después del inbound.",
"inbounds": "Inbounds",
"inboundsCount": "{count} Inbounds",
"enabled": "Activado",
"empty": "Aún no hay balanceadores",
"deleteConfirm": "¿Eliminar este balanceador?",
"errRemarkRequired": "El comentario es obligatorio",
"errInboundsRequired": "Selecciona al menos un inbound",
"errSortOrder": "El orden debe ser un número entero ≥ 1",
"toasts": {
"list": "No se pudieron listar los balanceadores de suscripción",
"create": "No se pudo crear el balanceador de suscripción",
"update": "No se pudo actualizar el balanceador de suscripción",
"delete": "No se pudo eliminar el balanceador de suscripción",
"invalidId": "Id no válido"
},
"tabBalancers": "Equilibradores",
"tabObservatory": "Observatorio",
"observatory": {
"title": "Observatorio del balanceador",
"desc": "Parámetros de probe para el burstObservatory incluido en cada perfil leastPing/leastLoad. random/roundRobin no generan observatorio. Se guarda como ajuste global de la suscripción JSON.",
"destination": "URL de probe",
"destinationDesc": "Dirección que el cliente sondea para medir cada salida miembro.",
"connectivity": "URL de conectividad",
"connectivityDesc": "Dirección opcional para verificar una vez que el miembro llega al destino. Vacío para omitir.",
"interval": "Intervalo de probe",
"intervalDesc": "Tiempo entre rondas de probe, p. ej. 1m.",
"timeout": "Tiempo de espera de probe",
"timeoutDesc": "Tiempo de espera de cada probe, p. ej. 5s.",
"sampling": "Muestreo",
"samplingDesc": "Número de probes consecutivos para promediar estabilidad.",
"httpMethod": "Método HTTP",
"httpMethodDesc": "Método usado para las solicitudes de probe.",
"note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
}
}
}, },
"xray": { "xray": {
"save": "Guardar configuración", "save": "Guardar configuración",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "فهرست مجاز محدودیت IP", "ipLimitAllowlist": "فهرست مجاز محدودیت IP",
"ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما)." "ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما).",
"subBalancers": {
"menu": "موزان‌کننده‌های اشتراک",
"title": "موزان‌کننده اشتراک",
"add": "افزودن موزان‌کننده",
"desc": "هر موزان‌کنندهٔ فعال به‌عنوان یک پروفایل اضافه به اشتراک JSON اضافه می‌شود و به‌طور خودکار بهترین نقطهٔ پایانیِ اینباند‌های انتخاب‌شده را برمی‌گزیند.",
"remark": "توضیح",
"remarkPlaceholder": "خودکار · سریع‌ترین",
"strategy": "استراتژی",
"strategyLeastLoad": "کم‌ترین بار",
"strategyLeastPing": "کم‌ترین پینگ",
"strategyRandom": "تصادفی",
"strategyRoundRobin": "گردشی",
"sortOrder": "ترتیب",
"sortOrderHelp": "جایگاه در فهرست اشتراک، درهم‌تنیده با ترتیب اینباند‌ها؛ با شمارهٔ برابر، موزان‌کننده بعد از اینباند می‌آید.",
"inbounds": "اینباند‌ها",
"inboundsCount": "{count} اینباند‌ها",
"enabled": "فعال",
"empty": "هنوز موزان‌کننده‌ای وجود ندارد",
"deleteConfirm": "این موزان‌کننده حذف شود؟",
"errRemarkRequired": "توضیح الزامی است",
"errInboundsRequired": "حداقل یک اینباند انتخاب کنید",
"errSortOrder": "ترتیب باید عدد صحیح ≥ ۱ باشد",
"toasts": {
"list": "فهرست‌سازی موزان‌کننده‌های اشتراک ناموفق بود",
"create": "ایجاد موزان‌کننده اشتراک ناموفق بود",
"update": "به‌روزرسانی موزان‌کننده اشتراک ناموفق بود",
"delete": "حذف موزان‌کننده اشتراک ناموفق بود",
"invalidId": "شناسه نامعتبر"
},
"tabBalancers": "بالانسرها",
"tabObservatory": "رصدخانه",
"observatory": {
"title": "رصدگر موزان‌کننده",
"desc": "پارامترهای probe برای burstObservatory که در هر پروفایل leastPing/leastLoad نوشته می‌شود. random/roundRobin رصدگر ندارند. به‌صورت تنظیم سراسری اشتراک JSON ذخیره می‌شود.",
"destination": "آدرس probe",
"destinationDesc": "آدرسی که کلاینت برای سنجش هر خروجی عضو آن را probe می‌کند.",
"connectivity": "آدرس اتصال",
"connectivityDesc": "آدرس اختیاری برای بررسی یک‌بارهٔ دسترسی به هدف. خالی بگذارید تا رد شود.",
"interval": "بازه probe",
"intervalDesc": "زمان بین دورهای probe، مثلاً 1m.",
"timeout": "مهلت probe",
"timeoutDesc": "مهلت هر probe، مثلاً 5s.",
"sampling": "نمونه‌برداری",
"samplingDesc": "تعداد probe متوالی برای میانگین پایداری.",
"httpMethod": "متد HTTP",
"httpMethodDesc": "متد استفاده‌شده برای درخواست‌های probe.",
"note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند."
}
}
}, },
"xray": { "xray": {
"save": "ذخیره", "save": "ذخیره",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Daftar izin batas IP", "ipLimitAllowlist": "Daftar izin batas IP",
"ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma)." "ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma).",
"subBalancers": {
"menu": "Penyeimbang langganan",
"title": "Penyeimbang langganan",
"add": "Tambah penyeimbang",
"desc": "Setiap penyeimbang yang aktif ditambahkan ke langganan JSON sebagai profil tambahan yang otomatis memilih titik akhir terbaik dari inbound terpilih.",
"remark": "Keterangan",
"remarkPlaceholder": "Otomatis · tercepat",
"strategy": "Strategi",
"strategyLeastLoad": "Beban terendah",
"strategyLeastPing": "Ping terendah",
"strategyRandom": "Acak",
"strategyRoundRobin": "Round robin",
"sortOrder": "Urutan",
"sortOrderHelp": "Posisi dalam daftar langganan, berselang-seling dengan urutan inbound; jika sama, penyeimbang berada setelah inbound.",
"inbounds": "Inbound",
"inboundsCount": "{count} Inbound",
"enabled": "Aktif",
"empty": "Belum ada penyeimbang",
"deleteConfirm": "Hapus penyeimbang ini?",
"errRemarkRequired": "Keterangan wajib diisi",
"errInboundsRequired": "Pilih minimal satu inbound",
"errSortOrder": "Urutan harus bilangan bulat ≥ 1",
"toasts": {
"list": "Gagal menampilkan daftar penyeimbang langganan",
"create": "Gagal membuat penyeimbang langganan",
"update": "Gagal memperbarui penyeimbang langganan",
"delete": "Gagal menghapus penyeimbang langganan",
"invalidId": "Id tidak valid"
},
"tabBalancers": "Penyeimbang",
"tabObservatory": "Observatory",
"observatory": {
"title": "Observatorium penyeimbang",
"desc": "Parameter probe untuk burstObservatory yang disisipkan ke setiap profil leastPing/leastLoad. random/roundRobin tanpa observatorium. Disimpan sebagai pengaturan langganan JSON tingkat panel.",
"destination": "URL probe",
"destinationDesc": "Alamat yang di-probe klien untuk mengukur setiap outbound anggota.",
"connectivity": "URL konektivitas",
"connectivityDesc": "Alamat opsional untuk memeriksa sekali bahwa anggota menjangkau tujuan. Kosongkan untuk melewati.",
"interval": "Interval probe",
"intervalDesc": "Waktu antar ronde probe, mis. 1m.",
"timeout": "Waktu habis probe",
"timeoutDesc": "Waktu habis per probe, mis. 5s.",
"sampling": "Pengambilan sampel",
"samplingDesc": "Jumlah probe beruntun untuk merata-ratakan stabilitas.",
"httpMethod": "Metode HTTP",
"httpMethodDesc": "Metode yang dipakai untuk permintaan probe.",
"note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
}
}
}, },
"xray": { "xray": {
"save": "Simpan", "save": "Simpan",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP 制限の許可リスト", "ipLimitAllowlist": "IP 制限の許可リスト",
"ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。" "ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。",
"subBalancers": {
"menu": "サブスクリプションバランサー",
"title": "サブスクリプションバランサー",
"add": "バランサーを追加",
"desc": "有効なバランサーは JSON サブスクリプションに追加プロファイルとして加わり、選択したインバウンドのエンドポイントから最適なものを自動選択します。",
"remark": "備考",
"remarkPlaceholder": "自動 · 最速",
"strategy": "方式",
"strategyLeastLoad": "最小負荷",
"strategyLeastPing": "最小 ping",
"strategyRandom": "ランダム",
"strategyRoundRobin": "ラウンドロビン",
"sortOrder": "順序",
"sortOrderHelp": "サブスクリプション一覧内の位置。インバウンドの順序と交互に並び、同番号の場合はインバウンドの後ろになります。",
"inbounds": "インバウンド",
"inboundsCount": "{count} インバウンド",
"enabled": "有効",
"empty": "バランサーはまだありません",
"deleteConfirm": "このバランサーを削除しますか?",
"errRemarkRequired": "備考を入力してください",
"errInboundsRequired": "インバウンドを1つ以上選択してください",
"errSortOrder": "順序は1以上の整数にしてください",
"toasts": {
"list": "サブスクリプションバランサーの一覧取得に失敗しました",
"create": "サブスクリプションバランサーの作成に失敗しました",
"update": "サブスクリプションバランサーの更新に失敗しました",
"delete": "サブスクリプションバランサーの削除に失敗しました",
"invalidId": "無効な id です"
},
"tabBalancers": "負荷分散",
"tabObservatory": "オブザーバトリ",
"observatory": {
"title": "バランサー観測",
"desc": "各 leastPing/leastLoad バランサープロファイルに埋め込む burstObservatory のプローブ設定。random/roundRobin には観測を入れません。パネル全体の JSON サブ設定として保存されます。",
"destination": "プローブ URL",
"destinationDesc": "クライアントが各メンバーアウトバウンドを計測するためのアドレス。",
"connectivity": "接続確認 URL",
"connectivityDesc": "メンバーがプローブ先へ到達できるか一度確認する任意のアドレス。空ならスキップ。",
"interval": "プローブ間隔",
"intervalDesc": "プローブ周期の間隔(例: 1m)。",
"timeout": "プローブタイムアウト",
"timeoutDesc": "1回のプローブのタイムアウト(例: 5s)。",
"sampling": "サンプリング",
"samplingDesc": "安定度を平均するための連続プローブ回数。",
"httpMethod": "HTTP メソッド",
"httpMethodDesc": "プローブ要求に使う HTTP メソッド。",
"note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
}
}
}, },
"xray": { "xray": {
"importRules": "ルールをインポート", "importRules": "ルールをインポート",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Lista de permissões do limite de IP", "ipLimitAllowlist": "Lista de permissões do limite de IP",
"ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula." "ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula.",
"subBalancers": {
"menu": "Balanceadores de assinatura",
"title": "Balanceador de assinatura",
"add": "Adicionar balanceador",
"desc": "Cada balanceador ativo é adicionado à assinatura JSON como um perfil extra que escolhe automaticamente o melhor endpoint entre os inbounds selecionados.",
"remark": "Descrição",
"remarkPlaceholder": "Auto · mais rápido",
"strategy": "Estratégia",
"strategyLeastLoad": "Menor carga",
"strategyLeastPing": "Menor ping",
"strategyRandom": "Aleatório",
"strategyRoundRobin": "Round robin",
"sortOrder": "Ordem",
"sortOrderHelp": "Posição na lista da assinatura, intercalada com a ordem dos inbounds; em caso de empate, o balanceador vem depois do inbound.",
"inbounds": "Inbounds",
"inboundsCount": "{count} Inbounds",
"enabled": "Ativado",
"empty": "Ainda não há balanceadores",
"deleteConfirm": "Excluir este balanceador?",
"errRemarkRequired": "A descrição é obrigatória",
"errInboundsRequired": "Selecione ao menos um inbound",
"errSortOrder": "A ordem deve ser um inteiro ≥ 1",
"toasts": {
"list": "Falha ao listar os balanceadores de assinatura",
"create": "Falha ao criar o balanceador de assinatura",
"update": "Falha ao atualizar o balanceador de assinatura",
"delete": "Falha ao excluir o balanceador de assinatura",
"invalidId": "Id inválido"
},
"tabBalancers": "Balanceadores",
"tabObservatory": "Observatório",
"observatory": {
"title": "Observatório do balanceador",
"desc": "Parâmetros de probe para o burstObservatory embutido em cada perfil leastPing/leastLoad. random/roundRobin não geram observatório. Salvo como ajuste global da assinatura JSON.",
"destination": "URL de probe",
"destinationDesc": "Endereço que o cliente sonda para medir cada saída membro.",
"connectivity": "URL de conectividade",
"connectivityDesc": "Endereço opcional para verificar uma vez que o membro alcança o destino. Vazio para pular.",
"interval": "Intervalo de probe",
"intervalDesc": "Tempo entre rodadas de probe, p. ex. 1m.",
"timeout": "Tempo limite de probe",
"timeoutDesc": "Tempo limite de cada probe, p. ex. 5s.",
"sampling": "Amostragem",
"samplingDesc": "Número de probes consecutivos para média de estabilidade.",
"httpMethod": "Método HTTP",
"httpMethodDesc": "Método usado nas requisições de probe.",
"note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
}
}
}, },
"xray": { "xray": {
"importRules": "Importar regras", "importRules": "Importar regras",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Григорианский (обычный)", "calendarGregorian": "Григорианский (обычный)",
"calendarJalalian": "Джалали (شمسی)", "calendarJalalian": "Джалали (شمسی)",
"ipLimitAllowlist": "Доверенные адреса для лимита", "ipLimitAllowlist": "Доверенные адреса для лимита",
"ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть." "ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть.",
"subBalancers": {
"menu": "Балансировщики подписки",
"title": "Балансировщик подписки",
"add": "Добавить балансировщик",
"desc": "Каждый включённый балансировщик добавляется в JSON-подписку как отдельный профиль, автоматически выбирающий лучший из эндпоинтов выбранных инбаундов (routing.balancers + burstObservatory в клиентском конфиге).",
"remark": "Примечание",
"remarkPlaceholder": "Авто · самый быстрый",
"strategy": "Стратегия",
"strategyLeastLoad": "Минимальная нагрузка",
"strategyLeastPing": "Минимальный пинг",
"strategyRandom": "Случайный",
"strategyRoundRobin": "По очереди",
"sortOrder": "Порядок",
"sortOrderHelp": "Позиция в списке подписки, чередуется с порядком инбаундов; при равных номерах балансировщик идёт после инбаунда.",
"inbounds": "Инбаунды",
"inboundsCount": "{count} Инбаунды",
"enabled": "Включён",
"empty": "Балансировщиков пока нет",
"deleteConfirm": "Удалить этот балансировщик?",
"errRemarkRequired": "Укажите примечание",
"errInboundsRequired": "Выберите хотя бы один инбаунд",
"errSortOrder": "Порядок — целое число ≥ 1",
"toasts": {
"list": "Не удалось получить список балансировщиков подписки",
"create": "Не удалось создать балансировщик подписки",
"update": "Не удалось обновить балансировщик подписки",
"delete": "Не удалось удалить балансировщик подписки",
"invalidId": "Некорректный id"
},
"tabBalancers": "Балансировщик",
"tabObservatory": "Обсерватория",
"observatory": {
"title": "Обсерватория балансировщика",
"desc": "Параметры probe-запросов для burstObservatory, добавляемого в профили leastPing/leastLoad. random/roundRobin обходятся без обсерватории. Хранится как общая настройка JSON-подписки.",
"destination": "URL проверки",
"destinationDesc": "Адрес, по которому клиент проверяет доступность каждого участника.",
"connectivity": "URL связности",
"connectivityDesc": "Необязательный адрес для однократной проверки доступности цели. Оставьте пустым, чтобы пропустить.",
"interval": "Интервал проверок",
"intervalDesc": "Время между раундами проверок, например 1m.",
"timeout": "Тайм-аут проверки",
"timeoutDesc": "Тайм-аут одной проверки, например 5s.",
"sampling": "Выборка",
"samplingDesc": "Число подряд проверок для усреднения стабильности.",
"httpMethod": "HTTP-метод",
"httpMethodDesc": "Метод запросов при проверках.",
"note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
}
}
}, },
"xray": { "xray": {
"importRules": "Импорт правил", "importRules": "Импорт правил",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP limiti izin listesi", "ipLimitAllowlist": "IP limiti izin listesi",
"ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış)." "ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış).",
"subBalancers": {
"menu": "Abonelik dengeleyicileri",
"title": "Abonelik dengeleyici",
"add": "Dengeleyici ekle",
"desc": "Etkin her dengeleyici, seçilen inbound'ların uç noktalarından en iyisini otomatik seçen ek bir profil olarak JSON aboneliğine eklenir.",
"remark": "Açıklama",
"remarkPlaceholder": "Otomatik · en hızlı",
"strategy": "Strateji",
"strategyLeastLoad": "En düşük yük",
"strategyLeastPing": "En düşük ping",
"strategyRandom": "Rastgele",
"strategyRoundRobin": "Sıralı",
"sortOrder": "Sıra",
"sortOrderHelp": "Abonelik listesindeki konumu, inbound sırası ile iç içe yerleşir; eşit numarada dengeleyici inbound'dan sonra gelir.",
"inbounds": "Inbound'lar",
"inboundsCount": "{count} Inbound'lar",
"enabled": "Etkin",
"empty": "Henüz dengeleyici yok",
"deleteConfirm": "Bu dengeleyici silinsin mi?",
"errRemarkRequired": "Açıklama zorunludur",
"errInboundsRequired": "En az bir inbound seçin",
"errSortOrder": "Sıra 1 veya daha büyük bir tam sayı olmalı",
"toasts": {
"list": "Abonelik dengeleyicileri listelenemedi",
"create": "Abonelik dengeleyicisi oluşturulamadı",
"update": "Abonelik dengeleyicisi güncellenemedi",
"delete": "Abonelik dengeleyicisi silinemedi",
"invalidId": "Geçersiz id"
},
"tabBalancers": "Dengeleyiciler",
"tabObservatory": "Gözlemci",
"observatory": {
"title": "Dengeleyici gözlemi",
"desc": "Her leastPing/leastLoad dengeleyici profiline gömülen burstObservatory probe parametreleri. random/roundRobin için gözlem eklenmez. Paneller arası JSON abonelik ayarı olarak saklanır.",
"destination": "Probe URL'si",
"destinationDesc": "İstemcinin her üye çıkışı ölçmek için denediği adres.",
"connectivity": "Bağlantı URL'si",
"connectivityDesc": "Üyenin hedefe ulaşabildiğini tek kez doğrulamak için isteğe bağlı adres. Atlamak için boş bırakın.",
"interval": "Probe aralığı",
"intervalDesc": "Probe turları arasındaki süre, örn. 1m.",
"timeout": "Probe zaman aşımı",
"timeoutDesc": "Tek bir probe için zaman aşımı, örn. 5s.",
"sampling": "Örnekleme",
"samplingDesc": "Kararlılık ortalaması için ardışık probe sayısı.",
"httpMethod": "HTTP yöntemi",
"httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.",
"note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
}
}
}, },
"xray": { "xray": {
"save": "Kaydet", "save": "Kaydet",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Григоріанський (звичайний)", "calendarGregorian": "Григоріанський (звичайний)",
"calendarJalalian": "Джалалі (شمسی)", "calendarJalalian": "Джалалі (شمسی)",
"ipLimitAllowlist": "Довірені адреси для ліміту", "ipLimitAllowlist": "Довірені адреси для ліміту",
"ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа." "ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа.",
"subBalancers": {
"menu": "Балансувальники підписки",
"title": "Балансувальник підписки",
"add": "Додати балансувальник",
"desc": "Кожний увімкнений балансувальник додається до JSON-підписки як окремий профіль, що автоматично обирає найкращу з кінцевих точок вибраних інбаундів.",
"remark": "Примітка",
"remarkPlaceholder": "Авто · найшвидший",
"strategy": "Стратегія",
"strategyLeastLoad": "Найменше навантаження",
"strategyLeastPing": "Найменший ping",
"strategyRandom": "Випадково",
"strategyRoundRobin": "По черзі",
"sortOrder": "Порядок",
"sortOrderHelp": "Позиція у списку підписки, чергується з порядком інбаундів; за однакового номера йде після інбаунда.",
"inbounds": "Інбаунди",
"inboundsCount": "{count} Інбаунди",
"enabled": "Увімкнено",
"empty": "Балансувальників ще немає",
"deleteConfirm": "Видалити цей балансувальник?",
"errRemarkRequired": "Вкажіть примітку",
"errInboundsRequired": "Виберіть хоча б один інбаунд",
"errSortOrder": "Порядок — ціле число ≥ 1",
"toasts": {
"list": "Не вдалося отримати список балансувальників підписки",
"create": "Не вдалося створити балансувальник підписки",
"update": "Не вдалося оновити балансувальник підписки",
"delete": "Не вдалося видалити балансувальник підписки",
"invalidId": "Некоректний id"
},
"tabBalancers": "Балансери",
"tabObservatory": "Обсерваторія",
"observatory": {
"title": "Обсерваторія балансувальника",
"desc": "Параметри probe-запитів для burstObservatory, що додається у профілі leastPing/leastLoad. random/roundRobin обходяться без обсерваторії. Зберігається як загальна налаштування JSON-підписки.",
"destination": "URL перевірки",
"destinationDesc": "Адреса, за якою клієнт перевіряє доступність кожного учасника.",
"connectivity": "URL зв’язності",
"connectivityDesc": "Необов’язкова адреса для одноразової перевірки доступності цілі. Залиште порожнім, щоб пропустити.",
"interval": "Інтервал перевірок",
"intervalDesc": "Час між раундами перевірок, наприклад 1m.",
"timeout": "Тайм-аут перевірки",
"timeoutDesc": "Тайм-аут однієї перевірки, наприклад 5s.",
"sampling": "Вибірка",
"samplingDesc": "Кількість підряд перевірок для усереднення стабільності.",
"httpMethod": "HTTP-метод",
"httpMethodDesc": "Метод запитів під час перевірок.",
"note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
}
}
}, },
"xray": { "xray": {
"save": "Зберегти", "save": "Зберегти",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Danh sách cho phép của giới hạn IP", "ipLimitAllowlist": "Danh sách cho phép của giới hạn IP",
"ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy." "ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy.",
"subBalancers": {
"menu": "Bộ cân bằng đăng ký",
"title": "Bộ cân bằng đăng ký",
"add": "Thêm bộ cân bằng",
"desc": "Mỗi bộ cân bằng đang bật được thêm vào đăng ký JSON như một hồ sơ riêng, tự động chọn điểm cuối tốt nhất trong các inbound đã chọn.",
"remark": "Ghi chú",
"remarkPlaceholder": "Tự động · nhanh nhất",
"strategy": "Chiến lược",
"strategyLeastLoad": "Tải thấp nhất",
"strategyLeastPing": "Ping thấp nhất",
"strategyRandom": "Ngẫu nhiên",
"strategyRoundRobin": "Luân phiên",
"sortOrder": "Thứ tự",
"sortOrderHelp": "Vị trí trong danh sách đăng ký, xen kẽ với thứ tự inbound; khi cùng số, bộ cân bằng đứng sau inbound.",
"inbounds": "Inbound",
"inboundsCount": "{count} Inbound",
"enabled": "Đã bật",
"empty": "Chưa có bộ cân bằng nào",
"deleteConfirm": "Xóa bộ cân bằng này?",
"errRemarkRequired": "Cần nhập ghi chú",
"errInboundsRequired": "Chọn ít nhất một inbound",
"errSortOrder": "Thứ tự phải là số nguyên ≥ 1",
"toasts": {
"list": "Không thể liệt kê các bộ cân bằng đăng ký",
"create": "Không thể tạo bộ cân bằng đăng ký",
"update": "Không thể cập nhật bộ cân bằng đăng ký",
"delete": "Không thể xóa bộ cân bằng đăng ký",
"invalidId": "Id không hợp lệ"
},
"tabBalancers": "Cân bằng",
"tabObservatory": "Observatory",
"observatory": {
"title": "Đài quan sát bộ cân bằng",
"desc": "Tham số probe cho burstObservatory nhúng vào mỗi hồ sơ leastPing/leastLoad. random/roundRobin không có đài quan sát. Lưu thành cài đặt chung của đăng ký JSON.",
"destination": "URL probe",
"destinationDesc": "Địa chỉ client thăm dò để đo mỗi outbound thành viên.",
"connectivity": "URL kết nối",
"connectivityDesc": "Địa chỉ tuỳ chọn để kiểm tra một lần thành viên có tới đích được không. Để trống để bỏ qua.",
"interval": "Khoảng probe",
"intervalDesc": "Thời gian giữa các vòng probe, vd. 1m.",
"timeout": "Hết giờ probe",
"timeoutDesc": "Hết giờ cho mỗi probe, vd. 5s.",
"sampling": "Lấy mẫu",
"samplingDesc": "Số lần probe liên tiếp để trung bình độ ổn định.",
"httpMethod": "Phương thức HTTP",
"httpMethodDesc": "Phương thức dùng cho yêu cầu probe.",
"note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
}
}
}, },
"xray": { "xray": {
"importRules": "Nhập quy tắc", "importRules": "Nhập quy tắc",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP 限制白名单", "ipLimitAllowlist": "IP 限制白名单",
"ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。" "ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。",
"subBalancers": {
"menu": "订阅均衡器",
"title": "订阅均衡器",
"add": "添加均衡器",
"desc": "每个启用的均衡器会作为额外配置加入 JSON 订阅,自动在所选入站的端点中选择最优节点(客户端配置中的 routing.balancers + burstObservatory)。",
"remark": "备注",
"remarkPlaceholder": "自动 · 最快",
"strategy": "策略",
"strategyLeastLoad": "最小负载",
"strategyLeastPing": "最低延迟",
"strategyRandom": "随机",
"strategyRoundRobin": "轮询",
"sortOrder": "顺序",
"sortOrderHelp": "在订阅列表中的位置,与入站顺序交错排列;序号相同时排在入站之后。",
"inbounds": "入站",
"inboundsCount": "{count} 入站",
"enabled": "启用",
"empty": "暂无均衡器",
"deleteConfirm": "确定删除此均衡器?",
"errRemarkRequired": "请填写备注",
"errInboundsRequired": "请至少选择一个入站",
"errSortOrder": "顺序必须为不小于 1 的整数",
"toasts": {
"list": "列出订阅均衡器失败",
"create": "创建订阅均衡器失败",
"update": "更新订阅均衡器失败",
"delete": "删除订阅均衡器失败",
"invalidId": "无效的 id"
},
"tabBalancers": "负载均衡",
"tabObservatory": "观测器",
"observatory": {
"title": "均衡器探活",
"desc": "写入每个 leastPing/leastLoad 均衡器配置的 burstObservatory 探活参数。random/roundRobin 不生成探活。作为面板级 JSON 订阅设置保存。",
"destination": "探活 URL",
"destinationDesc": "客户端探测每个成员出站的地址。",
"connectivity": "连通性 URL",
"connectivityDesc": "可选地址,检查成员能否到达探活目标。留空则跳过。",
"interval": "探活间隔",
"intervalDesc": "探活轮次之间的时间,例如 1m。",
"timeout": "探活超时",
"timeoutDesc": "单次探活超时,例如 5s。",
"sampling": "采样",
"samplingDesc": "用于稳定度平均的连续探活次数。",
"httpMethod": "HTTP 方法",
"httpMethodDesc": "探活请求使用的 HTTP 方法。",
"note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
}
}
}, },
"xray": { "xray": {
"importRules": "导入规则", "importRules": "导入规则",
+50 -1
View File
@@ -1386,7 +1386,56 @@
"calendarGregorian": "Gregorian (Standard)", "calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)", "calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP 限制白名單", "ipLimitAllowlist": "IP 限制白名單",
"ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。" "ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。",
"subBalancers": {
"menu": "訂閱平衡器",
"title": "訂閱平衡器",
"add": "新增平衡器",
"desc": "每個啟用的平衡器會作為額外設定加入 JSON 訂閱,自動從所選入站的端點中挑選最佳節點(用戶端設定中的 routing.balancers + burstObservatory)。",
"remark": "備註",
"remarkPlaceholder": "自動 · 最快",
"strategy": "策略",
"strategyLeastLoad": "最小負載",
"strategyLeastPing": "最低延遲",
"strategyRandom": "隨機",
"strategyRoundRobin": "輪詢",
"sortOrder": "順序",
"sortOrderHelp": "在訂閱列表中的位置,與入站順序交錯排列;序號相同時排在入站之後。",
"inbounds": "入站",
"inboundsCount": "{count} 入站",
"enabled": "啟用",
"empty": "尚無平衡器",
"deleteConfirm": "確定刪除此平衡器?",
"errRemarkRequired": "請填寫備註",
"errInboundsRequired": "請至少選擇一個入站",
"errSortOrder": "順序必須為不小於 1 的整數",
"toasts": {
"list": "列出訂閱平衡器失敗",
"create": "建立訂閱平衡器失敗",
"update": "更新訂閱平衡器失敗",
"delete": "刪除訂閱平衡器失敗",
"invalidId": "無效的 id"
},
"tabBalancers": "負載均衡",
"tabObservatory": "觀測器",
"observatory": {
"title": "平衡器探活",
"desc": "寫入每個 leastPing/leastLoad 平衡器設定檔的 burstObservatory 探活參數。random/roundRobin 不產生探活。以面板級 JSON 訂閱設定儲存。",
"destination": "探活 URL",
"destinationDesc": "用戶端探測每個成員出站的位址。",
"connectivity": "連通性 URL",
"connectivityDesc": "選用位址,檢查成員能否到達探活目標。留空則跳過。",
"interval": "探活間隔",
"intervalDesc": "探活輪次之間的時間,例如 1m。",
"timeout": "探活逾時",
"timeoutDesc": "單次探活逾時,例如 5s。",
"sampling": "取樣",
"samplingDesc": "用於穩定度平均的連續探活次數。",
"httpMethod": "HTTP 方法",
"httpMethodDesc": "探活請求使用的 HTTP 方法。",
"note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
}
}
}, },
"xray": { "xray": {
"save": "儲存", "save": "儲存",
+1
View File
@@ -39,6 +39,7 @@ func run(root, outDir string) error {
"ClientInbound", "ClientInbound",
"InboundFallback", "InboundFallback",
"Host", "Host",
"SubBalancer",
), ),
AliasAllow: setOf("Protocol"), AliasAllow: setOf("Protocol"),
Overrides: map[string][]walkOverride{ Overrides: map[string][]walkOverride{