From da01b7637dc317098e84ef54008fafdb722aaf35 Mon Sep 17 00:00:00 2001 From: DIMFLIX Date: Sun, 23 Aug 2026 23:34:20 +0300 Subject: [PATCH] feat(sub): client-side balancers for the JSON subscription (#6243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- CLAUDE.md | 5 +- .../docs/en/reference/api/api-tokens.mdx | 10 +- .../content/docs/en/reference/api/clients.mdx | 147 +- docs/content/docs/en/reference/api/hosts.mdx | 85 +- .../docs/en/reference/api/inbounds.mdx | 33 +- docs/content/docs/en/reference/api/meta.json | 1 + docs/content/docs/en/reference/api/nodes.mdx | 46 +- docs/content/docs/en/reference/api/server.mdx | 79 +- .../docs/en/reference/api/settings.mdx | 20 +- .../reference/api/subscription-balancers.mdx | 60 + .../en/reference/api/subscription-server.mdx | 14 +- .../docs/en/reference/api/xray-settings.mdx | 60 +- docs/lib/xray/subscription.ts | 2 + docs/public/openapi.json | 3381 ++++++++++++++--- frontend/public/openapi.json | 351 ++ .../api/queries/useSubBalancerMutations.ts | 41 + .../src/api/queries/useSubBalancersQuery.ts | 31 + frontend/src/api/queryKeys.ts | 4 + frontend/src/generated/examples.ts | 15 + frontend/src/generated/schemas.ts | 73 + frontend/src/generated/types.ts | 13 + frontend/src/generated/zod.ts | 14 + frontend/src/layouts/AppSidebar.tsx | 11 +- frontend/src/models/setting.ts | 1 + frontend/src/pages/api-docs/endpoints.ts | 78 + frontend/src/pages/settings/SettingsPage.tsx | 4 + .../pages/settings/SubBalancerFormModal.tsx | 172 + .../settings/SubscriptionBalancersTab.tsx | 359 ++ .../pages/xray/balancers/balancer-helpers.ts | 2 +- frontend/src/schemas/observatory.ts | 2 +- frontend/src/schemas/setting.ts | 1 + frontend/src/schemas/subBalancer.ts | 36 + .../src/test/sub-balancer-form-modal.test.tsx | 136 + internal/database/db.go | 1 + internal/database/migrate_data.go | 1 + internal/database/model/model.go | 15 + internal/sub/controller.go | 9 +- internal/sub/json_service.go | 306 +- internal/sub/sub.go | 6 + .../sub/sub_balancer_protocol_tag_test.go | 60 + internal/sub/sub_balancer_test.go | 422 ++ internal/sub/sub_json_observatory_test.go | 53 + internal/web/controller/api.go | 3 + internal/web/controller/sub_balancer.go | 126 + internal/web/controller/sub_balancer_test.go | 105 + internal/web/entity/entity.go | 1 + internal/web/service/inbound.go | 17 + internal/web/service/setting.go | 5 + internal/web/service/sub_balancer.go | 101 + .../sub_balancer_inbound_cleanup_test.go | 36 + internal/web/service/sub_balancer_test.go | 161 + internal/web/translation/ar-EG.json | 51 +- internal/web/translation/en-US.json | 51 +- internal/web/translation/es-ES.json | 51 +- internal/web/translation/fa-IR.json | 51 +- internal/web/translation/id-ID.json | 51 +- internal/web/translation/ja-JP.json | 51 +- internal/web/translation/pt-BR.json | 51 +- internal/web/translation/ru-RU.json | 51 +- internal/web/translation/tr-TR.json | 51 +- internal/web/translation/uk-UA.json | 51 +- internal/web/translation/vi-VN.json | 51 +- internal/web/translation/zh-CN.json | 51 +- internal/web/translation/zh-TW.json | 51 +- tools/openapigen/main.go | 1 + 65 files changed, 6587 insertions(+), 792 deletions(-) create mode 100644 docs/content/docs/en/reference/api/subscription-balancers.mdx create mode 100644 frontend/src/api/queries/useSubBalancerMutations.ts create mode 100644 frontend/src/api/queries/useSubBalancersQuery.ts create mode 100644 frontend/src/pages/settings/SubBalancerFormModal.tsx create mode 100644 frontend/src/pages/settings/SubscriptionBalancersTab.tsx create mode 100644 frontend/src/schemas/subBalancer.ts create mode 100644 frontend/src/test/sub-balancer-form-modal.test.tsx create mode 100644 internal/sub/sub_balancer_protocol_tag_test.go create mode 100644 internal/sub/sub_balancer_test.go create mode 100644 internal/sub/sub_json_observatory_test.go create mode 100644 internal/web/controller/sub_balancer.go create mode 100644 internal/web/controller/sub_balancer_test.go create mode 100644 internal/web/service/sub_balancer.go create mode 100644 internal/web/service/sub_balancer_inbound_cleanup_test.go create mode 100644 internal/web/service/sub_balancer_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0bf942f3a..8e3f1bd5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ file locations when it can answer in one hop. ## Frontend conventions (summary; full version in frontend/CLAUDE.md) - 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 hand-write. Do not edit `src/generated/`. - 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 `go test ./...`. Run `make help` for all targets. The local gate: - make verify # gen-check + lint + format-check + typecheck + test + build - # + build-storybook + make verify # gen-check + lint + typecheck + test + build + build-storybook 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 diff --git a/docs/content/docs/en/reference/api/api-tokens.mdx b/docs/content/docs/en/reference/api/api-tokens.mdx index 791241e2c..f47ca9f2d 100644 --- a/docs/content/docs/en/reference/api/api-tokens.mdx +++ b/docs/content/docs/en/reference/api/api-tokens.mdx @@ -1,12 +1,8 @@ --- title: API Tokens -description: 'Manage Bearer tokens used for programmatic auth (bots, central - panels acting on this node, CI). Each token has a unique name and an enabled - flag — disable to revoke without deleting, delete to revoke permanently. - 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 Authorization: Bearer <token> on any - /panel/api/* request — the token is a full-admin credential.' +description: Manage scoped Bearer tokens for programmatic auth. Tokens grant + admin, monitor, or node-sync access, may expire, and are stored as SHA-256 + hashes. The plaintext is returned only once at creation. full: true _openapi: preload: diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index d4e9f8269..b91035dc2 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -14,18 +14,24 @@ _openapi: 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 - title: 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 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 /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-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal' + title: '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 + 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 /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 title: 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 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 @@ -48,10 +54,10 @@ _openapi: 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' - depth: 2 - title: Replace a client's 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-per-client-share-links-and-remote-subscription-urls-surfaced-in-their-subscription-sends-the-full-set-the-server-replaces-all-rows' + title: Replace a client's external links and external subscriptions. Sends the + full set; the server replaces all rows. Disabled rows stay saved for + editing but are not emitted in generated subscriptions. + url: '#replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions' - depth: 2 title: Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually @@ -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' - depth: 2 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 - 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-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone' + 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. + 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 title: Return every client as a {client, inboundIds} array — the same shape /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. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) 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-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-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' + 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-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 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 @@ -188,6 +198,13 @@ _openapi: 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 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 @@ -204,6 +221,17 @@ _openapi: - depth: 2 title: 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 title: List the emails of currently connected clients (last seen within the heartbeat window), deduped across every node. @@ -248,34 +276,28 @@ _openapi: 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: headings: - 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 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 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 /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-page-size-capped-at-200-fetch-getemail-to-obtain-the-full-per-client-payload-for-an-editinfo-modal + 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 /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 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 call. Body is JSON. Per-protocol secrets are generated server-side 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 - 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 - - content: Replace a client's 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-per-client-share-links-and-remote-subscription-urls-surfaced-in-their-subscription-sends-the-full-set-the-server-replaces-all-rows + - content: Replace a client's external links and external subscriptions. Sends the + full set; the server replaces all rows. Disabled rows stay saved for + editing but are not emitted in generated subscriptions. + id: replace-a-clients-external-links-and-external-subscriptions-sends-the-full-set-the-server-replaces-all-rows-disabled-rows-stay-saved-for-editing-but-are-not-emitted-in-generated-subscriptions - content: Reset the up/down counters for every client globally. Quotas and expiry are not affected. Triggers an Xray restart if any counter actually moved. @@ -307,10 +329,10 @@ _openapi: 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 - 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-and-external-links-useful-for-clearing-clients-left-unattached-after-their-inbounds-were-removed-returns-the-deleted-count-cannot-be-undone + 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. + 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 /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included @@ -329,11 +351,15 @@ _openapi: addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) 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" + 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 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-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 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 @@ -417,6 +443,13 @@ _openapi: /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 across every attached inbound and pushes the change to Xray (or the 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 - content: 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 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, 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: - content: >- Fields the server fills in when they are omitted — a valid value sent @@ -536,7 +569,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/hosts.mdx b/docs/content/docs/en/reference/api/hosts.mdx index 3f58b5f99..92dc7ac0e 100644 --- a/docs/content/docs/en/reference/api/hosts.mdx +++ b/docs/content/docs/en/reference/api/hosts.mdx @@ -13,66 +13,65 @@ _openapi: sort order. url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order' - depth: 2 - title: Fetch a single host by ID. - url: '#fetch-a-single-host-by-id' + title: Fetch a single host group by Group ID. + url: '#fetch-a-single-host-group-by-group-id' - depth: 2 - title: Fetch one inbound's hosts, ordered by sort order then id. - url: '#fetch-one-inbounds-hosts-ordered-by-sort-order-then-id' + title: Fetch one inbound's hosts, grouped by host group. + url: '#fetch-one-inbounds-hosts-grouped-by-host-group' - depth: 2 title: Distinct, sorted set of tags used across all hosts. url: '#distinct-sorted-set-of-tags-used-across-all-hosts' - depth: 2 - title: Create a host on an inbound. inboundId and remark are required; security - defaults to "same" (inherit the inbound). - url: '#create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound' + title: Create a host group on inbounds. + url: '#create-a-host-group-on-inbounds' - depth: 2 - title: Replace a host’s content. The inbound and sort order are immutable here - (use /reorder for ordering). - url: '#replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering' + title: Replace a host group’s content. + url: '#replace-a-host-groups-content' - depth: 2 - title: Delete a host. - url: '#delete-a-host' + title: Delete a host group. + url: '#delete-a-host-group' - depth: 2 - title: Enable or disable a single host (disabled hosts are skipped in - subscriptions). - url: '#enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions' + title: Enable or disable a host group. + url: '#enable-or-disable-a-host-group' - depth: 2 - title: Set host sort order by the position of each id in the array. - url: '#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-group-sort-order-by-the-position-of-each-groupid-in-the-array' - depth: 2 - title: Enable or disable many hosts in one call. - url: '#enable-or-disable-many-hosts-in-one-call' + title: Add a host group to inbounds (same as /add). + url: '#add-a-host-group-to-inbounds-same-as-add' - depth: 2 - title: Delete many hosts in one call. - url: '#delete-many-hosts-in-one-call' + title: Enable or disable many host groups 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: headings: - content: 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. - id: fetch-a-single-host-by-id - - content: Fetch one inbound's hosts, ordered by sort order then id. - id: fetch-one-inbounds-hosts-ordered-by-sort-order-then-id + - content: Fetch a single host group by Group ID. + id: fetch-a-single-host-group-by-group-id + - content: Fetch one inbound's hosts, grouped by host group. + id: fetch-one-inbounds-hosts-grouped-by-host-group - content: 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; - security defaults to "same" (inherit the inbound). - id: create-a-host-on-an-inbound-inboundid-and-remark-are-required-security-defaults-to-same-inherit-the-inbound - - content: Replace a host’s content. The inbound and sort order are immutable here - (use /reorder for ordering). - id: replace-a-hosts-content-the-inbound-and-sort-order-are-immutable-here-use-reorder-for-ordering - - content: Delete a host. - id: delete-a-host - - content: Enable or disable a single host (disabled hosts are skipped in - subscriptions). - id: enable-or-disable-a-single-host-disabled-hosts-are-skipped-in-subscriptions - - content: Set host sort order by the position of each id in the array. - id: set-host-sort-order-by-the-position-of-each-id-in-the-array - - content: Enable or disable many hosts in one call. - id: enable-or-disable-many-hosts-in-one-call - - content: Delete many hosts in one call. - id: delete-many-hosts-in-one-call + - content: Create a host group on inbounds. + id: create-a-host-group-on-inbounds + - content: Replace a host group’s content. + id: replace-a-host-groups-content + - content: Delete a host group. + id: delete-a-host-group + - content: Enable or disable a host group. + id: enable-or-disable-a-host-group + - content: Set host group sort order by the position of each groupId in the array. + id: set-host-group-sort-order-by-the-position-of-each-groupid-in-the-array + - content: Add a host group to inbounds (same as /add). + id: add-a-host-group-to-inbounds-same-as-add + - content: Enable or disable many host groups in one call. + id: enable-or-disable-many-host-groups-in-one-call + - content: Delete many host groups in one call. + id: delete-many-host-groups-in-one-call contents: [] --- @@ -85,7 +84,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/inbounds.mdx b/docs/content/docs/en/reference/api/inbounds.mdx index 9ca337ea2..9f3ff6b8d 100644 --- a/docs/content/docs/en/reference/api/inbounds.mdx +++ b/docs/content/docs/en/reference/api/inbounds.mdx @@ -33,6 +33,15 @@ _openapi: 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 title: 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. 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 title: Zero out upload + download counters for a single inbound. Does not touch per-client counters. @@ -94,10 +107,6 @@ _openapi: title: 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: headings: - 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 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. id: fetch-a-single-inbound-by-numeric-id - 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 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 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. 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: [] --- @@ -184,7 +201,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/meta.json b/docs/content/docs/en/reference/api/meta.json index 6c8200d7c..b21d606de 100644 --- a/docs/content/docs/en/reference/api/meta.json +++ b/docs/content/docs/en/reference/api/meta.json @@ -11,6 +11,7 @@ "settings", "xray-settings", "subscription-server", + "subscription-balancers", "hosts", "nodes", "backup", diff --git a/docs/content/docs/en/reference/api/nodes.mdx b/docs/content/docs/en/reference/api/nodes.mdx index 1059c0ea5..6cf408989 100644 --- a/docs/content/docs/en/reference/api/nodes.mdx +++ b/docs/content/docs/en/reference/api/nodes.mdx @@ -22,6 +22,11 @@ _openapi: 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. 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 title: Fetch a single node by ID. url: '#fetch-a-single-node-by-id' @@ -32,12 +37,15 @@ _openapi: 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 - title: Register a new remote node. Provide its URL, apiToken, and optional - remark / allowPrivateAddress flag. - url: '#register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag' + title: Register a new remote node. Provide its URL, write-only apiToken, and + optional remark / allowPrivateAddress flag. Responses expose hasApiToken + only. + url: '#register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only' - depth: 2 - title: Replace a node’s connection details. Same body shape as /add. - url: '#replace-a-nodes-connection-details-same-body-shape-as-add' + title: '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.' + 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 title: 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, 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: headings: - 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 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. id: fetch-a-single-node-by-id - 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 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 - remark / allowPrivateAddress flag. - id: register-a-new-remote-node-provide-its-url-apitoken-and-optional-remark--allowprivateaddress-flag - - content: Replace a node’s connection details. Same body shape as /add. - id: replace-a-nodes-connection-details-same-body-shape-as-add + - content: Register a new remote node. Provide its URL, write-only apiToken, and + optional remark / allowPrivateAddress flag. Responses expose + hasApiToken only. + id: register-a-new-remote-node-provide-its-url-write-only-apitoken-and-optional-remark--allowprivateaddress-flag-responses-expose-hasapitoken-only + - 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. id: delete-a-node-inbounds-bound-to-it-are-not-auto-migrated - 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, 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: [] --- @@ -145,7 +151,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/server.mdx b/docs/content/docs/en/reference/api/server.mdx index f26e795fc..fb6449b62 100644 --- a/docs/content/docs/en/reference/api/server.mdx +++ b/docs/content/docs/en/reference/api/server.mdx @@ -7,6 +7,12 @@ _openapi: preload: - ./public/openapi.json 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 title: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 @@ -49,12 +55,19 @@ _openapi: - depth: 2 title: 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 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' - depth: 2 - title: Stream the SQLite database file as an attachment. Use as a manual backup. - url: '#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 + 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 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 @@ -123,9 +136,12 @@ _openapi: title: Return the last N lines of the Xray process log. url: '#return-the-last-n-lines-of-the-xray-process-log' - depth: 2 - title: Restore the panel DB from an uploaded SQLite file (multipart form, field - name "db"). The panel restarts after restore. Destructive. - url: '#restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive' + title: 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. + 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 title: Generate a new ECH (Encrypted Client Hello) keypair and config list for the given SNI. @@ -139,6 +155,20 @@ _openapi: title: Run `xray tls ping` against a remote server and return its live 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' + - 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 title: Fetch the fully aggregated inbound_client_ips database table. Used by 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' structuredData: 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 averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.' @@ -181,11 +216,16 @@ _openapi: id: list-xray-binary-versions-available-for-install-on-this-host - content: 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. 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 - backup. - id: stream-the-sqlite-database-file-as-an-attachment-use-as-a-manual-backup + - content: '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.' + 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 text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.' @@ -236,9 +276,12 @@ _openapi: id: return-the-last-n-lines-of-the-panels-own-log - content: 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, - field name "db"). The panel restarts after restore. Destructive. - id: restore-the-panel-db-from-an-uploaded-sqlite-file-multipart-form-field-name-db-the-panel-restarts-after-restore-destructive + - content: 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. + 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 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 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 + - 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 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 ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/settings.mdx b/docs/content/docs/en/reference/api/settings.mdx index 00be633e1..46a721191 100644 --- a/docs/content/docs/en/reference/api/settings.mdx +++ b/docs/content/docs/en/reference/api/settings.mdx @@ -15,11 +15,21 @@ _openapi: title: 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 title: 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 title: Change the panel admin username and password. Requires the current 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 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 /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 credentials for verification. The session is refreshed with the new values on success. @@ -83,7 +101,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/subscription-balancers.mdx b/docs/content/docs/en/reference/api/subscription-balancers.mdx new file mode 100644 index 000000000..b1b655cd6 --- /dev/null +++ b/docs/content/docs/en/reference/api/subscription-balancers.mdx @@ -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} + + + ); +} \ No newline at end of file diff --git a/docs/content/docs/en/reference/api/subscription-server.mdx b/docs/content/docs/en/reference/api/subscription-server.mdx index 15a4c3a54..ee6aef0e4 100644 --- a/docs/content/docs/en/reference/api/subscription-server.mdx +++ b/docs/content/docs/en/reference/api/subscription-server.mdx @@ -13,9 +13,10 @@ _openapi: - depth: 2 title: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html - header or ?html=1, renders a styled info page instead. 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-default-path-subsubid' + header or ?html=1, renders a styled info page instead. With + ?format=info, returns the page view-model as JSON (traffic, expiry, + 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 title: 'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. Default @@ -30,9 +31,10 @@ _openapi: headings: - content: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: - text/html header or ?html=1, renders a styled info page instead. - 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-default-path-subsubid + text/html header or ?html=1, renders a styled info page instead. With + ?format=info, returns the page view-model as JSON (traffic, expiry, + 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 client). Only when JSON subscription is enabled in settings. Default path: /json/:subid.' diff --git a/docs/content/docs/en/reference/api/xray-settings.mdx b/docs/content/docs/en/reference/api/xray-settings.mdx index 1ce619437..92567e59c 100644 --- a/docs/content/docs/en/reference/api/xray-settings.mdx +++ b/docs/content/docs/en/reference/api/xray-settings.mdx @@ -1,8 +1,7 @@ --- title: 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. full: true _openapi: preload: @@ -37,7 +36,8 @@ _openapi: title: Manage NordVPN integration. The action parameter selects the operation. url: '#manage-nordvpn-integration-the-action-parameter-selects-the-operation' - 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' - depth: 2 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 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' + - 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 title: List all outbound subscriptions (remote URLs that supply additional outbounds), newest first. @@ -83,9 +102,9 @@ _openapi: title: Delete an outbound subscription by id. url: '#delete-an-outbound-subscription-by-id' - depth: 2 - title: 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-axios-friendly-clients' + title: Delete an outbound subscription by id (POST alias of DELETE for clients + that cannot send DELETE). + url: '#delete-an-outbound-subscription-by-id-post-alias-of-delete-for-clients-that-cannot-send-delete' - depth: 2 title: Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload. @@ -121,8 +140,7 @@ _openapi: id: manage-cloudflare-warp-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 - - content: >- - Manage PIA WireGuard integration. The action parameter selects the + - content: 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. @@ -147,6 +165,22 @@ _openapi: - content: Ask the running core which outbound its router would pick for a 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 + - 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 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 - content: 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 - axios-friendly clients). - id: delete-an-outbound-subscription-by-id-post-alias-of-delete-for-axios-friendly-clients + - content: Delete an outbound subscription by id (POST alias of DELETE for clients + that cannot send DELETE). + 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 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 ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/lib/xray/subscription.ts b/docs/lib/xray/subscription.ts index 6265327f0..19c88e8a9 100644 --- a/docs/lib/xray/subscription.ts +++ b/docs/lib/xray/subscription.ts @@ -241,6 +241,8 @@ function proxyOutbound(c: SubClient): Record { }; } +// 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 { return { remarks: c.remark, diff --git a/docs/public/openapi.json b/docs/public/openapi.json index cbc458191..ba242f8c6 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -27,23 +27,21 @@ }, "schemas": { "AllSetting": { - "description": "AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.", "properties": { "datepicker": { - "description": "Date picker format", "type": "string" }, "expireDiff": { - "description": "Expiration warning threshold in days", "minimum": 0, "type": "integer" }, "externalTrafficInformEnable": { - "description": "Enable external traffic reporting", "type": "boolean" }, "externalTrafficInformURI": { - "description": "URI for external traffic reporting", + "type": "string" + }, + "ipLimitAllowlist": { "type": "string" }, "ldapAutoCreate": { @@ -71,11 +69,9 @@ "type": "integer" }, "ldapEnable": { - "description": "LDAP settings", "type": "boolean" }, "ldapFlagField": { - "description": "Generic flag configuration", "type": "string" }, "ldapHost": { @@ -84,6 +80,9 @@ "ldapInboundTags": { "type": "string" }, + "ldapInsecureSkipVerify": { + "type": "boolean" + }, "ldapInvertFlag": { "type": "boolean" }, @@ -105,7 +104,6 @@ "type": "boolean" }, "ldapUserAttr": { - "description": "e.g., mail or uid", "type": "string" }, "ldapUserFilter": { @@ -114,306 +112,267 @@ "ldapVlessField": { "type": "string" }, + "outboundDownThreshold": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, "pageSize": { - "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", "maximum": 1000, "minimum": 0, "type": "integer" }, "panelOutbound": { - "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)", "type": "string" }, "remarkTemplate": { - "description": "Subscription remark template ({{VAR}} tokens) rendered per client", "type": "string" }, "restartXrayOnClientDisable": { - "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit", "type": "boolean" }, "sessionMaxAge": { - "description": "Session maximum age in minutes (cap at one year)", "maximum": 525600, "minimum": 1, "type": "integer" }, "smtpCpu": { - "description": "CPU threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpEnable": { - "description": "Email (SMTP) notification settings\nEnable email notifications", "type": "boolean" }, "smtpEnabledEvents": { - "description": "Comma-separated event types to send via email", "type": "string" }, "smtpEncryptionType": { - "description": "SMTP encryption: none, starttls, tls", + "type": "string" + }, + "smtpFrom": { + "type": "string" + }, + "smtpFromName": { "type": "string" }, "smtpHost": { - "description": "SMTP server host", "type": "string" }, "smtpMemory": { - "description": "Memory threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpPassword": { - "description": "SMTP password", "type": "string" }, "smtpPort": { - "description": "SMTP server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "smtpTo": { - "description": "Comma-separated recipient emails", "type": "string" }, "smtpUsername": { - "description": "SMTP username", "type": "string" }, "subAnnounce": { - "description": "Subscription announce", "type": "string" }, "subCertFile": { - "description": "SSL certificate file for subscription server", "type": "string" }, + "subClashAutoDetect": { + "type": "boolean" + }, "subClashEnable": { - "description": "Enable Clash/Mihomo subscription endpoint", "type": "boolean" }, "subClashEnableRouting": { - "description": "Enable global routing rules for Clash/Mihomo", "type": "boolean" }, "subClashPath": { - "description": "Path for Clash/Mihomo subscription endpoint", "type": "string" }, "subClashRules": { - "description": "Clash/Mihomo global routing rules", "type": "string" }, "subClashURI": { - "description": "Clash/Mihomo subscription server URI", + "type": "string" + }, + "subClashUserAgentRegex": { "type": "string" }, "subDomain": { - "description": "Domain for subscription server validation", "type": "string" }, "subEnable": { - "description": "Subscription server settings\nEnable subscription server", "type": "boolean" }, "subEnableRouting": { - "description": "Enable routing for subscription", "type": "boolean" }, "subEncrypt": { - "description": "Encrypt subscription responses", "type": "boolean" }, "subHideSettings": { - "description": "Hide server settings in happ subscription (Only for Happ)", "type": "boolean" }, "subIncyEnableRouting": { - "description": "Enable routing injection for the Incy client", "type": "boolean" }, "subIncyRoutingRules": { - "description": "Incy routing deep-link injected into the subscription body (Only for Incy)", "type": "string" }, + "subJsonAlwaysArray": { + "type": "boolean" + }, + "subJsonAutoDetect": { + "type": "boolean" + }, "subJsonEnable": { - "description": "Enable JSON subscription endpoint", "type": "boolean" }, "subJsonFinalMask": { - "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)", "type": "string" }, "subJsonMux": { - "description": "JSON subscription mux configuration", + "type": "string" + }, + "subJsonObservatory": { "type": "string" }, "subJsonPath": { - "description": "Path for JSON subscription endpoint", "type": "string" }, "subJsonRules": { "type": "string" }, "subJsonURI": { - "description": "JSON subscription server URI", + "type": "string" + }, + "subJsonUserAgentRegex": { "type": "string" }, "subKeyFile": { - "description": "SSL private key file for subscription server", "type": "string" }, "subListen": { - "description": "Subscription server listen IP", "type": "string" }, "subPath": { - "description": "Base path for subscription URLs", "type": "string" }, "subPort": { - "description": "Subscription server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "subProfileUrl": { - "description": "Subscription profile URL", "type": "string" }, "subRoutingRules": { - "description": "Subscription global routing rules (Only for Happ)", "type": "string" }, + "subShowIdentityOnAllLinks": { + "type": "boolean" + }, "subSupportUrl": { - "description": "Subscription support URL", "type": "string" }, "subThemeDir": { - "description": "Absolute path to a folder containing a custom subscription page template", "type": "string" }, "subTitle": { - "description": "Subscription title", "type": "string" }, "subURI": { - "description": "Subscription server URI", "type": "string" }, "subUpdates": { - "description": "Subscription update interval in minutes", "maximum": 525600, "minimum": 0, "type": "integer" }, "tgBotAPIServer": { - "description": "Custom API server for Telegram bot", "type": "string" }, "tgBotBackup": { - "description": "Enable database backup via Telegram", "type": "boolean" }, "tgBotChatId": { - "description": "Telegram chat ID for notifications", "type": "string" }, "tgBotEnable": { - "description": "Telegram bot settings\nEnable Telegram bot notifications", "type": "boolean" }, "tgBotProxy": { - "description": "Proxy URL for Telegram bot", "type": "string" }, "tgBotToken": { - "description": "Telegram bot token", "type": "string" }, "tgCpu": { - "description": "CPU usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "tgEnabledEvents": { - "description": "Comma-separated event types to send via Telegram", "type": "string" }, "tgLang": { - "description": "Telegram bot language", "type": "string" }, "tgMemory": { - "description": "Memory usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "tgRunTime": { - "description": "Cron schedule for Telegram notifications", "type": "string" }, "timeLocation": { - "description": "Security settings\nTime zone location", "type": "string" }, "trafficDiff": { - "description": "Traffic warning threshold percentage", "maximum": 100, "minimum": 0, "type": "integer" }, "trustedProxyCIDRs": { - "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers", "type": "string" }, "twoFactorEnable": { - "description": "Enable two-factor authentication", "type": "boolean" }, "twoFactorToken": { - "description": "Two-factor authentication token", "type": "string" }, "warpUpdateInterval": { - "description": "WARP", "minimum": 0, "type": "integer" }, "webBasePath": { - "description": "Base path for web panel URLs", "type": "string" }, "webCertFile": { - "description": "Path to SSL certificate file for web server", "type": "string" }, "webDomain": { - "description": "Web server domain for domain validation", "type": "string" }, "webKeyFile": { - "description": "Path to SSL private key file for web server", "type": "string" }, "webListen": { - "description": "Web server settings\nWeb server listen IP address", "type": "string" }, "webPort": { - "description": "Web server port number", "maximum": 65535, "minimum": 1, "type": "integer" - }, - "subShowIdentityOnAllLinks": { - "type": "boolean" } }, "required": [ @@ -421,6 +380,7 @@ "expireDiff", "externalTrafficInformEnable", "externalTrafficInformURI", + "ipLimitAllowlist", "ldapAutoCreate", "ldapAutoDelete", "ldapBaseDN", @@ -432,6 +392,7 @@ "ldapFlagField", "ldapHost", "ldapInboundTags", + "ldapInsecureSkipVerify", "ldapInvertFlag", "ldapPassword", "ldapPort", @@ -441,6 +402,7 @@ "ldapUserAttr", "ldapUserFilter", "ldapVlessField", + "outboundDownThreshold", "pageSize", "panelOutbound", "remarkTemplate", @@ -450,6 +412,8 @@ "smtpEnable", "smtpEnabledEvents", "smtpEncryptionType", + "smtpFrom", + "smtpFromName", "smtpHost", "smtpMemory", "smtpPassword", @@ -458,11 +422,13 @@ "smtpUsername", "subAnnounce", "subCertFile", + "subClashAutoDetect", "subClashEnable", "subClashEnableRouting", "subClashPath", "subClashRules", "subClashURI", + "subClashUserAgentRegex", "subDomain", "subEnable", "subEnableRouting", @@ -470,12 +436,16 @@ "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subJsonAlwaysArray", + "subJsonAutoDetect", "subJsonEnable", "subJsonFinalMask", "subJsonMux", + "subJsonObservatory", "subJsonPath", "subJsonRules", "subJsonURI", + "subJsonUserAgentRegex", "subKeyFile", "subListen", "subPath", @@ -515,23 +485,18 @@ "type": "object" }, "AllSettingView": { - "description": "AllSettingView is the browser-safe settings read model. Secret values\nare redacted from the embedded write model and represented by presence\nflags so the UI can show configured/not configured state.", "properties": { "datepicker": { - "description": "Date picker format", "type": "string" }, "expireDiff": { - "description": "Expiration warning threshold in days", "minimum": 0, "type": "integer" }, "externalTrafficInformEnable": { - "description": "Enable external traffic reporting", "type": "boolean" }, "externalTrafficInformURI": { - "description": "URI for external traffic reporting", "type": "string" }, "hasApiToken": { @@ -555,6 +520,9 @@ "hasWarpSecret": { "type": "boolean" }, + "ipLimitAllowlist": { + "type": "string" + }, "ldapAutoCreate": { "type": "boolean" }, @@ -580,11 +548,9 @@ "type": "integer" }, "ldapEnable": { - "description": "LDAP settings", "type": "boolean" }, "ldapFlagField": { - "description": "Generic flag configuration", "type": "string" }, "ldapHost": { @@ -593,6 +559,9 @@ "ldapInboundTags": { "type": "string" }, + "ldapInsecureSkipVerify": { + "type": "boolean" + }, "ldapInvertFlag": { "type": "boolean" }, @@ -614,7 +583,6 @@ "type": "boolean" }, "ldapUserAttr": { - "description": "e.g., mail or uid", "type": "string" }, "ldapUserFilter": { @@ -623,306 +591,267 @@ "ldapVlessField": { "type": "string" }, + "outboundDownThreshold": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, "pageSize": { - "description": "UI settings\nNumber of items per page in lists (0 disables pagination)", "maximum": 1000, "minimum": 0, "type": "integer" }, "panelOutbound": { - "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)", "type": "string" }, "remarkTemplate": { - "description": "Subscription remark template ({{VAR}} tokens) rendered per client", "type": "string" }, "restartXrayOnClientDisable": { - "description": "Restart Xray when clients are auto-disabled by expiry/traffic limit", "type": "boolean" }, "sessionMaxAge": { - "description": "Session maximum age in minutes (cap at one year)", "maximum": 525600, "minimum": 1, "type": "integer" }, "smtpCpu": { - "description": "CPU threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpEnable": { - "description": "Email (SMTP) notification settings\nEnable email notifications", "type": "boolean" }, "smtpEnabledEvents": { - "description": "Comma-separated event types to send via email", "type": "string" }, "smtpEncryptionType": { - "description": "SMTP encryption: none, starttls, tls", + "type": "string" + }, + "smtpFrom": { + "type": "string" + }, + "smtpFromName": { "type": "string" }, "smtpHost": { - "description": "SMTP server host", "type": "string" }, "smtpMemory": { - "description": "Memory threshold for email notifications", "maximum": 100, "minimum": 0, "type": "integer" }, "smtpPassword": { - "description": "SMTP password", "type": "string" }, "smtpPort": { - "description": "SMTP server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "smtpTo": { - "description": "Comma-separated recipient emails", "type": "string" }, "smtpUsername": { - "description": "SMTP username", "type": "string" }, "subAnnounce": { - "description": "Subscription announce", "type": "string" }, "subCertFile": { - "description": "SSL certificate file for subscription server", "type": "string" }, + "subClashAutoDetect": { + "type": "boolean" + }, "subClashEnable": { - "description": "Enable Clash/Mihomo subscription endpoint", "type": "boolean" }, "subClashEnableRouting": { - "description": "Enable global routing rules for Clash/Mihomo", "type": "boolean" }, "subClashPath": { - "description": "Path for Clash/Mihomo subscription endpoint", "type": "string" }, "subClashRules": { - "description": "Clash/Mihomo global routing rules", "type": "string" }, "subClashURI": { - "description": "Clash/Mihomo subscription server URI", + "type": "string" + }, + "subClashUserAgentRegex": { "type": "string" }, "subDomain": { - "description": "Domain for subscription server validation", "type": "string" }, "subEnable": { - "description": "Subscription server settings\nEnable subscription server", "type": "boolean" }, "subEnableRouting": { - "description": "Enable routing for subscription", "type": "boolean" }, "subEncrypt": { - "description": "Encrypt subscription responses", "type": "boolean" }, "subHideSettings": { - "description": "Hide server settings in happ subscription (Only for Happ)", "type": "boolean" }, "subIncyEnableRouting": { - "description": "Enable routing injection for the Incy client", "type": "boolean" }, "subIncyRoutingRules": { - "description": "Incy routing deep-link injected into the subscription body (Only for Incy)", "type": "string" }, + "subJsonAlwaysArray": { + "type": "boolean" + }, + "subJsonAutoDetect": { + "type": "boolean" + }, "subJsonEnable": { - "description": "Enable JSON subscription endpoint", "type": "boolean" }, "subJsonFinalMask": { - "description": "JSON subscription global finalmask (tcp/udp masks + quicParams)", "type": "string" }, "subJsonMux": { - "description": "JSON subscription mux configuration", + "type": "string" + }, + "subJsonObservatory": { "type": "string" }, "subJsonPath": { - "description": "Path for JSON subscription endpoint", "type": "string" }, "subJsonRules": { "type": "string" }, "subJsonURI": { - "description": "JSON subscription server URI", + "type": "string" + }, + "subJsonUserAgentRegex": { "type": "string" }, "subKeyFile": { - "description": "SSL private key file for subscription server", "type": "string" }, "subListen": { - "description": "Subscription server listen IP", "type": "string" }, "subPath": { - "description": "Base path for subscription URLs", "type": "string" }, "subPort": { - "description": "Subscription server port", "maximum": 65535, "minimum": 1, "type": "integer" }, "subProfileUrl": { - "description": "Subscription profile URL", "type": "string" }, "subRoutingRules": { - "description": "Subscription global routing rules (Only for Happ)", "type": "string" }, + "subShowIdentityOnAllLinks": { + "type": "boolean" + }, "subSupportUrl": { - "description": "Subscription support URL", "type": "string" }, "subThemeDir": { - "description": "Absolute path to a folder containing a custom subscription page template", "type": "string" }, "subTitle": { - "description": "Subscription title", "type": "string" }, "subURI": { - "description": "Subscription server URI", "type": "string" }, "subUpdates": { - "description": "Subscription update interval in minutes", "maximum": 525600, "minimum": 0, "type": "integer" }, "tgBotAPIServer": { - "description": "Custom API server for Telegram bot", "type": "string" }, "tgBotBackup": { - "description": "Enable database backup via Telegram", "type": "boolean" }, "tgBotChatId": { - "description": "Telegram chat ID for notifications", "type": "string" }, "tgBotEnable": { - "description": "Telegram bot settings\nEnable Telegram bot notifications", "type": "boolean" }, "tgBotProxy": { - "description": "Proxy URL for Telegram bot", "type": "string" }, "tgBotToken": { - "description": "Telegram bot token", "type": "string" }, "tgCpu": { - "description": "CPU usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "tgEnabledEvents": { - "description": "Comma-separated event types to send via Telegram", "type": "string" }, "tgLang": { - "description": "Telegram bot language", "type": "string" }, "tgMemory": { - "description": "Memory usage threshold for alerts (percent)", "maximum": 100, "minimum": 0, "type": "integer" }, "tgRunTime": { - "description": "Cron schedule for Telegram notifications", "type": "string" }, "timeLocation": { - "description": "Security settings\nTime zone location", "type": "string" }, "trafficDiff": { - "description": "Traffic warning threshold percentage", "maximum": 100, "minimum": 0, "type": "integer" }, "trustedProxyCIDRs": { - "description": "Trusted reverse proxy IPs/CIDRs for forwarded headers", "type": "string" }, "twoFactorEnable": { - "description": "Enable two-factor authentication", "type": "boolean" }, "twoFactorToken": { - "description": "Two-factor authentication token", "type": "string" }, "warpUpdateInterval": { - "description": "WARP", "minimum": 0, "type": "integer" }, "webBasePath": { - "description": "Base path for web panel URLs", "type": "string" }, "webCertFile": { - "description": "Path to SSL certificate file for web server", "type": "string" }, "webDomain": { - "description": "Web server domain for domain validation", "type": "string" }, "webKeyFile": { - "description": "Path to SSL private key file for web server", "type": "string" }, "webListen": { - "description": "Web server settings\nWeb server listen IP address", "type": "string" }, "webPort": { - "description": "Web server port number", "maximum": 65535, "minimum": 1, "type": "integer" - }, - "subShowIdentityOnAllLinks": { - "type": "boolean" } }, "required": [ @@ -937,6 +866,7 @@ "hasTgBotToken", "hasTwoFactorToken", "hasWarpSecret", + "ipLimitAllowlist", "ldapAutoCreate", "ldapAutoDelete", "ldapBaseDN", @@ -948,6 +878,7 @@ "ldapFlagField", "ldapHost", "ldapInboundTags", + "ldapInsecureSkipVerify", "ldapInvertFlag", "ldapPassword", "ldapPort", @@ -957,6 +888,7 @@ "ldapUserAttr", "ldapUserFilter", "ldapVlessField", + "outboundDownThreshold", "pageSize", "panelOutbound", "remarkTemplate", @@ -966,6 +898,8 @@ "smtpEnable", "smtpEnabledEvents", "smtpEncryptionType", + "smtpFrom", + "smtpFromName", "smtpHost", "smtpMemory", "smtpPassword", @@ -974,11 +908,13 @@ "smtpUsername", "subAnnounce", "subCertFile", + "subClashAutoDetect", "subClashEnable", "subClashEnableRouting", "subClashPath", "subClashRules", "subClashURI", + "subClashUserAgentRegex", "subDomain", "subEnable", "subEnableRouting", @@ -986,12 +922,16 @@ "subHideSettings", "subIncyEnableRouting", "subIncyRoutingRules", + "subJsonAlwaysArray", + "subJsonAutoDetect", "subJsonEnable", "subJsonFinalMask", "subJsonMux", + "subJsonObservatory", "subJsonPath", "subJsonRules", "subJsonURI", + "subJsonUserAgentRegex", "subKeyFile", "subListen", "subPath", @@ -1114,6 +1054,16 @@ "Client": { "description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.", "properties": { + "adTag": { + "example": "0123456789abcdef0123456789abcdef", + "type": "string" + }, + "allowedIPs": { + "items": { + "type": "string" + }, + "type": "array" + }, "auth": { "description": "Auth password (Hysteria)", "type": "string" @@ -1124,6 +1074,7 @@ }, "created_at": { "description": "Creation timestamp", + "format": "int64", "type": "integer" }, "email": { @@ -1136,6 +1087,7 @@ }, "expiryTime": { "description": "Expiration timestamp", + "format": "int64", "type": "integer" }, "flow": { @@ -1150,6 +1102,9 @@ "description": "Unique client identifier", "type": "string" }, + "keepAlive": { + "type": "integer" + }, "limitIp": { "description": "IP limit for this client", "type": "integer" @@ -1158,10 +1113,27 @@ "description": "Client password", "type": "string" }, + "preSharedKey": { + "type": "string" + }, + "privateKey": { + "type": "string" + }, + "publicKey": { + "type": "string" + }, "reset": { "description": "Reset period in days", "type": "integer" }, + "resetDay": { + "description": "Calendar renewal day 1-31, 0 = interval mode", + "type": "integer" + }, + "resetMax": { + "description": "Max auto-renew count, 0 = unlimited", + "type": "integer" + }, "reverse": { "allOf": [ { @@ -1171,6 +1143,10 @@ "description": "VLESS simple reverse proxy settings", "nullable": true }, + "secret": { + "example": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", + "type": "string" + }, "security": { "description": "Security method (e.g., \"auto\", \"aes-128-gcm\")", "type": "string" @@ -1181,14 +1157,33 @@ }, "tgId": { "description": "Telegram user ID for notifications", + "format": "int64", "type": "integer" }, "totalGB": { "description": "Total traffic limit in GB", + "format": "int64", + "type": "integer" + }, + "trafficReset": { + "description": "Per-client traffic reset cycle, independent of the inbound's own (#5497).", + "enum": [ + "never", + "hourly", + "daily", + "weekly", + "monthly" + ], + "type": "string" + }, + "trafficResetDay": { + "maximum": 31, + "minimum": 1, "type": "integer" }, "updated_at": { "description": "Last update timestamp", + "format": "int64", "type": "integer" } }, @@ -1199,6 +1194,8 @@ "expiryTime", "limitIp", "reset", + "resetDay", + "resetMax", "security", "subId", "tgId", @@ -1212,6 +1209,7 @@ "type": "integer" }, "createdAt": { + "format": "int64", "type": "integer" }, "flowOverride": { @@ -1231,6 +1229,12 @@ }, "ClientRecord": { "properties": { + "adTag": { + "type": "string" + }, + "allowedIPs": { + "type": "string" + }, "auth": { "type": "string" }, @@ -1238,6 +1242,7 @@ "type": "string" }, "createdAt": { + "format": "int64", "type": "integer" }, "email": { @@ -1247,6 +1252,7 @@ "type": "boolean" }, "expiryTime": { + "format": "int64", "type": "integer" }, "flow": { @@ -1258,16 +1264,40 @@ "id": { "type": "integer" }, + "keepAlive": { + "type": "integer" + }, + "limitHwid": { + "type": "integer" + }, "limitIp": { "type": "integer" }, "password": { "type": "string" }, + "preSharedKey": { + "type": "string" + }, + "privateKey": { + "type": "string" + }, + "publicKey": { + "type": "string" + }, "reset": { "type": "integer" }, + "resetDay": { + "type": "integer" + }, + "resetMax": { + "type": "integer" + }, "reverse": {}, + "secret": { + "type": "string" + }, "security": { "type": "string" }, @@ -1275,12 +1305,21 @@ "type": "string" }, "tgId": { + "format": "int64", "type": "integer" }, "totalGB": { + "format": "int64", + "type": "integer" + }, + "trafficReset": { + "type": "string" + }, + "trafficResetDay": { "type": "integer" }, "updatedAt": { + "format": "int64", "type": "integer" }, "uuid": { @@ -1288,6 +1327,8 @@ } }, "required": [ + "adTag", + "allowedIPs", "auth", "comment", "createdAt", @@ -1297,14 +1338,24 @@ "flow", "group", "id", + "keepAlive", + "limitHwid", "limitIp", "password", + "preSharedKey", + "privateKey", + "publicKey", "reset", + "resetDay", + "resetMax", "reverse", + "secret", "security", "subId", "tgId", "totalGB", + "trafficReset", + "trafficResetDay", "updatedAt", "uuid" ], @@ -1326,6 +1377,7 @@ "properties": { "down": { "example": 2097152, + "format": "int64", "type": "integer" }, "email": { @@ -1338,6 +1390,7 @@ }, "expiryTime": { "example": 1735689600000, + "format": "int64", "type": "integer" }, "id": { @@ -1350,22 +1403,45 @@ }, "lastOnline": { "example": 1735680000000, + "format": "int64", + "type": "integer" + }, + "lastSubFetch": { + "example": 1735680000000, + "format": "int64", "type": "integer" }, "reset": { "example": 0, "type": "integer" }, + "resetCount": { + "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.", + "example": 0, + "type": "integer" + }, + "resetDay": { + "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.", + "example": 0, + "type": "integer" + }, + "resetMax": { + "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.", + "example": 0, + "type": "integer" + }, "subId": { "example": "i7tvdpeffi0hvvf1", "type": "string" }, "total": { "example": 10737418240, + "format": "int64", "type": "integer" }, "up": { "example": 1048576, + "format": "int64", "type": "integer" }, "uuid": { @@ -1381,7 +1457,11 @@ "id", "inboundId", "lastOnline", + "lastSubFetch", "reset", + "resetCount", + "resetDay", + "resetMax", "subId", "total", "up", @@ -1404,6 +1484,157 @@ ], "type": "object" }, + "GeoCategory": { + "description": "GeoCategory is one code inside a database, such as geosite's \"google\".", + "properties": { + "attributes": { + "example": [ + "ads", + "cn" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "code": { + "example": "google", + "type": "string" + }, + "entries": { + "example": 1284, + "type": "integer" + } + }, + "required": [ + "attributes", + "code", + "entries" + ], + "type": "object" + }, + "GeoCategoryPage": { + "description": "GeoCategoryPage is one page of categories plus the unpaged total.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/GeoCategory" + }, + "type": "array" + }, + "total": { + "example": 1043, + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "type": "object" + }, + "GeoEntry": { + "description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.", + "properties": { + "kind": { + "example": "domain", + "type": "string" + }, + "value": { + "example": "google.com", + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "GeoEntryPage": { + "description": "GeoEntryPage is one page of category entries plus the unpaged total.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/GeoEntry" + }, + "type": "array" + }, + "total": { + "example": 1284, + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "type": "object" + }, + "GeoFile": { + "description": "GeoFile describes one .dat database found in the asset directory.", + "properties": { + "categories": { + "example": 1043, + "type": "integer" + }, + "error": { + "type": "string" + }, + "kind": { + "example": "site", + "type": "string" + }, + "modifiedAt": { + "example": 1769558400000, + "format": "int64", + "type": "integer" + }, + "name": { + "example": "geosite.dat", + "type": "string" + }, + "size": { + "example": 1467392, + "format": "int64", + "type": "integer" + } + }, + "required": [ + "categories", + "kind", + "modifiedAt", + "name", + "size" + ], + "type": "object" + }, + "GeodataTokenIssue": { + "description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.", + "properties": { + "code": { + "example": "blabla", + "type": "string" + }, + "file": { + "example": "geosite.dat", + "type": "string" + }, + "reason": { + "example": "categoryMissing", + "type": "string" + }, + "token": { + "example": "geosite:blabla", + "type": "string" + } + }, + "required": [ + "reason", + "token" + ], + "type": "object" + }, "HistoryOfSeeders": { "description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.", "properties": { @@ -1421,7 +1652,6 @@ "type": "object" }, "Host": { - "description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.", "properties": { "address": { "example": "cdn.example.com", @@ -1437,6 +1667,7 @@ "type": "array" }, "createdAt": { + "format": "int64", "type": "integer" }, "echConfigList": { @@ -1455,6 +1686,9 @@ "fingerprint": { "type": "string" }, + "groupId": { + "type": "string" + }, "hostHeader": { "type": "string" }, @@ -1549,13 +1783,15 @@ "type": "array" }, "updatedAt": { + "format": "int64", "type": "integer" }, "verifyPeerCertByName": { "type": "string" }, "vlessRoute": { - "description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.", + "description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.", + "example": "443", "type": "string" } }, @@ -1568,6 +1804,7 @@ "excludeFromSubTypes", "finalMask", "fingerprint", + "groupId", "hostHeader", "id", "inboundId", @@ -1595,6 +1832,175 @@ ], "type": "object" }, + "HostGroup": { + "properties": { + "allowInsecure": { + "type": "boolean" + }, + "alpn": { + "items": { + "type": "string" + }, + "type": "array" + }, + "echConfigList": { + "type": "string" + }, + "excludeFromSubTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "finalMask": { + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "hostHeader": { + "type": "string" + }, + "hosts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "inboundIds": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "isDisabled": { + "type": "boolean" + }, + "isHidden": { + "type": "boolean" + }, + "keepSniBlank": { + "type": "boolean" + }, + "mihomoIpVersion": { + "enum": [ + "dual", + "ipv4", + "ipv6", + "ipv4-prefer", + "ipv6-prefer" + ], + "type": "string" + }, + "mihomoX25519": { + "type": "boolean" + }, + "muxParams": { + "type": "string" + }, + "nodeGuids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "overrideSniFromAddress": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "pinnedPeerCertSha256": { + "items": { + "type": "string" + }, + "type": "array" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "remark": { + "maxLength": 256, + "type": "string" + }, + "security": { + "enum": [ + "same", + "tls", + "none", + "reality" + ], + "type": "string" + }, + "serverDescription": { + "maxLength": 64, + "type": "string" + }, + "shuffleHost": { + "type": "boolean" + }, + "sni": { + "type": "string" + }, + "sockoptParams": { + "type": "string" + }, + "sortOrder": { + "type": "integer" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "verifyPeerCertByName": { + "type": "string" + }, + "vlessRoute": { + "type": "string" + } + }, + "required": [ + "allowInsecure", + "alpn", + "echConfigList", + "excludeFromSubTypes", + "finalMask", + "fingerprint", + "groupId", + "hostHeader", + "hosts", + "inboundIds", + "isDisabled", + "isHidden", + "keepSniBlank", + "mihomoIpVersion", + "mihomoX25519", + "muxParams", + "nodeGuids", + "overrideSniFromAddress", + "path", + "pinnedPeerCertSha256", + "port", + "remark", + "security", + "serverDescription", + "shuffleHost", + "sni", + "sockoptParams", + "sortOrder", + "tags", + "verifyPeerCertByName", + "vlessRoute" + ], + "type": "object" + }, "Inbound": { "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "properties": { @@ -1605,8 +2011,13 @@ }, "type": "array" }, + "disableFlow": { + "example": false, + "type": "boolean" + }, "down": { "description": "Download traffic in bytes", + "format": "int64", "type": "integer" }, "enable": { @@ -1616,6 +2027,7 @@ }, "expiryTime": { "description": "Expiration timestamp", + "format": "int64", "type": "integer" }, "fallbackParent": { @@ -1634,6 +2046,7 @@ }, "lastTrafficResetTime": { "description": "Last traffic reset timestamp", + "format": "int64", "type": "integer" }, "listen": { @@ -1702,6 +2115,7 @@ }, "total": { "description": "Total traffic limit in bytes", + "format": "int64", "type": "integer" }, "trafficReset": { @@ -1715,13 +2129,22 @@ ], "type": "string" }, + "trafficResetDay": { + "description": "Day of month for monthly traffic resets", + "example": 1, + "maximum": 31, + "minimum": 1, + "type": "integer" + }, "up": { "description": "Upload traffic in bytes", + "format": "int64", "type": "integer" } }, "required": [ "clientStats", + "disableFlow", "down", "enable", "expiryTime", @@ -1740,6 +2163,7 @@ "tag", "total", "trafficReset", + "trafficResetDay", "up" ], "type": "object" @@ -1807,10 +2231,24 @@ }, "InboundOption": { "properties": { + "enable": { + "example": true, + "type": "boolean" + }, "id": { "example": 1, "type": "integer" }, + "listen": { + "type": "string" + }, + "mtprotoDomain": { + "type": "string" + }, + "nodeAddress": { + "description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.", + "type": "string" + }, "nodeId": { "description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).", "nullable": true, @@ -1828,6 +2266,12 @@ "example": "VLESS-443", "type": "string" }, + "shareAddr": { + "type": "string" + }, + "shareAddrStrategy": { + "type": "string" + }, "ssMethod": { "type": "string" }, @@ -1838,9 +2282,19 @@ "tlsFlowCapable": { "example": true, "type": "boolean" + }, + "wgDns": { + "type": "string" + }, + "wgMtu": { + "type": "integer" + }, + "wgPublicKey": { + "type": "string" } }, "required": [ + "enable", "id", "port", "protocol", @@ -1852,17 +2306,12 @@ "type": "object" }, "Msg": { - "description": "Msg represents a standard API response message with success status, message text, and optional data object.", "properties": { "msg": { - "description": "Response message text", "type": "string" }, - "obj": { - "description": "Optional data object" - }, + "obj": {}, "success": { - "description": "Indicates if the operation was successful", "type": "boolean" } }, @@ -1887,10 +2336,6 @@ "allowPrivateAddress": { "type": "boolean" }, - "apiToken": { - "example": "abcdef0123456789", - "type": "string" - }, "basePath": { "example": "/", "type": "string" @@ -1903,6 +2348,7 @@ "type": "boolean" }, "configDirtyAt": { + "format": "int64", "type": "integer" }, "cpuPct": { @@ -1911,6 +2357,7 @@ }, "createdAt": { "example": 1700000000, + "format": "int64", "type": "integer" }, "depletedCount": { @@ -1956,6 +2403,7 @@ "lastHeartbeat": { "description": "unix seconds, 0 = never", "example": 1700000000, + "format": "int64", "type": "integer" }, "latencyMs": { @@ -1972,10 +2420,12 @@ }, "netDown": { "example": 2097152, + "format": "int64", "type": "integer" }, "netUp": { "example": 1048576, + "format": "int64", "type": "integer" }, "onlineCount": { @@ -2032,10 +2482,12 @@ }, "updatedAt": { "example": 1700000000, + "format": "int64", "type": "integer" }, "uptimeSecs": { "example": 86400, + "format": "int64", "type": "integer" }, "xrayError": { @@ -2054,7 +2506,6 @@ "activeCount", "address", "allowPrivateAddress", - "apiToken", "basePath", "clientCount", "configDirty", @@ -2093,10 +2544,320 @@ ], "type": "object" }, + "NodeMutationRequest": { + "description": "NodeMutationRequest is the node write/probe contract. ApiToken is accepted\nonly as input. On update, nil means keep the stored token; replacement and\nclearing are explicit and mutually exclusive.", + "properties": { + "address": { + "type": "string" + }, + "allowPrivateAddress": { + "type": "boolean" + }, + "apiToken": { + "nullable": true, + "type": "string" + }, + "basePath": { + "type": "string" + }, + "clearApiToken": { + "type": "boolean" + }, + "enable": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "inboundSyncMode": { + "enum": [ + "all", + "selected" + ], + "type": "string" + }, + "inboundTags": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "outboundTag": { + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "remark": { + "type": "string" + }, + "scheme": { + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "tlsVerifyMode": { + "enum": [ + "verify", + "skip", + "pin", + "mtls" + ], + "type": "string" + } + }, + "required": [ + "address", + "allowPrivateAddress", + "basePath", + "enable", + "id", + "inboundSyncMode", + "inboundTags", + "name", + "outboundTag", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "tlsVerifyMode" + ], + "type": "object" + }, + "NodeView": { + "description": "NodeView is the browser/API read contract for nodes. Credentials are\nwrite-only: responses expose only whether a node has a token configured.", + "properties": { + "activeCount": { + "example": 20, + "type": "integer" + }, + "address": { + "example": "node.example.com", + "type": "string" + }, + "allowPrivateAddress": { + "example": false, + "type": "boolean" + }, + "basePath": { + "example": "/", + "type": "string" + }, + "clientCount": { + "example": 25, + "type": "integer" + }, + "configDirty": { + "example": false, + "type": "boolean" + }, + "configDirtyAt": { + "example": 0, + "format": "int64", + "type": "integer" + }, + "cpuPct": { + "example": 12.5, + "type": "number" + }, + "createdAt": { + "example": 1700000000, + "format": "int64", + "type": "integer" + }, + "depletedCount": { + "example": 1, + "type": "integer" + }, + "disabledCount": { + "example": 2, + "type": "integer" + }, + "enable": { + "example": true, + "type": "boolean" + }, + "guid": { + "example": "node-guid", + "type": "string" + }, + "hasApiToken": { + "example": true, + "type": "boolean" + }, + "id": { + "example": 1, + "type": "integer" + }, + "inboundCount": { + "example": 3, + "type": "integer" + }, + "inboundSyncMode": { + "example": "all", + "type": "string" + }, + "inboundTags": { + "example": [ + "in-443-tcp" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "lastError": { + "type": "string" + }, + "lastHeartbeat": { + "example": 1700000000, + "format": "int64", + "type": "integer" + }, + "latencyMs": { + "example": 42, + "type": "integer" + }, + "memPct": { + "example": 45.2, + "type": "number" + }, + "name": { + "example": "edge-1", + "type": "string" + }, + "netDown": { + "example": 1048576, + "format": "int64", + "type": "integer" + }, + "netUp": { + "example": 2097152, + "format": "int64", + "type": "integer" + }, + "onlineCount": { + "example": 5, + "type": "integer" + }, + "outboundTag": { + "example": "direct", + "type": "string" + }, + "panelVersion": { + "example": "v3.x.x", + "type": "string" + }, + "parentGuid": { + "type": "string" + }, + "pinnedCertSha256": { + "type": "string" + }, + "port": { + "example": 2053, + "type": "integer" + }, + "remark": { + "example": "Primary edge", + "type": "string" + }, + "scheme": { + "example": "https", + "type": "string" + }, + "status": { + "example": "online", + "type": "string" + }, + "tlsVerifyMode": { + "example": "verify", + "type": "string" + }, + "transitive": { + "example": false, + "type": "boolean" + }, + "updatedAt": { + "example": 1700003600, + "format": "int64", + "type": "integer" + }, + "uptimeSecs": { + "example": 86400, + "format": "int64", + "type": "integer" + }, + "xrayError": { + "type": "string" + }, + "xrayState": { + "example": "running", + "type": "string" + }, + "xrayVersion": { + "example": "25.10.31", + "type": "string" + } + }, + "required": [ + "activeCount", + "address", + "allowPrivateAddress", + "basePath", + "clientCount", + "configDirty", + "configDirtyAt", + "cpuPct", + "createdAt", + "depletedCount", + "disabledCount", + "enable", + "guid", + "hasApiToken", + "id", + "inboundCount", + "inboundSyncMode", + "inboundTags", + "lastError", + "lastHeartbeat", + "latencyMs", + "memPct", + "name", + "netDown", + "netUp", + "onlineCount", + "outboundTag", + "panelVersion", + "pinnedCertSha256", + "port", + "remark", + "scheme", + "status", + "tlsVerifyMode", + "updatedAt", + "uptimeSecs", + "xrayError", + "xrayState", + "xrayVersion" + ], + "type": "object" + }, "OutboundTraffics": { "description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.", "properties": { "down": { + "format": "int64", "type": "integer" }, "id": { @@ -2106,9 +2867,11 @@ "type": "string" }, "total": { + "format": "int64", "type": "integer" }, "up": { + "format": "int64", "type": "integer" } }, @@ -2121,6 +2884,35 @@ ], "type": "object" }, + "PanelUpdateStatus": { + "description": "PanelUpdateStatus reports the outcome of the most recently launched panel\nself-update. RunID lets the caller confirm this status belongs to the\nupdate it started rather than a stale result left over from an earlier\nrun; State is one of \"pending\", \"success\", or \"failed\". RunID is a decimal\nstring, not a JSON number: it's a formatted UnixNano timestamp, and\nJavaScript's number type can't represent that precisely (it exceeds\nNumber.MAX_SAFE_INTEGER), which would let two different runs round to the\nsame value on the wire and defeat the whole point of this field.", + "properties": { + "exitCode": { + "example": 0, + "type": "integer" + }, + "finishedAt": { + "example": 1735689612, + "format": "int64", + "type": "integer" + }, + "runId": { + "example": "1735689600123456789", + "type": "string" + }, + "state": { + "example": "success", + "type": "string" + } + }, + "required": [ + "exitCode", + "finishedAt", + "runId", + "state" + ], + "type": "object" + }, "ProbeResultUI": { "properties": { "cpuPct": { @@ -2148,6 +2940,7 @@ }, "uptimeSecs": { "example": 86400, + "format": "int64", "type": "integer" }, "xrayError": { @@ -2176,6 +2969,116 @@ ], "type": "object" }, + "RealityScanResult": { + "properties": { + "alpn": { + "example": "h2", + "type": "string" + }, + "certChainValid": { + "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.", + "example": true, + "type": "boolean" + }, + "certIssuer": { + "example": "Google Trust Services", + "type": "string" + }, + "certSubject": { + "example": "cloudflare.com", + "type": "string" + }, + "certValid": { + "example": true, + "type": "boolean" + }, + "curveID": { + "example": "X25519", + "type": "string" + }, + "feasible": { + "example": true, + "type": "boolean" + }, + "h2": { + "example": true, + "type": "boolean" + }, + "host": { + "example": "www.cloudflare.com", + "type": "string" + }, + "ip": { + "example": "104.16.124.96", + "type": "string" + }, + "latencyMs": { + "example": 180, + "type": "integer" + }, + "notAfter": { + "example": "2026-08-01T00:00:00Z", + "type": "string" + }, + "port": { + "example": 443, + "type": "integer" + }, + "privateTarget": { + "description": "PrivateTarget marks a target that resolves to a loopback/private/link-local\naddress: blocked before the probe unless the caller opted in, then flagged.", + "example": false, + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "serverNames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "target": { + "example": "www.cloudflare.com:443", + "type": "string" + }, + "tls13": { + "example": true, + "type": "boolean" + }, + "tlsVersion": { + "example": "1.3", + "type": "string" + }, + "x25519": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "alpn", + "certChainValid", + "certIssuer", + "certSubject", + "certValid", + "curveID", + "feasible", + "h2", + "host", + "ip", + "latencyMs", + "notAfter", + "port", + "privateTarget", + "reason", + "serverNames", + "target", + "tls13", + "tlsVersion", + "x25519" + ], + "type": "object" + }, "Setting": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { @@ -2196,6 +3099,71 @@ ], "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": { "description": "User represents a user account in the 3x-ui panel.", "properties": { @@ -2261,12 +3229,16 @@ }, { "name": "API Tokens", - "description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. 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 Authorization: Bearer <token> on any /panel/api/* request — the token is a full-admin credential." + "description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation." }, { "name": "Xray Settings", "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", "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." @@ -2510,13 +3482,18 @@ "id": 14825, "inboundId": 1, "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, "up": 1048576, "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c" } ], + "disableFlow": false, "down": 0, "enable": true, "expiryTime": 0, @@ -2538,6 +3515,7 @@ "tag": "in-443-tcp", "total": 0, "trafficReset": "never", + "trafficResetDay": 1, "up": 0 } ] @@ -2630,14 +3608,23 @@ "success": true, "obj": [ { + "enable": true, "id": 1, + "listen": "", + "mtprotoDomain": "", + "nodeAddress": "", "nodeId": null, "port": 443, "protocol": "vless", "remark": "VLESS-443", + "shareAddr": "", + "shareAddrStrategy": "", "ssMethod": "", "tag": "in-443-tcp", - "tlsFlowCapable": true + "tlsFlowCapable": true, + "wgDns": "", + "wgMtu": 0, + "wgPublicKey": "" } ] } @@ -2647,6 +3634,43 @@ } } }, + "/panel/api/inbounds/allLinks": { + "get": { + "tags": [ + "Inbounds" + ], + "summary": "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.", + "operationId": "get_panel_api_inbounds_allLinks", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": [ + "vless://uuid@host:443?security=reality&...#Germany-alice", + "vmess://eyJ2IjoyLC..." + ] + } + } + } + } + } + } + }, "/panel/api/inbounds/get/{id}": { "get": { "tags": [ @@ -2979,6 +4003,60 @@ } } }, + "/panel/api/inbounds/{id}/subSortIndex": { + "post": { + "tags": [ + "Inbounds" + ], + "summary": "Set only the subscription sort order. Reads the stored inbound, so a reorder cannot carry a stale client list over a concurrent edit.", + "operationId": "post_panel_api_inbounds_id_subSortIndex", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Inbound ID.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "subSortIndex": 2 + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/inbounds/{id}/resetTraffic": { "post": { "tags": [ @@ -3304,6 +4382,36 @@ } } }, + "/panel/api/openapi.json": { + "get": { + "tags": [ + "Server" + ], + "summary": "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.", + "operationId": "get_panel_api_openapi_json", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/server/status": { "get": { "tags": [ @@ -3737,6 +4845,47 @@ } } }, + "/panel/api/server/getUpdateStatus": { + "get": { + "tags": [ + "Server" + ], + "summary": "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.", + "operationId": "get_panel_api_server_getUpdateStatus", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/PanelUpdateStatus" + } + } + }, + "example": { + "success": true, + "obj": { + "exitCode": 0, + "finishedAt": 1735689612, + "runId": "1735689600123456789", + "state": "success" + } + } + } + } + } + } + } + }, "/panel/api/server/getConfigJson": { "get": { "tags": [ @@ -3772,7 +4921,7 @@ "tags": [ "Server" ], - "summary": "Stream the SQLite database file as an attachment. Use as a manual backup.", + "summary": "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.", "operationId": "get_panel_api_server_getDb", "responses": { "200": { @@ -4263,6 +5412,12 @@ }, "obj": {} } + }, + "example": { + "success": true, + "obj": { + "runId": "1735689600123456789" + } } } } @@ -4510,7 +5665,7 @@ "tags": [ "Server" ], - "summary": "Restore the panel DB from an uploaded SQLite file (multipart form, field name \"db\"). The panel restarts after restore. Destructive.", + "summary": "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.", "operationId": "post_panel_api_server_importDB", "responses": { "200": { @@ -4667,6 +5822,149 @@ } } }, + "/panel/api/server/scanRealityTarget": { + "post": { + "tags": [ + "Server" + ], + "summary": "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.", + "operationId": "post_panel_api_server_scanRealityTarget", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/RealityScanResult" + } + } + }, + "example": { + "success": true, + "obj": { + "alpn": "h2", + "certChainValid": true, + "certIssuer": "Google Trust Services", + "certSubject": "cloudflare.com", + "certValid": true, + "curveID": "X25519", + "feasible": true, + "h2": true, + "host": "www.cloudflare.com", + "ip": "104.16.124.96", + "latencyMs": 180, + "notAfter": "2026-08-01T00:00:00Z", + "port": 443, + "privateTarget": false, + "reason": "", + "serverNames": [ + "" + ], + "target": "www.cloudflare.com:443", + "tls13": true, + "tlsVersion": "1.3", + "x25519": true + } + } + } + } + } + } + } + }, + "/panel/api/server/scanRealityTargets": { + "post": { + "tags": [ + "Server" + ], + "summary": "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.", + "operationId": "post_panel_api_server_scanRealityTargets", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "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/RealityScanResult" + } + } + } + }, + "example": { + "success": true, + "obj": [ + { + "alpn": "h2", + "certChainValid": true, + "certIssuer": "Google Trust Services", + "certSubject": "cloudflare.com", + "certValid": true, + "curveID": "X25519", + "feasible": true, + "h2": true, + "host": "www.cloudflare.com", + "ip": "104.16.124.96", + "latencyMs": 180, + "notAfter": "2026-08-01T00:00:00Z", + "port": 443, + "privateTarget": false, + "reason": "", + "serverNames": [ + "" + ], + "target": "www.cloudflare.com:443", + "tls13": true, + "tlsVersion": "1.3", + "x25519": true + } + ] + } + } + } + } + } + } + }, "/panel/api/server/clientIps": { "get": { "tags": [ @@ -4799,7 +6097,7 @@ "tags": [ "Clients" ], - "summary": "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 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 /get/:email to obtain the full per-client payload for an edit/info modal.", + "summary": "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 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 /get/:email to obtain the full per-client payload for an edit/info modal.", "operationId": "get_panel_api_clients_list_paged", "parameters": [ { @@ -4894,6 +6192,7 @@ "totalGB": 53687091200, "expiryTime": 1735689600000, "limitIp": 0, + "limitHwid": 0, "reset": 0, "inboundIds": [ 3, @@ -4915,12 +6214,18 @@ "summary": { "total": 2000, "active": 1850, + "onlineCount": 1, + "depletedCount": 0, + "expiringCount": 0, + "deactiveCount": 150, "online": [ "alice@example.com" ], "depleted": [], "expiring": [], - "deactive": [] + "deactive": [ + "bob@example.com" + ] } } } @@ -4971,6 +6276,47 @@ } } }, + "/panel/api/clients/get/tgId/{tgId}": { + "get": { + "tags": [ + "Clients" + ], + "summary": "Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID.", + "operationId": "get_panel_api_clients_get_tgId_tgId", + "parameters": [ + { + "name": "tgId", + "in": "path", + "required": true, + "description": "Telegram user ID (numeric).", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/clients/add": { "post": { "tags": [ @@ -4993,6 +6339,7 @@ "expiryTime": 1735689600000, "tgId": 0, "limitIp": 0, + "limitHwid": 0, "enable": true }, "inboundIds": [ @@ -5059,6 +6406,7 @@ "email": "alice@example.com", "totalGB": 107374182400, "expiryTime": 1767225600000, + "limitHwid": 2, "tgId": 123456789, "enable": true } @@ -5271,7 +6619,7 @@ "tags": [ "Clients" ], - "summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.", + "summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.", "operationId": "post_panel_api_clients_email_externalLinks", "parameters": [ { @@ -5289,19 +6637,36 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "object", + "properties": { + "externalLinks": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET." + } + }, + "required": [ + "externalLinks" + ] }, "example": { "externalLinks": [ { "kind": "link", "value": "vless://uuid@host:443?...#srv", - "remark": "DE" + "remark": "DE", + "enable": true, + "expiryTime": 0 }, { "kind": "subscription", "value": "https://provider.example/sub/abc", - "remark": "Provider" + "remark": "Provider", + "enable": false, + "expiryTime": 1767225600000, + "namePrefix": "[zjh] " } ] } @@ -5408,7 +6773,7 @@ "tags": [ "Clients" ], - "summary": "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.", + "summary": "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.", "operationId": "post_panel_api_clients_delOrphans", "responses": { "200": { @@ -5472,6 +6837,7 @@ "id": "...", "totalGB": 53687091200, "expiryTime": 0, + "limitHwid": 2, "enable": true, "subId": "..." }, @@ -5548,7 +6914,7 @@ "tags": [ "Clients" ], - "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) 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-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.", + "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) 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-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.", "operationId": "post_panel_api_clients_bulkAdjust", "requestBody": { "required": true, @@ -5799,6 +7165,7 @@ "email": "alice@example.com", "totalGB": 53687091200, "expiryTime": 0, + "limitHwid": 2, "enable": true }, "inboundIds": [ @@ -5810,6 +7177,7 @@ "email": "bob@example.com", "totalGB": 53687091200, "expiryTime": 0, + "limitHwid": 0, "enable": true }, "inboundIds": [ @@ -6377,6 +7745,55 @@ } } }, + "/panel/api/clients/groups/resetTraffic": { + "post": { + "tags": [ + "Clients" + ], + "summary": "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.", + "operationId": "post_panel_api_clients_groups_resetTraffic", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "name": "customer-a" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": { + "name": "customer-a" + } + } + } + } + } + } + } + }, "/panel/api/clients/resetTraffic/{email}": { "post": { "tags": [ @@ -6555,6 +7972,150 @@ } } }, + "/panel/api/clients/hwids/{email}": { + "post": { + "tags": [ + "Clients" + ], + "summary": "List registered HWID devices for a client. Hashes are not exposed.", + "operationId": "post_panel_api_clients_hwids_email", + "parameters": [ + { + "name": "email", + "in": "path", + "required": true, + "description": "Client email.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": [ + { + "id": 1, + "firstSeen": 1735000000000, + "lastSeen": 1735100000000, + "userAgent": "Happ/1.0", + "deviceOs": "android", + "osVersion": "15", + "deviceModel": "Pixel 9" + } + ] + } + } + } + } + } + }, + "delete": { + "tags": [ + "Clients" + ], + "summary": "Clear all registered HWID devices for a client so new devices can register again.", + "operationId": "delete_panel_api_clients_hwids_email", + "parameters": [ + { + "name": "email", + "in": "path", + "required": true, + "description": "Client email.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, + "/panel/api/clients/hwids/{email}/{id}": { + "delete": { + "tags": [ + "Clients" + ], + "summary": "Remove a single registered HWID device by its id, freeing one slot under the HWID limit.", + "operationId": "delete_panel_api_clients_hwids_email_id", + "parameters": [ + { + "name": "email", + "in": "path", + "required": true, + "description": "Client email.", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "required": true, + "description": "Device id, from the list endpoint.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/clients/onlines": { "post": { "tags": [ @@ -6800,7 +8361,11 @@ "id": 14825, "inboundId": 1, "lastOnline": 1735680000000, + "lastSubFetch": 1735680000000, "reset": 0, + "resetCount": 0, + "resetDay": 0, + "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, "up": 1048576, @@ -6932,7 +8497,7 @@ "obj": { "type": "array", "items": { - "$ref": "#/components/schemas/Node" + "$ref": "#/components/schemas/NodeView" } } } @@ -6941,48 +8506,48 @@ "success": true, "obj": [ { - "activeCount": 23, - "address": "node1.example.com", + "activeCount": 20, + "address": "node.example.com", "allowPrivateAddress": false, - "apiToken": "abcdef0123456789", "basePath": "/", - "clientCount": 27, + "clientCount": 25, "configDirty": false, "configDirtyAt": 0, - "cpuPct": 23.5, + "cpuPct": 12.5, "createdAt": 1700000000, "depletedCount": 1, - "disabledCount": 3, + "disabledCount": 2, "enable": true, - "guid": "", + "guid": "node-guid", + "hasApiToken": true, "id": 1, - "inboundCount": 5, + "inboundCount": 3, "inboundSyncMode": "all", "inboundTags": [ - "" + "in-443-tcp" ], "lastError": "", "lastHeartbeat": 1700000000, "latencyMs": 42, - "memPct": 45.1, - "name": "de-fra-1", - "netDown": 2097152, - "netUp": 1048576, - "onlineCount": 3, - "outboundTag": "", + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", "panelVersion": "v3.x.x", "parentGuid": "", "pinnedCertSha256": "", "port": 2053, - "remark": "", + "remark": "Primary edge", "scheme": "https", "status": "online", "tlsVerifyMode": "verify", "transitive": false, - "updatedAt": 1700000000, + "updatedAt": 1700003600, "uptimeSecs": 86400, "xrayError": "", - "xrayState": "", + "xrayState": "running", "xrayVersion": "25.10.31" } ] @@ -7072,6 +8637,36 @@ } } }, + "/panel/api/nodes/mtls/reloadClient": { + "post": { + "tags": [ + "Nodes" + ], + "summary": "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.", + "operationId": "post_panel_api_nodes_mtls_reloadClient", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/nodes/get/{id}": { "get": { "tags": [ @@ -7104,7 +8699,57 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/NodeView" + } + } + }, + "example": { + "success": true, + "obj": { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" } } } @@ -7166,7 +8811,7 @@ "tags": [ "Nodes" ], - "summary": "Register a new remote node. Provide its URL, apiToken, and optional remark / allowPrivateAddress flag.", + "summary": "Register a new remote node. Provide its URL, write-only apiToken, and optional remark / allowPrivateAddress flag. Responses expose hasApiToken only.", "operationId": "post_panel_api_nodes_add", "requestBody": { "required": true, @@ -7183,6 +8828,7 @@ "port": 2053, "basePath": "/", "apiToken": "abcdef...", + "clearApiToken": false, "enable": true, "allowPrivateAddress": false } @@ -7203,7 +8849,57 @@ "msg": { "type": "string" }, - "obj": {} + "obj": { + "$ref": "#/components/schemas/NodeView" + } + } + }, + "example": { + "success": true, + "obj": { + "activeCount": 20, + "address": "node.example.com", + "allowPrivateAddress": false, + "basePath": "/", + "clientCount": 25, + "configDirty": false, + "configDirtyAt": 0, + "cpuPct": 12.5, + "createdAt": 1700000000, + "depletedCount": 1, + "disabledCount": 2, + "enable": true, + "guid": "node-guid", + "hasApiToken": true, + "id": 1, + "inboundCount": 3, + "inboundSyncMode": "all", + "inboundTags": [ + "in-443-tcp" + ], + "lastError": "", + "lastHeartbeat": 1700000000, + "latencyMs": 42, + "memPct": 45.2, + "name": "edge-1", + "netDown": 1048576, + "netUp": 2097152, + "onlineCount": 5, + "outboundTag": "direct", + "panelVersion": "v3.x.x", + "parentGuid": "", + "pinnedCertSha256": "", + "port": 2053, + "remark": "Primary edge", + "scheme": "https", + "status": "online", + "tlsVerifyMode": "verify", + "transitive": false, + "updatedAt": 1700003600, + "uptimeSecs": 86400, + "xrayError": "", + "xrayState": "running", + "xrayVersion": "25.10.31" } } } @@ -7217,7 +8913,7 @@ "tags": [ "Nodes" ], - "summary": "Replace a node’s connection details. Same body shape as /add.", + "summary": "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.", "operationId": "post_panel_api_nodes_update_id", "parameters": [ { @@ -7244,7 +8940,8 @@ "address": "node1.example.com", "port": 2053, "basePath": "/", - "apiToken": "abcdef...", + "apiToken": "", + "clearApiToken": false, "enable": true, "allowPrivateAddress": false } @@ -7730,7 +9427,7 @@ "obj": { "type": "array", "items": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/HostGroup" } } } @@ -7739,27 +9436,30 @@ "success": true, "obj": [ { - "address": "cdn.example.com", "allowInsecure": false, "alpn": [ "" ], - "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ "" ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", - "id": 1, - "inboundId": 1, + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], "isDisabled": false, "isHidden": false, "keepSniBlank": false, "mihomoIpVersion": "dual", "mihomoX25519": false, - "muxParams": null, + "muxParams": "", "nodeGuids": [ "" ], @@ -7768,18 +9468,17 @@ "pinnedPeerCertSha256": [ "" ], - "port": 8443, - "remark": "cdn-front", + "port": 0, + "remark": "", "security": "same", "serverDescription": "", "shuffleHost": false, "sni": "", - "sockoptParams": null, + "sockoptParams": "", "sortOrder": 0, "tags": [ "" ], - "updatedAt": 0, "verifyPeerCertByName": "", "vlessRoute": "" } @@ -7791,21 +9490,21 @@ } } }, - "/panel/api/hosts/get/{id}": { + "/panel/api/hosts/get/{groupId}": { "get": { "tags": [ "Hosts" ], - "summary": "Fetch a single host by ID.", - "operationId": "get_panel_api_hosts_get_id", + "summary": "Fetch a single host group by Group ID.", + "operationId": "get_panel_api_hosts_get_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -7824,34 +9523,37 @@ "type": "string" }, "obj": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/HostGroup" } } }, "example": { "success": true, "obj": { - "address": "cdn.example.com", "allowInsecure": false, "alpn": [ "" ], - "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ "" ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", - "id": 1, - "inboundId": 1, + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], "isDisabled": false, "isHidden": false, "keepSniBlank": false, "mihomoIpVersion": "dual", "mihomoX25519": false, - "muxParams": null, + "muxParams": "", "nodeGuids": [ "" ], @@ -7860,18 +9562,17 @@ "pinnedPeerCertSha256": [ "" ], - "port": 8443, - "remark": "cdn-front", + "port": 0, + "remark": "", "security": "same", "serverDescription": "", "shuffleHost": false, "sni": "", - "sockoptParams": null, + "sockoptParams": "", "sortOrder": 0, "tags": [ "" ], - "updatedAt": 0, "verifyPeerCertByName": "", "vlessRoute": "" } @@ -7887,7 +9588,7 @@ "tags": [ "Hosts" ], - "summary": "Fetch one inbound's hosts, ordered by sort order then id.", + "summary": "Fetch one inbound's hosts, grouped by host group.", "operationId": "get_panel_api_hosts_byInbound_inboundId", "parameters": [ { @@ -7917,7 +9618,7 @@ "obj": { "type": "array", "items": { - "$ref": "#/components/schemas/Host" + "$ref": "#/components/schemas/HostGroup" } } } @@ -7926,27 +9627,30 @@ "success": true, "obj": [ { - "address": "cdn.example.com", "allowInsecure": false, "alpn": [ "" ], - "createdAt": 0, "echConfigList": "", "excludeFromSubTypes": [ "" ], "finalMask": "", "fingerprint": "", + "groupId": "", "hostHeader": "", - "id": 1, - "inboundId": 1, + "hosts": [ + "" + ], + "inboundIds": [ + 0 + ], "isDisabled": false, "isHidden": false, "keepSniBlank": false, "mihomoIpVersion": "dual", "mihomoX25519": false, - "muxParams": null, + "muxParams": "", "nodeGuids": [ "" ], @@ -7955,18 +9659,17 @@ "pinnedPeerCertSha256": [ "" ], - "port": 8443, - "remark": "cdn-front", + "port": 0, + "remark": "", "security": "same", "serverDescription": "", "shuffleHost": false, "sni": "", - "sockoptParams": null, + "sockoptParams": "", "sortOrder": 0, "tags": [ "" ], - "updatedAt": 0, "verifyPeerCertByName": "", "vlessRoute": "" } @@ -8021,7 +9724,7 @@ "tags": [ "Hosts" ], - "summary": "Create a host on an inbound. inboundId and remark are required; security defaults to \"same\" (inherit the inbound).", + "summary": "Create a host group on inbounds.", "operationId": "post_panel_api_hosts_add", "requestBody": { "required": true, @@ -8031,12 +9734,15 @@ "type": "object" }, "example": { - "inboundId": 1, + "inboundIds": [ + 1 + ], "remark": "cdn-front", - "address": "cdn.example.com", + "hosts": [ + "cdn.example.com" + ], "port": 8443, "security": "same", - "sni": "", "tags": [ "CDN" ] @@ -8059,57 +9765,63 @@ "type": "string" }, "obj": { - "$ref": "#/components/schemas/Host" + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } } } }, "example": { "success": true, - "obj": { - "address": "cdn.example.com", - "allowInsecure": false, - "alpn": [ - "" - ], - "createdAt": 0, - "echConfigList": "", - "excludeFromSubTypes": [ - "" - ], - "finalMask": "", - "fingerprint": "", - "hostHeader": "", - "id": 1, - "inboundId": 1, - "isDisabled": false, - "isHidden": false, - "keepSniBlank": false, - "mihomoIpVersion": "dual", - "mihomoX25519": false, - "muxParams": null, - "nodeGuids": [ - "" - ], - "overrideSniFromAddress": false, - "path": "", - "pinnedPeerCertSha256": [ - "" - ], - "port": 8443, - "remark": "cdn-front", - "security": "same", - "serverDescription": "", - "shuffleHost": false, - "sni": "", - "sockoptParams": null, - "sortOrder": 0, - "tags": [ - "" - ], - "updatedAt": 0, - "verifyPeerCertByName": "", - "vlessRoute": "" - } + "obj": [ + { + "address": "cdn.example.com", + "allowInsecure": false, + "alpn": [ + "" + ], + "createdAt": 0, + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "id": 1, + "inboundId": 1, + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": null, + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 8443, + "remark": "cdn-front", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": null, + "sortOrder": 0, + "tags": [ + "" + ], + "updatedAt": 0, + "verifyPeerCertByName": "", + "vlessRoute": "443" + } + ] } } } @@ -8117,21 +9829,21 @@ } } }, - "/panel/api/hosts/update/{id}": { + "/panel/api/hosts/update/{groupId}": { "post": { "tags": [ "Hosts" ], - "summary": "Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).", - "operationId": "post_panel_api_hosts_update_id", + "summary": "Replace a host group’s content.", + "operationId": "post_panel_api_hosts_update_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8143,12 +9855,15 @@ "type": "object" }, "example": { - "inboundId": 1, + "inboundIds": [ + 1 + ], "remark": "cdn-front", - "address": "cdn.example.com", + "hosts": [ + "cdn.example.com" + ], "port": 8443, "security": "same", - "sni": "", "tags": [ "CDN" ] @@ -8171,57 +9886,63 @@ "type": "string" }, "obj": { - "$ref": "#/components/schemas/Host" + "type": "array", + "items": { + "$ref": "#/components/schemas/Host" + } } } }, "example": { "success": true, - "obj": { - "address": "cdn.example.com", - "allowInsecure": false, - "alpn": [ - "" - ], - "createdAt": 0, - "echConfigList": "", - "excludeFromSubTypes": [ - "" - ], - "finalMask": "", - "fingerprint": "", - "hostHeader": "", - "id": 1, - "inboundId": 1, - "isDisabled": false, - "isHidden": false, - "keepSniBlank": false, - "mihomoIpVersion": "dual", - "mihomoX25519": false, - "muxParams": null, - "nodeGuids": [ - "" - ], - "overrideSniFromAddress": false, - "path": "", - "pinnedPeerCertSha256": [ - "" - ], - "port": 8443, - "remark": "cdn-front", - "security": "same", - "serverDescription": "", - "shuffleHost": false, - "sni": "", - "sockoptParams": null, - "sortOrder": 0, - "tags": [ - "" - ], - "updatedAt": 0, - "verifyPeerCertByName": "", - "vlessRoute": "" - } + "obj": [ + { + "address": "cdn.example.com", + "allowInsecure": false, + "alpn": [ + "" + ], + "createdAt": 0, + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "id": 1, + "inboundId": 1, + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": null, + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 8443, + "remark": "cdn-front", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": null, + "sortOrder": 0, + "tags": [ + "" + ], + "updatedAt": 0, + "verifyPeerCertByName": "", + "vlessRoute": "443" + } + ] } } } @@ -8229,21 +9950,21 @@ } } }, - "/panel/api/hosts/del/{id}": { + "/panel/api/hosts/del/{groupId}": { "post": { "tags": [ "Hosts" ], - "summary": "Delete a host.", - "operationId": "post_panel_api_hosts_del_id", + "summary": "Delete a host group.", + "operationId": "post_panel_api_hosts_del_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8270,21 +9991,21 @@ } } }, - "/panel/api/hosts/setEnable/{id}": { + "/panel/api/hosts/setEnable/{groupId}": { "post": { "tags": [ "Hosts" ], - "summary": "Enable or disable a single host (disabled hosts are skipped in subscriptions).", - "operationId": "post_panel_api_hosts_setEnable_id", + "summary": "Enable or disable a host group.", + "operationId": "post_panel_api_hosts_setEnable_groupId", "parameters": [ { - "name": "id", + "name": "groupId", "in": "path", "required": true, - "description": "Host ID.", + "description": "Host Group ID.", "schema": { - "type": "integer" + "type": "string" } } ], @@ -8329,7 +10050,7 @@ "tags": [ "Hosts" ], - "summary": "Set host sort order by the position of each id in the array.", + "summary": "Set host group sort order by the position of each groupId in the array.", "operationId": "post_panel_api_hosts_reorder", "requestBody": { "required": true, @@ -8340,9 +10061,8 @@ }, "example": { "ids": [ - 3, - 1, - 2 + "abc-123", + "def-456" ] } } @@ -8371,12 +10091,122 @@ } } }, + "/panel/api/hosts/bulk/add": { + "post": { + "tags": [ + "Hosts" + ], + "summary": "Add a host group to inbounds (same as /add).", + "operationId": "post_panel_api_hosts_bulk_add", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "inboundIds": [ + 1, + 2 + ], + "hosts": [ + "cdn.example.com", + "cdn2.example.com:443" + ], + "remark": "Cloudflare CDN", + "port": 0, + "security": "same", + "isDisabled": false + } + } + } + }, + "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/Host" + } + } + } + }, + "example": { + "success": true, + "obj": [ + { + "address": "cdn.example.com", + "allowInsecure": false, + "alpn": [ + "" + ], + "createdAt": 0, + "echConfigList": "", + "excludeFromSubTypes": [ + "" + ], + "finalMask": "", + "fingerprint": "", + "groupId": "", + "hostHeader": "", + "id": 1, + "inboundId": 1, + "isDisabled": false, + "isHidden": false, + "keepSniBlank": false, + "mihomoIpVersion": "dual", + "mihomoX25519": false, + "muxParams": null, + "nodeGuids": [ + "" + ], + "overrideSniFromAddress": false, + "path": "", + "pinnedPeerCertSha256": [ + "" + ], + "port": 8443, + "remark": "cdn-front", + "security": "same", + "serverDescription": "", + "shuffleHost": false, + "sni": "", + "sockoptParams": null, + "sortOrder": 0, + "tags": [ + "" + ], + "updatedAt": 0, + "verifyPeerCertByName": "", + "vlessRoute": "443" + } + ] + } + } + } + } + } + } + }, "/panel/api/hosts/bulk/setEnable": { "post": { "tags": [ "Hosts" ], - "summary": "Enable or disable many hosts in one call.", + "summary": "Enable or disable many host groups in one call.", "operationId": "post_panel_api_hosts_bulk_setEnable", "requestBody": { "required": true, @@ -8387,9 +10217,8 @@ }, "example": { "ids": [ - 1, - 2, - 3 + "abc-123", + "def-456" ], "enable": false } @@ -8424,7 +10253,7 @@ "tags": [ "Hosts" ], - "summary": "Delete many hosts in one call.", + "summary": "Delete many host groups in one call.", "operationId": "post_panel_api_hosts_bulk_del", "requestBody": { "required": true, @@ -8435,9 +10264,8 @@ }, "example": { "ids": [ - 1, - 2, - 3 + "abc-123", + "def-456" ] } } @@ -8556,6 +10384,36 @@ } } }, + "/panel/api/setting/factoryDefaults": { + "post": { + "tags": [ + "Settings" + ], + "summary": "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.", + "operationId": "post_panel_api_setting_factoryDefaults", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/setting/update": { "post": { "tags": [ @@ -8596,6 +10454,53 @@ } } }, + "/panel/api/setting/validateRegex": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Validate any regular expression with the backend Go RE2 compiler without saving it.", + "operationId": "post_panel_api_setting_validateRegex", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "regex": "(?m)^general-purpose$" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "msg": "" + } + } + } + } + } + } + }, "/panel/api/setting/updateUser": { "post": { "tags": [ @@ -9596,6 +11501,221 @@ } } }, + "/panel/api/xray/geodata/files": { + "get": { + "tags": [ + "Xray Settings" + ], + "summary": "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\".", + "operationId": "get_panel_api_xray_geodata_files", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, + "/panel/api/xray/geodata/categories": { + "get": { + "tags": [ + "Xray Settings" + ], + "summary": "One page of a database's categories, each with its entry count and the attributes its domains carry (e.g. \"ads\", \"cn\").", + "operationId": "get_panel_api_xray_geodata_categories", + "parameters": [ + { + "name": "file", + "in": "query", + "required": true, + "description": "Database file name inside the asset folder, e.g. geosite.dat (required).", + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "description": "Case-insensitive substring filter on the category code.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "Rows to skip. Defaults to 0.", + "schema": { + "type": "integer" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, + "/panel/api/xray/geodata/entries": { + "get": { + "tags": [ + "Xray Settings" + ], + "summary": "One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.", + "operationId": "get_panel_api_xray_geodata_entries", + "parameters": [ + { + "name": "file", + "in": "query", + "required": true, + "description": "Database file name inside the asset folder (required).", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "in": "query", + "required": true, + "description": "Category code, case-insensitive, e.g. google (required).", + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "description": "Case-insensitive substring filter on the rule value.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "Rows to skip. Defaults to 0.", + "schema": { + "type": "integer" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Rows to return, capped at 500. Defaults to the cap.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, + "/panel/api/xray/geodata/validate": { + "post": { + "tags": [ + "Xray Settings" + ], + "summary": "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.", + "operationId": "post_panel_api_xray_geodata_validate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/xray/outbound-subs": { "get": { "tags": [ @@ -9739,7 +11859,7 @@ "tags": [ "Xray Settings" ], - "summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).", + "summary": "Delete an outbound subscription by id (POST alias of DELETE for clients that cannot send DELETE).", "operationId": "post_panel_api_xray_outbound_subs_id_del", "parameters": [ { @@ -9887,12 +12007,286 @@ } } }, + "/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}": { "get": { "tags": [ "Subscription Server" ], - "summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.", + "summary": "Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. With ?format=info, returns the page view-model as JSON (traffic, expiry, online status; no links) for live polling. Default path: /sub/:subid.", "operationId": "get_subPath_subid", "parameters": [ { @@ -9904,6 +12298,15 @@ "type": "string" } }, + { + "name": "format", + "in": "query", + "required": false, + "description": "Set to \"info\" to get the subscription status view-model as JSON instead of the links.", + "schema": { + "type": "string" + } + }, { "name": "subPath", "in": "path", @@ -10213,234 +12616,6 @@ } } } - }, - "/panel/api/nodes/mtls/reloadClient": { - "post": { - "tags": [ - "Nodes" - ], - "summary": "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.", - "operationId": "post_panel_api_nodes_mtls_reloadClient", - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - } - } - } - } - } - } - }, - "/panel/api/inbounds/{id}/subSortIndex": { - "post": { - "tags": [ - "Inbounds" - ], - "summary": "Set only the subscription sort order. Reads the stored inbound, so a reorder cannot carry a stale client list over a concurrent edit.", - "operationId": "post_panel_api_inbounds_id_subSortIndex", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Inbound ID.", - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - }, - "example": { - "subSortIndex": 2 - } - } - } - }, - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - } - } - } - } - } - } - }, - "/panel/api/clients/hwids/{email}": { - "post": { - "tags": [ - "Clients" - ], - "summary": "List registered HWID devices for a client. Hashes are not exposed.", - "operationId": "post_panel_api_clients_hwids_email", - "parameters": [ - { - "name": "email", - "in": "path", - "required": true, - "description": "Client email.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - }, - "example": { - "success": true, - "obj": [ - { - "id": 1, - "firstSeen": 1735000000000, - "lastSeen": 1735100000000, - "userAgent": "Happ/1.0", - "deviceOs": "android", - "osVersion": "15", - "deviceModel": "Pixel 9" - } - ] - } - } - } - } - } - }, - "delete": { - "tags": [ - "Clients" - ], - "summary": "Clear all registered HWID devices for a client so new devices can register again.", - "operationId": "delete_panel_api_clients_hwids_email", - "parameters": [ - { - "name": "email", - "in": "path", - "required": true, - "description": "Client email.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - } - } - } - } - } - } - }, - "/panel/api/clients/hwids/{email}/{id}": { - "delete": { - "tags": [ - "Clients" - ], - "summary": "Remove a single registered HWID device by its id, freeing one slot under the HWID limit.", - "operationId": "delete_panel_api_clients_hwids_email_id", - "parameters": [ - { - "name": "email", - "in": "path", - "required": true, - "description": "Client email.", - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "required": true, - "description": "Device id, from the list endpoint.", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "msg": { - "type": "string" - }, - "obj": {} - } - } - } - } - } - } - } } } } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 45a902b50..ba242f8c6 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -241,6 +241,9 @@ "subJsonMux": { "type": "string" }, + "subJsonObservatory": { + "type": "string" + }, "subJsonPath": { "type": "string" }, @@ -438,6 +441,7 @@ "subJsonEnable", "subJsonFinalMask", "subJsonMux", + "subJsonObservatory", "subJsonPath", "subJsonRules", "subJsonURI", @@ -716,6 +720,9 @@ "subJsonMux": { "type": "string" }, + "subJsonObservatory": { + "type": "string" + }, "subJsonPath": { "type": "string" }, @@ -920,6 +927,7 @@ "subJsonEnable", "subJsonFinalMask", "subJsonMux", + "subJsonObservatory", "subJsonPath", "subJsonRules", "subJsonURI", @@ -3091,6 +3099,71 @@ ], "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": { "description": "User represents a user account in the 3x-ui panel.", "properties": { @@ -3162,6 +3235,10 @@ "name": "Xray Settings", "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", "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}": { "get": { "tags": [ diff --git a/frontend/src/api/queries/useSubBalancerMutations.ts b/frontend/src/api/queries/useSubBalancerMutations.ts new file mode 100644 index 000000000..5a12f2706 --- /dev/null +++ b/frontend/src/api/queries/useSubBalancerMutations.ts @@ -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), + }; +} diff --git a/frontend/src/api/queries/useSubBalancersQuery.ts b/frontend/src/api/queries/useSubBalancersQuery.ts new file mode 100644 index 000000000..95c210250 --- /dev/null +++ b/frontend/src/api/queries/useSubBalancersQuery.ts @@ -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 { + 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, + }; +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 8e4e4257c..313fcff9d 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -13,6 +13,10 @@ export const keys = { byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const, tags: () => ['hosts', 'tags'] as const, }, + subBalancers: { + root: () => ['sub-balancers'] as const, + list: () => ['sub-balancers', 'list'] as const, + }, settings: { root: () => ['settings'] as const, all: () => ['settings', 'all'] as const, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index aed7c520f..1aa72e071 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -66,6 +66,7 @@ export const EXAMPLES: Record = { "subJsonEnable": false, "subJsonFinalMask": "", "subJsonMux": "", + "subJsonObservatory": "", "subJsonPath": "", "subJsonRules": "", "subJsonURI": "", @@ -179,6 +180,7 @@ export const EXAMPLES: Record = { "subJsonEnable": false, "subJsonFinalMask": "", "subJsonMux": "", + "subJsonObservatory": "", "subJsonPath": "", "subJsonRules": "", "subJsonURI": "", @@ -728,6 +730,19 @@ export const EXAMPLES: Record = { "key": "", "value": "" }, + "SubBalancer": { + "createdAt": 1710000000000, + "enabled": true, + "id": 1, + "inboundIds": [ + 1, + 3 + ], + "remark": "auto-fastest", + "sortOrder": 1, + "strategy": "random", + "updatedAt": 1710000000000 + }, "User": { "id": 0, "password": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 06efbc130..7527c517a 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -215,6 +215,9 @@ export const SCHEMAS: Record = { "subJsonMux": { "type": "string" }, + "subJsonObservatory": { + "type": "string" + }, "subJsonPath": { "type": "string" }, @@ -412,6 +415,7 @@ export const SCHEMAS: Record = { "subJsonEnable", "subJsonFinalMask", "subJsonMux", + "subJsonObservatory", "subJsonPath", "subJsonRules", "subJsonURI", @@ -690,6 +694,9 @@ export const SCHEMAS: Record = { "subJsonMux": { "type": "string" }, + "subJsonObservatory": { + "type": "string" + }, "subJsonPath": { "type": "string" }, @@ -894,6 +901,7 @@ export const SCHEMAS: Record = { "subJsonEnable", "subJsonFinalMask", "subJsonMux", + "subJsonObservatory", "subJsonPath", "subJsonRules", "subJsonURI", @@ -3065,6 +3073,71 @@ export const SCHEMAS: Record = { ], "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": { "description": "User represents a user account in the 3x-ui panel.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 0eb091259..b1f1c0a37 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -74,6 +74,7 @@ export interface AllSetting { subJsonEnable: boolean; subJsonFinalMask: string; subJsonMux: string; + subJsonObservatory: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; @@ -188,6 +189,7 @@ export interface AllSettingView { subJsonEnable: boolean; subJsonFinalMask: string; subJsonMux: string; + subJsonObservatory: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; @@ -698,6 +700,17 @@ export interface Setting { value: string; } +export interface SubBalancer { + createdAt: number; + enabled: boolean; + id: number; + inboundIds: number[]; + remark: string; + sortOrder: number; + strategy: string; + updatedAt: number; +} + export interface User { id: number; password: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 41fa21b58..cd0dbbc75 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -90,6 +90,7 @@ export const AllSettingSchema = z.object({ subJsonEnable: z.boolean(), subJsonFinalMask: z.string(), subJsonMux: z.string(), + subJsonObservatory: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), @@ -205,6 +206,7 @@ export const AllSettingViewSchema = z.object({ subJsonEnable: z.boolean(), subJsonFinalMask: z.string(), subJsonMux: z.string(), + subJsonObservatory: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), @@ -746,6 +748,18 @@ export const SettingSchema = z.object({ }); export type Setting = z.infer; +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; + export const UserSchema = z.object({ id: z.number().int(), password: z.string(), diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index a294bc37f..a0159a10b 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -6,6 +6,7 @@ import { Drawer, Layout, Menu } from 'antd'; import type { MenuProps } from 'antd'; import { ApiOutlined, + ApartmentOutlined, CloseOutlined, CloudServerOutlined, ClusterOutlined, @@ -177,6 +178,7 @@ export default function AppSidebar() { const { pathname, hash } = useLocation(); const { allSetting } = useAllSettings(); const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable); + const showSubBalancers = !!allSetting.subJsonEnable; const [hovered, setHovered] = useState(() => hoveredAcrossRemounts); const [pinned, setPinned] = useState(readSidebarPinned); @@ -262,8 +264,15 @@ export default function AppSidebar() { label: t('menu.subFormats'), }); } + if (showSubBalancers) { + children.push({ + key: '/settings#subscription-balancers', + icon: , + label: t('pages.settings.subBalancers.menu'), + }); + } return children; - }, [t, showSubFormats]); + }, [t, showSubFormats, showSubBalancers]); const xrayChildren = useMemo>( () => [ diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index e7b04fb64..b88a80b85 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -66,6 +66,7 @@ export class AllSetting { subJsonMux = ''; subJsonRules = ''; subJsonFinalMask = ''; + subJsonObservatory = ''; subThemeDir = ''; subHideSettings = false; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 431e1f31c..839825e99 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', title: 'Subscription Server', diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index f862d6e46..d8b1bba3d 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -29,6 +29,7 @@ import TelegramTab from './TelegramTab'; import EmailTab from './EmailTab'; import SubscriptionGeneralTab from './SubscriptionGeneralTab'; import SubscriptionFormatsTab from './SubscriptionFormatsTab'; +import SubscriptionBalancersTab from './SubscriptionBalancersTab'; import './SettingsPage.css'; interface ApiMsg { @@ -42,6 +43,7 @@ const tabSlugs = [ 'email', 'subscription', 'subscription-formats', + 'subscription-balancers', ]; function isIp(h: string): boolean { @@ -219,6 +221,8 @@ export default function SettingsPage() { return ; case 'subscription-formats': return ; + case 'subscription-balancers': + return ; default: return ; } diff --git a/frontend/src/pages/settings/SubBalancerFormModal.tsx b/frontend/src/pages/settings/SubBalancerFormModal.tsx new file mode 100644 index 000000000..6151d21d8 --- /dev/null +++ b/frontend/src/pages/settings/SubBalancerFormModal.tsx @@ -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 = { + 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({ 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 ( + + {messageContextHolder} + +
+ + + + + + + + methods.setValue('inboundIds', v, { shouldDirty: true })} + /> + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/settings/SubscriptionBalancersTab.tsx b/frontend/src/pages/settings/SubscriptionBalancersTab.tsx new file mode 100644 index 000000000..2e2dad9b9 --- /dev/null +++ b/frontend/src/pages/settings/SubscriptionBalancersTab.tsx @@ -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 = { + 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) => 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(null); + + const inboundLabels = useMemo(() => { + const map = new Map(); + 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( + 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) => ( + {strategy} + ), + }, + { + title: t('pages.settings.subBalancers.inbounds'), + key: 'inbounds', + render: (_: unknown, r: SubBalancer) => { + const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`); + return ( + + {t('pages.settings.subBalancers.inboundsCount', { count: labels.length })} + + ); + }, + }, + { + title: t('pages.settings.subBalancers.enabled'), + dataIndex: 'enabled', + key: 'enabled', + width: 80, + align: 'center' as const, + render: (_: unknown, r: SubBalancer) => ( + toggleEnabled(r)} /> + ), + }, + { + title: '', + key: 'actions', + width: 96, + render: (_: unknown, r: SubBalancer) => ( + + + } + /> + )} +
+ +
+ r.id} + pagination={false} + loading={loading && !fetched} + scroll={{ x: true }} + locale={{ emptyText: t('pages.settings.subBalancers.empty') }} + columns={columns} + /> + + ); + + const observatoryTab = ( + <> + + + + + {observatoryEnabled && ( +
+ + setObservatoryField('destination', e.target.value)} + /> + + + setObservatoryField('connectivity', e.target.value)} + /> + + + setObservatoryField('interval', e.target.value)} + /> + + + setObservatoryField('timeout', e.target.value)} + /> + + + setObservatoryField('sampling', v))} + /> + + +