mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 20:07:13 +00:00
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:
@@ -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 <token></code> on any
|
|
||||||
/panel/api/* request — the token is a full-admin credential.'
|
|
||||||
full: true
|
full: true
|
||||||
_openapi:
|
_openapi:
|
||||||
preload:
|
preload:
|
||||||
|
|||||||
@@ -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 client’s up/down counters. Re-enables the client across
|
title: Zero out a single client’s 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 client’s up/down counters. Re-enables the client
|
- content: Zero out a single client’s 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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 host’s content. The inbound and sort order are immutable here
|
title: Replace a host group’s 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 group’s content.
|
||||||
- content: Replace a host’s 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 panel’s "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 panel’s "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",
|
||||||
|
|||||||
@@ -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 node’s connection details. Same body shape as /add.
|
title: 'Replace a node’s 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 node’s 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 node’s 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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 that’s currently running on this host.
|
title: Return the assembled Xray config that’s 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 that’s currently running on this host.
|
- content: Return the assembled Xray config that’s 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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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']>>(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export class AllSetting {
|
|||||||
subJsonMux = '';
|
subJsonMux = '';
|
||||||
subJsonRules = '';
|
subJsonRules = '';
|
||||||
subJsonFinalMask = '';
|
subJsonFinalMask = '';
|
||||||
|
subJsonObservatory = '';
|
||||||
subThemeDir = '';
|
subThemeDir = '';
|
||||||
subHideSettings = false;
|
subHideSettings = false;
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,6 +84,7 @@ func allModels() []any {
|
|||||||
&model.NodeClientIp{},
|
&model.NodeClientIp{},
|
||||||
&model.ClientGlobalTraffic{},
|
&model.ClientGlobalTraffic{},
|
||||||
&model.OutboundSubscription{},
|
&model.OutboundSubscription{},
|
||||||
|
&model.SubBalancer{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func migrationModels() []any {
|
|||||||
&model.NodeClientIp{},
|
&model.NodeClientIp{},
|
||||||
&model.ClientGlobalTraffic{},
|
&model.ClientGlobalTraffic{},
|
||||||
&model.OutboundSubscription{},
|
&model.OutboundSubscription{},
|
||||||
|
&model.SubBalancer{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "احفظ",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "ذخیره",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "ルールをインポート",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Импорт правил",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Зберегти",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "导入规则",
|
||||||
|
|||||||
@@ -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": "儲存",
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user