Compare commits

...

43 Commits

Author SHA1 Message Date
shazzreab 98ba88037c fix(subClashService): improve merging of clash rules in YAML (#5054) 2026-06-08 09:56:25 +02:00
Roman Gogolev d739bcf71e fix arm architecture xray binary file name (#5060) 2026-06-08 09:55:44 +02:00
Turan b0fe21c804 i18n(tr): Improve Turkish translation consistency and terminology (#5066)
Thank you for this great project!

I've made a comprehensive revision of the Turkish translation to improve consistency, grammatical accuracy, and natural flow for Turkish-speaking network administrators.

**Key Improvements:**
- **Unified "Client" Terminology:** Consistently translated as "Kullanıcı" (User) for human accounts and "İstemci" (Client) for software applications throughout the UI and Telegram Bot.
- **Inbounds & Outbounds:** Replaced the literal translations with professional networking terms: "Bağlantı Noktaları" (Inbounds) and "Çıkış Noktaları" (Outbounds).
- **Vowel Harmony Fixes:** Corrected several Turkish grammatical vowel harmony issues (e.g., *kullanıcısi* → *kullanıcısı*, *kullanıcılarini* → *kullanıcılarını*).
- **Capitalization & Phrasing:** Fixed capitalization inconsistencies (e.g., "Son Çevrimiçi") and improved phrasing for terms like "camouflage" → "Maskeleme" and "transport" → "Aktarım".

Technical English terms (SNI, TLS, REALITY, grpc, Vision, etc.) are intentionally kept in English as they are the standard in network engineering. 

Hope this helps the Turkish community!
2026-06-08 09:55:14 +02:00
Turan f6558571b4 docs(i18n): Add Turkish translation for README (#5067)
Hello! I noticed there was no Turkish README file despite having many other languages.

I have created README.tr_TR.md to help the Turkish community properly understand and deploy 3x-ui. The translation uses highly accurate networking terminology that matches the recent tr-TR.json improvements.

Thank you!
2026-06-08 09:54:13 +02:00
Tokenicrat 词元 4e253588ae fix(update.sh): allow skipping ssl setup when updating (#5071) 2026-06-08 09:53:50 +02:00
MHSanaei c6f15cd53f refactor(api)!: move /panel/setting and /panel/xray under /panel/api
Settings and Xray config endpoints now live at /panel/api/setting/* and /panel/api/xray/*, registered under the existing /panel/api group so they inherit the same Bearer-or-session auth (checkAPIAuth) as the rest of the API. An API token is a full-admin credential, so this just makes the surface consistent. The SPA page routes /panel/settings and /panel/xray are unchanged.

BREAKING CHANGE: the old /panel/setting/* and /panel/xray/* paths are removed. External callers must switch to the /panel/api/ prefix. Frontend call sites, API docs, the dev proxy, and the route-documentation test are updated to match.
2026-06-06 16:22:41 +02:00
MHSanaei a014c01725 feat(api-docs): generate OpenAPI components/schemas from Go structs
A new emit_jsonschema.go walks the same allow-listed structs as the zod/types/examples emitters and writes generated/schemas.ts (SCHEMAS). build-openapi mounts it under components.schemas and points each typed response obj at a $ref instead of an untyped {} blob, so Swagger renders real models and openapi-generator can emit clients.

Also add a vitest guard that safeParses every EXAMPLES entry against its generated zod schema, reviving the previously unused generated/zod.ts and catching drift between the example and schema emitters.
2026-06-06 16:22:21 +02:00
MHSanaei e56f6c63f6 fix(api-docs): target the panel base path in OpenAPI servers
ServeOpenAPISpec shipped servers:[{url:"/"}], so Swagger UI "Try it out" and external generators hit the origin root and ignored a non-root webBasePath. Inject the runtime base path into the single servers entry at serve time, touching only that field via json.RawMessage so the rest of the spec is preserved verbatim.
2026-06-06 16:22:08 +02:00
MHSanaei 83799d71b0 feat(api-docs): generate response examples from Go structs; fix SS2022 PSK regen (#4996)
Stop hand-writing OpenAPI response examples, which kept drifting from the real payloads (clients/traffic missing fields, inbounds/list exposing userId which is json:"-", the fictional inbound-443 tag instead of the real in-<port>-<transport> form).

tools/openapigen now emits frontend/src/generated/examples.ts: a per-struct example instance built from type defaults, validate oneof/min bounds, and example: struct tags, with nested-ref expansion and a cycle guard. build-openapi.mjs composes the {success,obj} envelope from it for any endpoint annotated with responseSchema (+ responseSchemaArray for lists); the hand-written response is dropped for those. Service DTOs InboundOption/ApiTokenView/ProbeResultUI are added to the walker.

#4996: client password regeneration now produces a valid Shadowsocks 2022 PSK (correct base64 length per cipher) when an SS2022 inbound is attached, in both the single and bulk client forms; backend surfaces ssMethod on /inbounds/options so the UI can pick the right length.

Also: Swagger UI persists the Authorization token across reloads (persistAuthorization).
2026-06-06 14:58:15 +02:00
MHSanaei 483952cfa0 fix(finalmask): validate fragment mask length so empty/zero-min can't crash xray
A fragment TCP finalmask with an empty length (the form's default for a
newly added mask) serializes to a 0-0 range, and xray-core rejects
LengthMin == 0 with a fatal config error that aborts the whole process,
taking every inbound offline. Default a new fragment mask to length
100-200 and add a form validator rejecting an empty value or a zero
minimum range before save. Verified against xray 26.6.1 (#4998).
2026-06-06 13:34:53 +02:00
MHSanaei 668c0922ca fix(sub): restore standard base64 for Shadowrocket sub link (#5001)
URL-safe base64 (-/_ with stripped padding) broke Shadowrocket import: it decodes the add/sub path segment as standard base64 and rejects -/_, so the subscription was silently not added. Revert to plain btoa() output as originally shipped in #3489.
2026-06-06 13:10:36 +02:00
MHSanaei 1b2a17f7e3 i18n: translate #4988 sockopt/REALITY-target/Freedom strings for all locales
Commit 6ed6f57b (#4988) added tcpWindowClampHint, the four realityTarget* keys, and the three FreedomHappyEyeballs* keys to en-US only. Fill in the other 12 locales so the new sockopt hint, REALITY target validation messages, and Freedom Happy Eyeballs options are localized. Technical tokens (REALITY, Xray-core, IPv4/IPv6, Happy Eyeballs, port examples, ms) are kept literal.
2026-06-06 12:42:30 +02:00
Sanaei e6c1ce9aa9 feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005)
* feat(nodes): add stable panel GUID identity (multi-hop phase 0)

Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983).

Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on.

Refs #4983

* feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1)

Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched.

The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id.

Refs #4983

* feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2)

Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983).

xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly.

Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983

Refs #4983

* feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a)

Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983).

The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next.

Refs #4983

* feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b)

The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983).

Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983

Refs #4983

* i18n(nodes): translate subNode/subNodeTip across all locales

Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation.

Refs #4983
2026-06-06 12:33:39 +02:00
nima1024m 6ed6f57b5c fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988)
* fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target

Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults
to 0, split server/client Reality fields on save, validate target host:port in
the inbound form, and expose Happy Eyeballs for the direct freedom outbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(panel): keep REALITY public key on the wire, guard freedom noises

The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload.

Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler.

Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-06 02:40:32 +02:00
MHSanaei e409bc305d fix(iplimit): skip stale access-log emails after client rename/delete
The IP-limit job scrapes the Xray access log, which keeps lines tagged with a client's old email for up to a log-rotation cycle after a rename or delete. For each such email getInboundByEmail (settings LIKE %email%) found nothing, so the job logged 'failed to fetch inbound settings: record not found' every run and recreated an inbound_client_ips row for the dead email (rows reappeared even after manual deletion).

processLogFile now resolves the inbound once per email: if it maps to no inbound (gorm.ErrRecordNotFound) it logs at Debug, drops any orphan tracking row, and skips - so stale entries self-heal instead of spamming ERROR. The resolved inbound is passed into updateInboundClientIps, removing its internal lookup. updateClientTraffics also calls DelClientIPs alongside DelClientStat so a full inbound edit that drops an email doesn't leave a ghost row.

Closes #4963
2026-06-06 02:20:39 +02:00
MHSanaei 2b4e199a97 fix(sub): don't project public inbounds through a fallback master
A standalone inbound bound to a public/wildcard listen that still carried a stale inbound_fallbacks row had its share/subscription link rewritten with the master's port + Reality/TLS settings (keeping only its own transport), producing an unusable link that silently fails - the client connects but no traffic flows. The leak hit every backend link surface: subscription URL, JSON sub, Clash sub, and the panel Client Information link.

Gate projectThroughFallbackMaster on reachability: only project a child that is not directly reachable on its own listen (loopback or a unix-domain socket). A public or wildcard inbound advertises its own port + security regardless of any fallback row. Legit loopback/socket fallback children still project as before.

Closes #4987
2026-06-06 02:13:39 +02:00
MHSanaei 75bc6e8076 fix(inbound-form): wrap long labels and shorten RU pinned-cert label
Long TLS-tab labels overflowed their field in locales with wider strings (e.g. Russian 'Pinned Peer Cert SHA-256'). Add AntD labelWrap to the inbound and outbound form modals so any over-long label wraps onto a second line instead of overflowing, and shorten the Russian pinnedPeerCertSha256 label to fit.

Closes #4986
2026-06-06 01:53:46 +02:00
MHSanaei eeb19b7240 fix(node-sync): merge client enable with boolean AND for PostgreSQL
The per-client traffic merge built enable = CASE WHEN ? = 0 THEN 0 ELSE enable END, mixing an integer literal with the boolean enable column. PostgreSQL rejects this with SQLSTATE 42804, aborting every node traffic merge transaction every 5s and freezing all up/down/last_online accounting on Postgres main panels. Replace with enable AND ?, which is type-safe on Postgres (boolean AND boolean) and identical in semantics on SQLite: the node may only disable a client, never re-enable one the panel already disabled.

Closes #4964
2026-06-06 01:46:55 +02:00
MHSanaei 5b9db13e55 fix(finalmask): treat sudoku customTables as array of tables
customTables is the plural array form of customTable, so default it to [""] and edit it with a tags Select instead of binding a text Input to an array value.
2026-06-06 01:35:14 +02:00
Sanaei 0706b0b3a8 feat(x-ui.sh): add migrateDB command for SQLite .db <-> .dump (#4910)
* feat(x-ui.sh): add migrateDB command and menu for SQLite .db <-> .dump

Adds an "x-ui migrateDB <file>" subcommand and a PostgreSQL-menu option (9)
that convert between a SQLite .db and a portable .dump file. Direction is
auto-detected from the extension and delegated to the bundled binary
(x-ui migrate-db --dump/--restore), so no external sqlite3 client is needed.

Depends on the matching binary support, so it is only usable from the next
panel release.

* fix(x-ui.sh): address review feedback on migrateDB

Per Copilot review on PR #4910:
- Probe the bundled binary for migrate-db --dump support and fail with a clear
  upgrade message instead of a raw "flag not defined" error on old builds.
- Prompt before overwriting an existing .dump in dump mode (parity with restore).
- Refuse to restore into the live database path while x-ui is running, to avoid
  corrupting the running panel.
- Fix the usage/synopsis strings to show input is optional ([file] not <file>).
2026-06-05 11:28:11 +02:00
MHSanaei db118cbcc9 v3.2.8 2026-06-05 11:06:17 +02:00
MHSanaei e7ffae5329 fix(outbound): import ech and pcs from TLS share links
The vless/trojan link parser's TLS branch read only sni/fp/alpn, so the
ech (echConfigList) and pcs (pinnedPeerCertSha256) query params were
dropped on import even though buildStream allocates both fields. Read
them in applySecurityParams to match the inbound link generator and the
hysteria2 parser.
2026-06-05 11:01:51 +02:00
MHSanaei f470bc7cf8 docs(contributing): refresh frontend guide and add Postgres launch profile
The frontend section still described the old multi-page app. Rewrite it for the current React Router SPA (single index.html bundle), TanStack Query server state, the Zod source-of-truth model plus generated types, and link logic under src/lib/xray. Update the "adding a page" flow to the route-based approach and drop the stale MIGRATED_ROUTES / "no React Router" notes.

Correct the Vite pin (was 8.0.13 "never bump", now exact 8.0.16) and add "npm run test" to the PR checklist.

Document the new "Run 3x-ui (Postgres)" launch profile and fix the gitignore claim: .vscode/launch.json is checked in, not gitignored.
2026-06-05 10:57:51 +02:00
MHSanaei a8d5d0dfab fix(external-proxy): relabel "Host" as "Address", add per-entry ECH (#4935)
The external proxy "Host" field was bound to dest (the connection address that becomes the link host) but labeled "Host", misleading users into thinking it set a transport host header. Relabel it to "Address" to match what it actually controls.

Add per-entry ECH (echConfigList) to the external proxy schema, form (shown under Force TLS = TLS), the TS link generator, and the Go sub services: ech is emitted on share links and vmess objects, and written into the stream so the JSON subscription picks it up via the existing tlsData reader.
2026-06-05 10:40:11 +02:00
MHSanaei b40f869f2a fix(node): keep client/inbound edits working when a node is offline (#4923, #4931)
Node-backed client and inbound edits no longer hard-fail when the backing node is offline or disabled. Edits commit to the panel DB immediately and reconcile to the node when it reconnects (eventual consistency); the panel is the single source of truth for desired config.

- Add Node.ConfigDirty/ConfigDirtyAt; mark a node dirty when an edit commits without reaching it (cleared via CAS on ConfigDirtyAt after a full reconcile).
- nodePushPlan() reads node state fresh from the DB, skips the push for offline/disabled nodes (no 10s hang), and treats push failures as non-fatal across every mutation path (client add/update/del + bulk + attach/detach; inbound add/update/del/toggle/resetTraffic).
- ReconcileNode() pushes the panel's desired config to a node on reconnect (refreshing the remote tag cache first) and prunes node-side orphans; runs before the traffic pull in the node sync job.
- While a node is dirty the traffic pull applies only up/down deltas and node-initiated disables, never overwriting desired config from a stale node snapshot.
- Surface a non-blocking 'saved; will sync on reconnect' warning to the UI.

Validated with a two-panel Docker E2E: client delete/update, attach/detach, and inbound add/delete all reconcile correctly offline -> reconnect.
2026-06-05 02:26:57 +02:00
MHSanaei e08456269b fix(traffic): count local traffic for clients whose shared row is node-owned (#4921)
client_traffics is keyed by email (one shared row per client across every
inbound it is attached to). addClientTraffic filtered with
`inbound_id NOT IN (node inbounds)`, so when a client was attached to both a
node inbound and the mother inbound and the node inbound was attached first,
the shared row carried the node inbound's id (AddClientStat uses OnConflict
DoNothing and never refreshes it) and the local xray's traffic for that client
was dropped entirely. The client showed online but its usage stayed at zero
unless the mother inbound happened to be attached first.

Match purely by email instead. The reported emails come only from the local
xray, which only knows local-attached clients, so the query is still correctly
scoped, and this also repairs already-broken rows that a per-row AddClientStat
fix alone could not.
2026-06-05 00:24:01 +02:00
MHSanaei f8e902a7b6 fix(sub): include ECH config in TLS share links and JSON subscription
echConfigList was stored under tlsSettings.settings but the share-link
and JSON-subscription generators only read fingerprint and
pinnedPeerCertSha256 from that bag, silently dropping ECH from VLESS,
Trojan and VMess links. Read echConfigList alongside them and flatten it
into tlsSettings.echConfigList for the JSON subscription.

Closes #4933
2026-06-05 00:20:29 +02:00
Hamed d6d2085d60 fix: restart remote xray after disabling a client to kill active sessions (#4918)
* fix(node-traffic): restart remote xray after disabling clients to kill active sessions

When a client's traffic limit is reached on a remote node, the panel pushes
enable=false to that node via UpdateInbound. The node calls RemoveUser on its
local xray, which blocks new connections but leaves any already-established TCP
session alive. The user could continue browsing/downloading until they
disconnected voluntarily.

Fix: after successfully pushing a client disable to a remote node, call
RestartXray on that node. This mirrors what already happens for the local node
when the "Restart Xray on client disable" setting is enabled (default: on),
and ensures active sessions are terminated immediately on all nodes where the
client was disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(node): restart remote xray after tx commit, not inside it

Move the remote RestartXray calls out of the addTraffic write
transaction. disableInvalidClients now returns the affected remote
node IDs instead of restarting their xray while the SQLite write lock
is held; AddTraffic performs the restart after the transaction commits
via restartRemoteNodesOnDisable. Avoids holding the serialized write
lock across slow per-node restart RPCs.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-05 00:02:52 +02:00
Hamed 12d84c2a46 fix(node-traffic): prevent stale node snapshot from re-enabling disabled client (#4917)
When a remote node syncs traffic back to the panel, the UPDATE in
setRemoteTrafficLocked wrote cs.Enable directly into client_traffics.enable.
If a snapshot carrying enable=true arrived after the central panel had already
set enable=false (due to the client reaching their traffic limit), it silently
re-enabled the client — letting them consume 2-3x their allotted quota before
the next disable cycle caught up.

Fix: replace the unconditional SET enable = ? with a CASE expression that only
allows a disable (0->0), never a re-enable (0->1). The central panel remains
the sole authority for turning a client back on.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-04 23:54:29 +02:00
biohazardous-man 97f88fb1a9 feat(sub): modern xray JSON format with unified finalmask editor (#4912)
* feat(sub): add finalmask support to JSON subscriptions

* feat(sub): modern xray JSON format with unified finalmask editor

Drop the legacy JSON subscription format entirely and always emit the
modern xray shape:

- Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/
  shadowsocks; hysteria was already flat.
- Express fragment/noise via streamSettings.finalmask instead of the
  legacy direct_out freedom dialer + dialerProxy sockopt.

The global finalmask (tcp/udp masks + quicParams) is stored as a single
setting (subJsonFinalMask) and merged into every generated stream,
replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams
settings.

Reuse the existing FinalMaskForm (used by inbound/outbound) for the
settings UI via a small bridge component; add a showAll prop so all
TCP/UDP/QUIC sections render for the global case. This supersedes the
hand-rolled Fragment/Noises/quicParams tabs with the full mask editor
(all mask types).

Note: this is a breaking change — JSON subscriptions now require a
recent xray client on the consumer side.

* fix

---------

Co-authored-by: biohazardous-man <biohazardous-man@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-04 23:51:48 +02:00
Misfit-s f947fbd6c6 feat(Clash): Add routing rules and enable routing option for Clash subscriptions (#4904)
* feat(clash): add routing rules and enable routing option for Clash/Mihomo subscriptions

Allows adding custom YAML blocks and placeholders to Clash exports.

Why: Shifting routing to the client prevents server IP exposure for
DIRECT traffic and reduces unnecessary server bandwidth/CPU usage.

* fix

---------

Co-authored-by: Misfit-s <>
2026-06-04 21:55:51 +02:00
dependabot[bot] ba63fa8569 chore(deps): bump i18next from 26.3.0 to 26.3.1 in /frontend (#4901)
Bumps [i18next](https://github.com/i18next/i18next) from 26.3.0 to 26.3.1.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.1)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 21:46:11 +02:00
康厚超 73ce11508e fix(tgbot): ignore commands for other bots (#4894)
Telegram group chats can contain multiple bots. Commands addressed to another bot, such as /status@other_bot, should not be handled by the 3x-ui bot.

Closes #4893
2026-06-04 21:45:44 +02:00
lim-kim930 a4b3e999a1 fix(i18n): add 1-year expiration to language cookie (#4890) 2026-06-04 21:38:15 +02:00
MHSanaei d3db828b46 perf(clients): scale-audit remaining client/inbound endpoints to 200k
Drive every client/inbound/group endpoint at 100k-200k clients on PostgreSQL and fix the latent issues found in previously-unbenchmarked paths:

- enrichClientStats: chunk the email IN lookup (was an unchunked bind that crashed past 65535 clients without traffic rows, taking down GetInbounds/GetInboundDetail/GetAllInbounds)

- GetOnlineClients: add the missing nil-process guard its siblings already have, so ListPaged no longer panics before xray starts

- GetClientTrafficByEmail: read UUID/subId from the indexed clients table instead of parsing the inbound's full settings JSON (439ms to ~1.5ms, flat in N)

- BulkResetTraffic: replace the per-email serialized loop with one chunked bulk UPDATE in a single transaction

- DelDepleted: delegate to the already-batched BulkDelete instead of deleting each depleted client one by one

Adds a postgres-gated full endpoint sweep plus an A/B benchmark, and SQLite correctness tests for the changed methods.
2026-06-04 21:32:15 +02:00
MHSanaei d1e733b9e9 perf(clients): chunk IN queries and de-quadratic bulk delete/group/list
Bulk client operations bound their entire working set in a single
WHERE x IN (...) clause, which exceeds PostgreSQL's 65535-parameter limit
(and SQLite's 32766) and gives the planner a pathological query, so they
failed outright on inbounds/selections larger than the limit. Every such
query is now chunked at 400 items:

- BulkDelete / delete-all-clients: six IN queries chunked, and the
  per-row delete tombstone (which swept the whole in-memory map on every
  call, O(N^2)) replaced with a single bulk sweep.
- BulkAdjust: record and inbound-mapping lookups chunked.
- AddToGroup / RemoveFromGroup (bulk add/remove to group): three IN
  queries chunked.
- replaceGroupValue (rename/delete group): inbound-mapping lookup chunked.
- List (all-clients listing): link and traffic lookups chunked.

Measured on PostgreSQL 16: delete-all-clients on a 100k-client inbound
now completes in ~7s (previously crashed at the parameter limit); bulk
add/remove to group ~6s and full client list ~1s at 100k.

sync_scale_postgres_test.go adds skip-gated benchmarks for delete-all,
group add/remove, and list.
2026-06-04 20:35:30 +02:00
MHSanaei f185d3315c perf(clients): scale add/delete and bulk client operations
Follow-up to the SyncInbound bulk rewrite, fixing the remaining O(M*N)
and O(M)-round-trip behaviour in the add/delete and bulk paths that made
them time out on large inbounds (worst case minutes), especially on
PostgreSQL.

- compactOrphans: chunk the "email IN (...)" lookup (400/batch) instead
  of binding every email at once. A single huge IN exceeded PostgreSQL's
  65535-parameter limit (and SQLite's) and made the planner pathological,
  so add/delete failed outright past ~100k clients.

- emailsUsedByOtherInbounds: new batched form used by delInboundClients
  (BulkDetach) and bulkDelInboundClients (BulkDelete), replacing a
  per-email global JSON scan (O(M*N)) with one scan, and skipped entirely
  when keepTraffic is set.

- BulkCreate: rewritten to validate/dedup in one pass, then group clients
  by inbound and add them in a single addInboundClient call per inbound
  (one getAllEmailSubIDs, one settings rewrite, one SyncInbound) instead
  of running the full single-create pipeline per client.

- Bulk delete/adjust: batch DelClientStat/DelClientIPs with IN deletes
  and wrap the settings Save + SyncInbound in one transaction, so the
  per-row writes share a single fsync instead of one per row.

Measured on PostgreSQL 16 (one inbound, M=2000 affected clients):
  - create: 8m35s (M=500) -> ~1-5s
  - detach: 52s -> ~4s (flat in N)
  - delete: ~16s -> ~1-4s
  - adjust: ~20s -> ~7-10s
add/delete of a single client on a 200k-client inbound stays in seconds.

sync_scale_postgres_test.go adds skip-gated benchmarks (XUI_DB_TYPE=
postgres) for the single add/delete and the five bulk operations.
2026-06-04 19:41:00 +02:00
MHSanaei 756746dbca perf(clients): make SyncInbound bulk to fix large-inbound timeouts (#4885)
Every client mutation funnels through SyncInbound, which ran O(n) DB
round-trips per call: one SELECT per client, a Save+UpdateColumn per
client, and a per-row junction INSERT. Toggling a single client on a
large inbound issued thousands of queries and timed out, badly so on
PostgreSQL where each round-trip pays TCP latency.

SyncInbound now:
- loads existing records with a single chunked SELECT ... email IN (...)
  instead of one query per client
- writes only the records that actually changed (skips no-op Saves), so
  toggling/editing one client writes one row, not all of them
- batch-creates new records and batch-inserts the junction rows

Merge and sticky-field semantics are unchanged. Measured on PostgreSQL
16: a single-client toggle on a 50k-client inbound drops from ~8m54s to
~0.9s, and seeding 50k clients from ~2m48s to ~1.6s; 200k clients sync
in seconds.

A skip-gated benchmark (web/service/sync_scale_postgres_test.go, run
with XUI_DB_TYPE=postgres) reproduces and verifies the scaling.
2026-06-04 18:14:25 +02:00
MHSanaei 44291de989 fix(ssl): clean ECC state, guard cert reuse, register renew hook (#4875)
- Cleanup on issuance/install failure now also removes the acme.sh
  ${domain}_ecc (and ${ip}_ecc) directory, not just ${domain}, so a
  failed run no longer leaves partial state behind.
- The 'existing certificate' check only reuses a cert when its
  fullchain.cer and key files are actually present and non-empty;
  otherwise the broken state is removed and issuance proceeds. This
  fixes the 0-byte fullchain.pem produced by reusing failed state.
- Menu option 5 (set cert paths) now registers the acme.sh --installcert
  hook with --reloadcmd 'x-ui restart' when acme.sh knows the domain, so
  auto-renewal copies the renewed cert and reloads the panel.
2026-06-04 17:15:33 +02:00
MHSanaei b1d079fc24 fix(fail2ban): exempt SSH and panel ports from IP-limit ban (#4896)
The 3x-ipl action used iptables-allports, so a banned IP lost all TCP
access including SSH and the panel, locking admins out (especially with
dynamic-IP clients). The ban now blocks every TCP port except the SSH
and panel ports via a multiport negation, derived at jail-creation time
in both x-ui.sh and DockerEntrypoint.sh. This keeps IP-limit working for
all current and future inbounds without per-port config.
2026-06-04 17:05:27 +02:00
MHSanaei 14e2d4954a fix(migrate-db): drop legacy client_traffics FK before Postgres copy (#4882)
AutoMigrate re-creates the client_traffics -> inbounds foreign key, but
the running panel drops it and tolerates client_traffics rows whose
inbound was deleted. Migrating a DB with such orphaned rows failed with
an fk_inbounds_client_stats violation. Drop the constraint on the
destination right after AutoMigrate so the copy matches runtime behavior.
2026-06-04 16:57:09 +02:00
MHSanaei db86007ab8 fix(multi-node): scope remote client update/delete to one inbound (#4892)
UpdateUser and DeleteUser hit the node's email-based full-client endpoints, which fanned out to every inbound the client had on the node: editing a client wiped flow on the node's other inbounds, and detaching one node inbound deleted the client from all of them.

Make both inbound-scoped, mirroring AddClient. DeleteUser now detaches the resolved remote inbound id; UpdateUser passes an inboundIds scope so the node updates only that inbound.
2026-06-04 16:45:40 +02:00
MHSanaei a07c7b7f4e feat(migrate-db): SQLite <-> .dump conversion and Download Migration in Overview
Binary: extend the migrate-db subcommand with --dump and --restore so a
SQLite database can be exported to a portable SQL text dump and rebuilt from
one, alongside the existing --dsn PostgreSQL copy. Implemented in Go via the
bundled sqlite driver (new database/dump_sqlite.go); no external sqlite3 client
is required. Add ExportPostgresToSQLite (reverse of MigrateData) to build a
SQLite .db from live PostgreSQL data, reusing the shared copyAllModels helper.

Overview: add a "Download Migration" item to Backup & Restore plus a
getMigration endpoint/service that returns a .dump on SQLite or a .db on
PostgreSQL, so the data can seed a panel on the other backend. Document the
endpoint in api-docs and translate the three new strings across all locales.

Tests: cover the destination-side copy (AutoMigrate + copyTable into SQLite)
and the dump/restore round-trip including quoted values. Ignore *.dump.

The x-ui.sh helper that drives this from the CLI is in PR #4910.
2026-06-04 15:32:22 +02:00
135 changed files with 11160 additions and 1669 deletions
+1
View File
@@ -6,3 +6,4 @@ db
cert cert
pgdata pgdata
*.db *.db
*.dump
+17
View File
@@ -37,6 +37,23 @@ jobs:
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt) go test $(cat /tmp/go-packages.txt)
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Regenerate schemas, examples and OpenAPI
run: npm run gen
working-directory: frontend
- name: Fail if generated files are stale (run 'npm run gen' and commit)
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
govulncheck: govulncheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -38,6 +38,7 @@ Thumbs.db
x-ui.db x-ui.db
x-ui.db-shm x-ui.db-shm
x-ui.db-wal x-ui.db-wal
*.dump
# Ignore Docker specific files # Ignore Docker specific files
docker-compose.override.yml docker-compose.override.yml
+66 -28
View File
@@ -86,10 +86,11 @@ Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `a
### Inside VS Code ### Inside VS Code
The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent): The repo checks in two VS Code launch profiles in `.vscode/launch.json`: **Run 3x-ui (Debug)** for the default SQLite setup, and **Run 3x-ui (Postgres)** which points `XUI_DB_TYPE`/`XUI_DB_DSN` at a local PostgreSQL. The Postgres profile also prepends the PostgreSQL `bin` to `PATH` so the panel can find `pg_dump`/`pg_restore` (the `postgresql-client` tools used for DB backup/restore) — adjust the DSN and that path to your machine:
```jsonc ```jsonc
{ {
"$schema": "vscode://schemas/launch",
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
@@ -106,6 +107,23 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
"XUI_BIN_FOLDER": "x-ui" "XUI_BIN_FOLDER": "x-ui"
}, },
"console": "integratedTerminal" "console": "integratedTerminal"
},
{
"name": "Run 3x-ui (Postgres)",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
"XUI_DEBUG": "true",
"XUI_LOG_FOLDER": "x-ui",
"XUI_BIN_FOLDER": "x-ui",
"XUI_DB_TYPE": "postgres",
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable",
"PATH": "C:\\Program Files\\PostgreSQL\\18\\bin;${env:PATH}"
},
"console": "integratedTerminal"
} }
] ]
} }
@@ -117,14 +135,21 @@ The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`
### Architecture ### Architecture
The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either. The frontend ships **three Vite bundles**, each emitted into `web/dist/` and embedded into the Go binary at compile time via `embed.FS`:
- **`index.html`** — the admin panel, a **single-page app**. `src/main.tsx` mounts a `react-router` `createBrowserRouter` (see `src/routes.tsx`) under the `/panel` basename; every route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/groups`, `/panel/nodes`, `/panel/settings`, `/panel/xray`, `/panel/api-docs`) is lazy-loaded inside a shared `PanelLayout` (sidebar + header + `<Outlet>`).
- **`login.html`** — the login + 2FA screen (`src/entries/login.tsx`), a standalone bundle.
- **`subpage.html`** — the public subscription viewer (`src/entries/subpage.tsx`), a standalone bundle.
Panel navigation happens client-side through React Router, and per-route code is lazy-split so the initial panel load stays small. `login` and `subpage` stay separate documents because they are reached without an authenticated panel session.
### State and data flow ### State and data flow
- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive. - **Server state via TanStack Query.** API reads go through `@tanstack/react-query` (`QueryProvider` in `src/main.tsx`, keys in `src/api/queryKeys.ts`); responses are cached and invalidated on mutation rather than blindly re-fetched, and WebSocket pushes feed back into the cache via `src/api/websocketBridge.ts`.
- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global. - **Local UI state stays in the page** (`useState`); shared concerns go through contexts and hooks in `src/hooks/` (`useTheme`, `useWebSocket`, `useClients`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer. - **Zod is the single source of truth.** Schemas in `src/schemas/` define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with `z.infer` — never hand-written. Go-side types are mirrored into `src/generated/` by `npm run gen:zod` (do not hand-edit that folder).
- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`. - **xray domain logic** — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in `src/lib/xray/`. `src/models/` keeps only thin legacy types still being migrated onto schemas.
- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.ts`.
### i18n ### i18n
@@ -134,21 +159,22 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
| Goal | Command | | Goal | Command |
|------|---------| |------|---------|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. | | Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. | | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there. The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild. > **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
### Adding a new page ### Adding a new page
1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`). Most new screens are **admin-panel routes** and need no new HTML or Vite entry:
2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component). 1. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
4. Register the entry in `rollupOptions.input` inside `vite.config.js`. 2. Register it in `src/routes.tsx` under the `/panel` tree (lazy-import it like the others).
5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works. 3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it should be reachable from the nav.
6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable without the panel shell) needs the full entry treatment: add `frontend/<page>.html`, a `src/entries/<page>.tsx` bootstrap, register it in `rollupOptions.input` inside `vite.config.js`, and wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
### Conventions ### Conventions
@@ -157,27 +183,40 @@ The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/pa
- **Function components + hooks** everywhere. No class components. - **Function components + hooks** everywhere. No class components.
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising. - **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. - **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes. - **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode. - **Document new endpoints.** Every new `g.POST`/`g.GET` in `web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
### Project layout ### Project layout
``` ```
frontend/ frontend/
├── *.html — Vite entry HTML, one per panel route ├── index.html — admin panel SPA entry
├── login.html — login + 2FA entry
├── subpage.html — public subscription viewer entry
├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*" ├── tsconfig.json — strict, jsx: "react-jsx", paths "@/*" → "src/*"
├── eslint.config.js — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks) ├── eslint.config.js — ESLint flat config (@eslint/js + typescript-eslint + react-hooks)
├── vite.config.js ├── vite.config.js
├── vitest.config.ts
├── scripts/ — build-openapi.mjs (endpoints.ts → openapi.json)
└── src/ └── src/
├── entries/ — per-page bootstrap (createRoot + render) ├── main.tsx — admin SPA bootstrap (router + providers)
├── pages/ — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub) ├── routes.tsx — react-router routes mounted under /panel
├── components/ — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …) ├── entries/ — bootstrap for the standalone bundles (login, subpage)
├── hooks/ — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …) ├── layouts/ — PanelLayout + AppSidebar
├── api/ — Axios setup + CSRF interceptor + WebSocket client ├── pages/ — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub
├── components/ — cross-page React components
├── hooks/ — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …)
├── api/ — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client
├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/) ├── i18n/ — react-i18next bootstrap (JSON lives in web/translation/)
├── models/ — Inbound, DBInbound, Outbound, Status, reality-targets, … ├── lib/xray/ — pure xray logic: link generation, defaults, form ⇄ wire adapters
├── schemas/ — Zod source of truth for the xray config model
├── generated/ — code-generated Zod + TS types from Go (do not hand-edit)
├── models/ — thin legacy types still being migrated
├── styles/ — shared CSS (page-cards, …) ├── styles/ — shared CSS (page-cards, …)
└── utils/ — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, … ├── test/ — Vitest specs + golden fixtures
└── utils/ — HttpUtil, ClipboardManager, SizeFormatter, …
``` ```
For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md). For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
@@ -202,7 +241,7 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
3. Run the relevant checks before pushing: 3. Run the relevant checks before pushing:
- `go build ./...` - `go build ./...`
- `go test ./...` (when Go code changed) - `go test ./...` (when Go code changed)
- `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed) - `cd frontend && npm run typecheck && npm run lint && npm run test && npm run build` (when the frontend changed; CI runs this same set on every PR via `.github/workflows/ci.yml`)
4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged. 4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
5. Open the PR against `main` with a brief description of what changed and how to test it. 5. Open the PR against `main` with a brief description of what changed and how to test it.
@@ -218,9 +257,8 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` | | `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` | | `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
## Issues and discussion ## Issues
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues) - Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
- General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`. Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
+13 -2
View File
@@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex = ignoreregex =
EOF EOF
# Ports to exempt from the ban so an over-limit proxy client can never lock
# the administrator out of SSH or the panel. The ban still covers every other
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
# added later without regenerating these files.
SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -)
[ -z "$SSH_PORTS" ] && SSH_PORTS="22"
PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
EXEMPT_PORTS="$SSH_PORTS"
[ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT"
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
[INCLUDES] [INCLUDES]
before = iptables-allports.conf before = iptables-allports.conf
@@ -42,16 +52,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]' actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype> actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype> actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init] [Init]
name = default name = default
protocol = tcp protocol = tcp
chain = INPUT chain = INPUT
exemptports = $EXEMPT_PORTS
EOF EOF
fail2ban-client -x start fail2ban-client -x start
+177
View File
@@ -0,0 +1,177 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
</p>
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş, gelişmiş ve açık kaynaklı bir web kontrol panelidir. Tek bir VPS'den çok düğümlü (multi-node) kurulumlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen 3X-UI; daha geniş protokol desteği, iyileştirilmiş kararlılık, kullanıcı başına (per-client) trafik takibi ve birçok yaşam kalitesi (QoL) özelliği ekler.
> [!IMPORTANT]
> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
## Özellikler
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
- **Modern aktarım (Transport) & güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvenli hale getirilmiştir.
- **Yedek bağlantılar (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port (örn. 443) üzerinden birden fazla protokol (örn. VLESS ve Trojan) sunma.
- **Kullanıcı bazlı yönetim** — Trafik kotaları, son kullanma tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıklamayla paylaşım bağlantıları, QR kodları ve abonelikler.
- **Trafik istatistikleri** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri.
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panelden birden fazla sunucuyu yönetin ve ölçeklendirin.
- **Çıkış noktaları & yönlendirme (Outbound & Routing)** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve çıkış noktası proxy zincirleme.
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
- **Telegram botu** (Uzaktan izleme ve yönetim için).
- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
## Ekran Görüntüleri
<details>
<summary>Genişletmek için tıklayın</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Genel Bakış" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Bağlantı Noktaları" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Kullanıcı Ekle" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Yapılandırmalar" src="./media/05-add-nodes-light.png">
</picture>
</details>
## Hızlı Başlangıç
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu (path) oluşturulur. Kurulumdan sonra `x-ui` komutunu çalıştırarak yönetim menüsünü açabilir; buradan hizmeti başlatabilir/durdurabilir, giriş bilgilerinizi görüntüleyebilir veya sıfırlayabilir, SSL sertifikalarını yönetebilir ve daha fazlasını yapabilirsiniz.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
## Desteklenen Platformlar
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine ve Windows.
**Mimariler:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## Veritabanı Seçenekleri
3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Sıfır kurulum gerektirir, küçük ve orta ölçekli dağıtımlar için idealdir.
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çok düğümlü (multi-node) kurulumlar için önerilir. Yükleyici sizin için PostgreSQL'i yerel olarak kurabilir veya mevcut bir sunucuya DSN ile bağlanabilir.
Çalışma anında arka uç, ortam değişkenleri (environment variables) aracılığıyla seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# Ardından /etc/default/x-ui dosyasında XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
systemctl restart x-ui
```
Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
### Docker
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Dahili PostgreSQL hizmetiyle çalıştırmak için `docker-compose.yml` dosyasındaki iki `XUI_DB_*` ortam değişkeni satırının başındaki yorum işaretini kaldırın ve profille başlatın:
```bash
docker compose --profile postgres up -d
```
İmaj, kullanıcı bazlı **IP sınırlarını** zorlamak için Fail2ban'i (varsayılan olarak etkindir) içerir. Fail2ban, ihlalcileri `iptables` ile engeller ve bu işlem `NET_ADMIN` yetkisi gerektirir. `docker-compose.yml` bunu `cap_add` aracılığıyla zaten sağlar; eğer container'ı bunun yerine `docker run` ile başlatırsanız yetkileri kendiniz eklemelisiniz, aksi takdirde engellemeler sadece günlüğe (log) kaydedilir ancak asla uygulanmaz:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## Ortam Değişkenleri
| Değişken | Açıklama | Varsayılan |
| --- | --- | --- |
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bağlantı sayısı (PostgreSQL havuzu) | — |
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
## Desteklenen Diller
Panel kullanıcı arayüzü 13 dilde mevcuttur:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## Katkıda Bulunma
Katkılara açığız. Lütfen bir sorun (issue) veya çekme isteği (pull request) açmadan önce [Katkıda Bulunma Rehberi](/CONTRIBUTING.md)'ni okuyun.
## Özel Teşekkürler
- [alireza0](https://github.com/alireza0/)
## Teşekkür
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Dahili İran alan adları ve güvenlik/reklam engelleme odaklı geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme kuralları._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._
## Topluluk Araçları
3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Bağlantı noktalarını, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak yönetin._
## Projeyi Destekleyin
**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Zaman İçindeki Yıldız Sayısı
[![Zaman İçindeki Yıldız Sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+1 -1
View File
@@ -1 +1 @@
3.2.7 3.2.8
+218
View File
@@ -0,0 +1,218 @@
package database
import (
"database/sql"
"fmt"
"os"
"strconv"
"strings"
"unicode/utf8"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DumpSQLite writes a portable SQL text dump of the SQLite database at srcPath
// to outPath. The output mirrors the `sqlite3 .dump` format (schema + data +
// indexes wrapped in a transaction), so it can be rebuilt with RestoreSQLite or
// loaded by the sqlite3 CLI. The source database is opened read-only in effect
// and left untouched.
func DumpSQLite(srcPath, outPath string) error {
data, err := DumpSQLiteToBytes(srcPath)
if err != nil {
return err
}
return os.WriteFile(outPath, data, 0o644)
}
// DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite
// but returns it in memory, which the panel uses to stream a migration download.
func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
if _, err := os.Stat(srcPath); err != nil {
return nil, fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
}
gdb, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
return nil, err
}
sqlDB, err := gdb.DB()
if err != nil {
return nil, err
}
defer sqlDB.Close()
var b strings.Builder
b.WriteString("PRAGMA foreign_keys=OFF;\n")
b.WriteString("BEGIN TRANSACTION;\n")
// Tables in creation order, each followed by its data.
type object struct{ name, ddl string }
var tables []object
rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
if err != nil {
return nil, err
}
for rows.Next() {
var o object
if err := rows.Scan(&o.name, &o.ddl); err != nil {
rows.Close()
return nil, err
}
tables = append(tables, o)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
for _, t := range tables {
b.WriteString(t.ddl)
b.WriteString(";\n")
if err := dumpTableData(sqlDB, t.name, &b); err != nil {
return nil, err
}
}
// AUTOINCREMENT bookkeeping, restored verbatim like the sqlite3 CLI does.
if sqliteTableExists(sqlDB, "sqlite_sequence") {
b.WriteString("DELETE FROM sqlite_sequence;\n")
if err := dumpTableData(sqlDB, "sqlite_sequence", &b); err != nil {
return nil, err
}
}
// Indexes, triggers and views after the data is in place.
rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
if err != nil {
return nil, err
}
for rows2.Next() {
var ddl string
if err := rows2.Scan(&ddl); err != nil {
rows2.Close()
return nil, err
}
b.WriteString(ddl)
b.WriteString(";\n")
}
if err := rows2.Err(); err != nil {
rows2.Close()
return nil, err
}
rows2.Close()
b.WriteString("COMMIT;\n")
return []byte(b.String()), nil
}
// RestoreSQLite rebuilds a SQLite database at dstPath from a SQL text dump
// produced by DumpSQLite (or `sqlite3 .dump`). dstPath must not already exist so
// an existing database is never clobbered silently.
func RestoreSQLite(dumpPath, dstPath string) error {
script, err := os.ReadFile(dumpPath)
if err != nil {
return err
}
if _, err := os.Stat(dstPath); err == nil {
return fmt.Errorf("destination already exists: %s", dstPath)
}
gdb, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
return err
}
sqlDB, err := gdb.DB()
if err != nil {
return err
}
// mattn/go-sqlite3 executes every statement in a multi-statement string.
if _, err := sqlDB.Exec(string(script)); err != nil {
sqlDB.Close()
os.Remove(dstPath)
return fmt.Errorf("restore failed: %w", err)
}
return sqlDB.Close()
}
// dumpTableData appends one INSERT statement per row of table to b.
func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
rows, err := db.Query(`SELECT * FROM "` + table + `"`)
if err != nil {
return err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return err
}
n := len(cols)
prefix := `INSERT INTO "` + table + `" VALUES(`
for rows.Next() {
vals := make([]any, n)
ptrs := make([]any, n)
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return err
}
b.WriteString(prefix)
for i, v := range vals {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(sqliteLiteral(v))
}
b.WriteString(");\n")
}
return rows.Err()
}
// sqliteLiteral renders a scanned column value as a SQLite SQL literal.
func sqliteLiteral(v any) string {
switch x := v.(type) {
case nil:
return "NULL"
case int64:
return strconv.FormatInt(x, 10)
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
if x {
return "1"
}
return "0"
case string:
return quoteSQLiteText(x)
case []byte:
if utf8.Valid(x) {
return quoteSQLiteText(string(x))
}
var sb strings.Builder
sb.WriteString("X'")
for _, c := range x {
fmt.Fprintf(&sb, "%02x", c)
}
sb.WriteByte('\'')
return sb.String()
default:
return quoteSQLiteText(fmt.Sprintf("%v", x))
}
}
func quoteSQLiteText(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func sqliteTableExists(db *sql.DB, name string) bool {
var found string
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
return err == nil
}
+137
View File
@@ -0,0 +1,137 @@
package database
import (
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// TestCopyAllModelsIntoSQLite exercises the same AutoMigrate + copyTable
// machinery that ExportPostgresToSQLite relies on, but with a SQLite source so
// it needs no external database. The Postgres source path uses identical gorm
// reads (see MigrateData), so this validates the destination-side copy.
func TestCopyAllModelsIntoSQLite(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "src.db")
dstPath := filepath.Join(dir, "dst.db")
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open src: %v", err)
}
defer closeGorm(src)
for _, m := range migrationModels() {
if err := src.AutoMigrate(m); err != nil {
t.Fatalf("automigrate src %T: %v", m, err)
}
}
// Seed a few rows across parent/child tables and a composite-PK table.
if err := src.Create(&model.User{Username: "admin", Password: "x"}).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
if err := src.Create(&model.Inbound{UserId: 1, Remark: "in", Port: 443, Protocol: "vless", Tag: "inbound-443"}).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := src.Create(&xray.ClientTraffic{InboundId: 1, Email: "a@b.c", Enable: true, Up: 10, Down: 20}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open dst: %v", err)
}
defer closeGorm(dst)
if err := copyAllModels(src, dst); err != nil {
t.Fatalf("copyAllModels: %v", err)
}
for _, tc := range []struct {
model any
want int64
}{
{&model.User{}, 1},
{&model.Inbound{}, 1},
{&xray.ClientTraffic{}, 1},
} {
var got int64
if err := dst.Model(tc.model).Count(&got).Error; err != nil {
t.Fatalf("count %T: %v", tc.model, err)
}
if got != tc.want {
t.Errorf("%T: got %d rows, want %d", tc.model, got, tc.want)
}
}
// Spot-check a copied value survived the round-trip.
var ct xray.ClientTraffic
if err := dst.Where("email = ?", "a@b.c").First(&ct).Error; err != nil {
t.Fatalf("read back traffic: %v", err)
}
if ct.Up != 10 || ct.Down != 20 || !ct.Enable {
t.Errorf("traffic mismatch: %+v", ct)
}
}
// TestDumpAndRestoreSQLiteRoundTrip dumps a seeded SQLite db to .dump text and
// rebuilds it, asserting the row survives.
func TestDumpAndRestoreSQLiteRoundTrip(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "src.db")
dumpPath := filepath.Join(dir, "out.dump")
dstPath := filepath.Join(dir, "rebuilt.db")
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open src: %v", err)
}
if err := src.AutoMigrate(&model.Setting{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := src.Create(&model.Setting{Key: "secret", Value: "o'brien \"quote\""}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if sqlDB, _ := src.DB(); sqlDB != nil {
sqlDB.Close()
}
if err := DumpSQLite(srcPath, dumpPath); err != nil {
t.Fatalf("DumpSQLite: %v", err)
}
if fi, err := os.Stat(dumpPath); err != nil || fi.Size() == 0 {
t.Fatalf("dump missing/empty: %v", err)
}
if err := RestoreSQLite(dumpPath, dstPath); err != nil {
t.Fatalf("RestoreSQLite: %v", err)
}
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open dst: %v", err)
}
defer closeGorm(dst)
var s model.Setting
if err := dst.Where("key = ?", "secret").First(&s).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if s.Value != "o'brien \"quote\"" {
t.Errorf("value mismatch after round-trip: %q", s.Value)
}
}
// closeGorm closes the underlying *sql.DB so Windows can delete the temp file.
func closeGorm(db *gorm.DB) {
if db == nil {
return
}
if s, err := db.DB(); err == nil {
s.Close()
}
}
+93
View File
@@ -86,6 +86,23 @@ func MigrateData(srcPath, dstDSN string) error {
} }
} }
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
// client_traffics rows whose inbound was deleted. Drop it here too so copying
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
return fmt.Errorf("drop legacy foreign key: %w", err)
}
// Empty the destination tables so the migration is idempotent: a fresh
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
// panel start, and a partially-failed earlier run leaves rows behind. Either
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
// our tables (only) before copying.
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
return fmt.Errorf("clear destination tables: %w", err)
}
totalRows := 0 totalRows := 0
for _, m := range migrationModels() { for _, m := range migrationModels() {
n, err := copyTable(src, dst, m) n, err := copyTable(src, dst, m)
@@ -105,6 +122,62 @@ func MigrateData(srcPath, dstDSN string) error {
return nil return nil
} }
// ExportPostgresToSQLite copies every row from the PostgreSQL database described
// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
// dstPath is created/overwritten; the PostgreSQL source is left untouched.
func ExportPostgresToSQLite(srcDSN, dstPath string) error {
if srcDSN == "" {
return errors.New("source DSN is required")
}
if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
return err
}
// Start from an empty file so AutoMigrate creates the canonical schema.
if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
return err
}
src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open postgres source: %w", err)
}
srcSQL, err := src.DB()
if err != nil {
return err
}
defer srcSQL.Close()
// No WAL: keep all data in the main file so it is complete once closed.
dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open sqlite destination: %w", err)
}
dstSQL, err := dst.DB()
if err != nil {
return err
}
defer dstSQL.Close()
return copyAllModels(src, dst)
}
// copyAllModels (re)creates the schema on dst and copies every migrated table
// from src to dst in FK-safe order. src/dst may be any gorm backend.
func copyAllModels(src, dst *gorm.DB) error {
for _, m := range migrationModels() {
if err := dst.AutoMigrate(m); err != nil {
return fmt.Errorf("AutoMigrate %T: %w", m, err)
}
}
for _, m := range migrationModels() {
if _, err := copyTable(src, dst, m); err != nil {
return fmt.Errorf("copy %T: %w", m, err)
}
}
return nil
}
func copyTable(src, dst *gorm.DB, mdl any) (int, error) { func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
const batchSize = 500 const batchSize = 500
@@ -157,6 +230,26 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
return total, nil return total, nil
} }
// truncatePostgresTables empties every migrated table on dst in a single
// statement, resetting identity sequences. CASCADE covers the inbound/client
// foreign keys regardless of insertion order. Only the panel's own tables are
// touched, never the rest of the schema.
func truncatePostgresTables(dst *gorm.DB, models []any) error {
tables := make([]string, 0, len(models))
for _, m := range models {
stmt := &gorm.Statement{DB: dst}
if err := stmt.Parse(m); err != nil {
return err
}
tables = append(tables, `"`+stmt.Schema.Table+`"`)
}
if len(tables) == 0 {
return nil
}
log.Println("Clearing destination tables...")
return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
}
// resetPostgresSequences advances each migrated table's id sequence past MAX(id), // resetPostgresSequences advances each migrated table's id sequence past MAX(id),
// otherwise the next INSERT-without-id would clash with copied rows. // otherwise the next INSERT-without-id would clash with copied rows.
func resetPostgresSequences(dst *gorm.DB) error { func resetPostgresSequences(dst *gorm.DB) error {
+72 -28
View File
@@ -41,13 +41,13 @@ type User struct {
// Inbound represents an Xray inbound configuration with traffic statistics and settings. // Inbound represents an Xray inbound configuration with traffic statistics and settings.
type Inbound struct { type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` // Unique identifier
UserId int `json:"-"` // Associated user ID UserId int `json:"-"` // Associated user ID
Up int64 `json:"up" form:"up"` // Upload traffic in bytes Up int64 `json:"up" form:"up"` // Upload traffic in bytes
Down int64 `json:"down" form:"down"` // Download traffic in bytes Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
Remark string `json:"remark" form:"remark"` // Human-readable remark Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
@@ -55,14 +55,22 @@ type Inbound struct {
// Xray configuration fields // Xray configuration fields
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"` Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"` Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
Settings string `json:"settings" form:"settings"` Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
// OriginNodeGuid is the panelGuid of the node that physically hosts this
// inbound, propagated up across hops (#4983). Empty for an inbound that
// lives on this panel's own xray; set to the originating node's GUID when
// the inbound was synced from a node (kept as-is across further hops). Lets
// the master attribute a deeply nested inbound to the real node instead of
// the intermediate one it was fetched through.
OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
// FallbackParent is populated by the API layer when this inbound is // FallbackParent is populated by the API layer when this inbound is
// attached as a fallback child of a VLESS/Trojan TCP-TLS master. // attached as a fallback child of a VLESS/Trojan TCP-TLS master.
// The frontend uses it to rewrite client-share links so they advertise // The frontend uses it to rewrite client-share links so they advertise
@@ -370,39 +378,75 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime // endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below. // status fields below.
type Node struct { type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"` Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
Address string `json:"address" form:"address" validate:"required"` Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
BasePath string `json:"basePath" form:"basePath"` BasePath string `json:"basePath" form:"basePath" example:"/"`
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"` ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"` Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"` TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"` PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
// Guid is the remote panel's stable self-identifier (its panelGuid),
// learned from each heartbeat. It is the globally stable node identity used
// to attribute online clients/inbounds to the physical node across a chain
// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
// Observed-state only — never user-edited.
Guid string `json:"guid" gorm:"column:guid;index"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when // Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is // the row is otherwise unchanged so the UI's "last seen" tooltip is
// truthful without us having to read LastHeartbeat separately. // truthful without us having to read LastHeartbeat separately.
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"` LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion"` XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"` PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct"` CpuPct float64 `json:"cpuPct" example:"23.5"`
MemPct float64 `json:"memPct"` MemPct float64 `json:"memPct" example:"45.1"`
UptimeSecs uint64 `json:"uptimeSecs"` UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
LastError string `json:"lastError"` LastError string `json:"lastError"`
InboundCount int `json:"inboundCount" gorm:"-"` ConfigDirty bool `json:"configDirty" gorm:"default:false"`
ClientCount int `json:"clientCount" gorm:"-"` ConfigDirtyAt int64 `json:"configDirtyAt"`
OnlineCount int `json:"onlineCount" gorm:"-"`
DepletedCount int `json:"depletedCount" gorm:"-"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` ClientCount int `json:"clientCount" gorm:"-" example:"27"`
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
// ParentGuid + Transitive are set only when a node is surfaced as part of a
// node tree (#4983): direct nodes carry the master panel's own GUID, a
// transitive sub-node carries its parent node's GUID. Transitive nodes are
// read-only projections (Id == 0, not persisted) — never edited or deployed.
ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
Transitive bool `json:"transitive,omitempty" gorm:"-"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
}
// NodeSummary is the read-only identity of a node as published one hop up: the
// view a panel exposes about the nodes it directly manages, so a master can
// surface transitive sub-nodes in a chained topology (#4983). Counts are
// computed by the consuming master from its own per-GUID data, never trusted
// from the child, so this carries identity/health only.
type NodeSummary struct {
Guid string `json:"guid"`
ParentGuid string `json:"parentGuid"`
Name string `json:"name"`
Address string `json:"address"`
Scheme string `json:"scheme"`
Port int `json:"port"`
Status string `json:"status"`
LastHeartbeat int64 `json:"lastHeartbeat"`
LatencyMs int `json:"latencyMs"`
PanelVersion string `json:"panelVersion"`
XrayVersion string `json:"xrayVersion"`
} }
type CustomGeoResource struct { type CustomGeoResource struct {
+4 -34
View File
@@ -17,7 +17,7 @@
"axios": "^1.17.0", "axios": "^1.17.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.0", "i18next": "^26.3.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2", "qs": "^6.15.2",
@@ -1934,9 +1934,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1954,9 +1951,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1974,9 +1968,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1994,9 +1985,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2014,9 +2002,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2034,9 +2019,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5087,9 +5069,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.0", "version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -5615,9 +5597,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5639,9 +5618,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5663,9 +5639,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5687,9 +5660,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+2 -1
View File
@@ -16,6 +16,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs", "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen" "gen:zod": "cd .. && go run ./tools/openapigen"
}, },
@@ -29,7 +30,7 @@
"axios": "^1.17.0", "axios": "^1.17.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.0", "i18next": "^26.3.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2", "qs": "^6.15.2",
+2015 -120
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -4,6 +4,8 @@ import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts'; import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
import { SCHEMAS } from '../src/generated/schemas.ts';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json'); const outPath = join(__dirname, '..', 'public', 'openapi.json');
@@ -128,7 +130,22 @@ function buildOperation(ep, tag) {
} }
const responses = {}; const responses = {};
const successExample = tryParseJson(ep.response); let successExample = tryParseJson(ep.response);
let objSchema = {};
if (ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
if (SCHEMAS[ep.responseSchema] === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
}
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
if (successExample === undefined) {
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
}
responses['200'] = { responses['200'] = {
description: 'Successful response', description: 'Successful response',
content: { content: {
@@ -138,7 +155,7 @@ function buildOperation(ep, tag) {
properties: { properties: {
success: { type: 'boolean' }, success: { type: 'boolean' },
msg: { type: 'string' }, msg: { type: 'string' },
obj: {}, obj: objSchema,
}, },
}, },
...(successExample !== undefined ? { example: successExample } : {}), ...(successExample !== undefined ? { example: successExample } : {}),
@@ -192,13 +209,14 @@ function buildSpec() {
title: '3X-UI Panel API', title: '3X-UI Panel API',
version: PANEL_VERSION, version: PANEL_VERSION,
description: description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.', 'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
}, },
servers: [ servers: [
{ url: '/', description: 'Current panel (basePath aware)' }, { url: '/', description: 'Current panel (basePath aware)' },
], ],
components: { components: {
securitySchemes: SECURITY_SCHEMES, securitySchemes: SECURITY_SCHEMES,
schemas: SCHEMAS,
}, },
security: [{ bearerAuth: [] }, { cookieAuth: [] }], security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags, tags,
+2 -2
View File
@@ -8,7 +8,7 @@ import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
async function fetchAllSetting(): Promise<AllSettingInput | null> { async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
const validated = parseMsg(msg, AllSettingSchema, 'setting/all'); const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
return validated.obj; return validated.obj;
@@ -47,7 +47,7 @@ export function useAllSettings() {
if (!body.success) { if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues); console.warn('[zod] setting/update body failed validation', body.error.issues);
} }
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next); return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
}, },
onSuccess: (msg) => { onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() }); if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+1 -1
View File
@@ -21,7 +21,7 @@ export const keys = {
list: (params: unknown) => ['clients', 'list', params] as const, list: (params: unknown) => ['clients', 'list', params] as const,
all: () => ['clients', 'all'] as const, all: () => ['clients', 'all'] as const,
onlines: () => ['clients', 'onlines'] as const, onlines: () => ['clients', 'onlines'] as const,
onlinesByNode: () => ['clients', 'onlinesByNode'] as const, onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
activeInbounds: () => ['clients', 'activeInbounds'] as const, activeInbounds: () => ['clients', 'activeInbounds'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const, lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const, groups: () => ['clients', 'groups'] as const,
+396
View File
@@ -0,0 +1,396 @@
// Code generated by tools/openapigen. DO NOT EDIT.
export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
"webKeyFile": "",
"webListen": "",
"webPort": 1
},
"AllSettingView": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
"webKeyFile": "",
"webListen": "",
"webPort": 1
},
"ApiToken": {
"createdAt": 0,
"enabled": false,
"id": 0,
"name": "",
"token": ""
},
"ApiTokenView": {
"createdAt": 1736000000,
"enabled": true,
"id": 2,
"name": "central-panel-a",
"token": "new-token-string"
},
"Client": {
"auth": "",
"comment": "",
"created_at": 0,
"email": "",
"enable": false,
"expiryTime": 0,
"flow": "",
"group": "",
"id": "",
"limitIp": 0,
"password": "",
"reset": 0,
"reverse": null,
"security": "",
"subId": "",
"tgId": 0,
"totalGB": 0,
"updated_at": 0
},
"ClientInbound": {
"clientId": 0,
"createdAt": 0,
"flowOverride": "",
"inboundId": 0
},
"ClientRecord": {
"auth": "",
"comment": "",
"createdAt": 0,
"email": "",
"enable": false,
"expiryTime": 0,
"flow": "",
"group": "",
"id": 0,
"limitIp": 0,
"password": "",
"reset": 0,
"reverse": null,
"security": "",
"subId": "",
"tgId": 0,
"totalGB": 0,
"updatedAt": 0,
"uuid": ""
},
"ClientReverse": {
"tag": ""
},
"ClientTraffic": {
"down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
},
"CustomGeoResource": {
"alias": "",
"createdAt": 0,
"id": 0,
"lastModified": "",
"lastUpdatedAt": 0,
"localPath": "",
"type": "",
"updatedAt": 0,
"url": ""
},
"FallbackParentInfo": {
"masterId": 0,
"path": ""
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
},
"Inbound": {
"clientStats": [
{
"down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
}
],
"down": 0,
"enable": true,
"expiryTime": 0,
"fallbackParent": null,
"id": 1,
"lastTrafficResetTime": 0,
"listen": "",
"nodeId": null,
"originNodeGuid": "",
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"settings": null,
"sniffing": null,
"streamSettings": null,
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"up": 0
},
"InboundClientIps": {
"clientEmail": "",
"id": 0,
"ips": null
},
"InboundFallback": {
"alpn": "",
"childId": 0,
"dest": "",
"id": 0,
"masterId": 0,
"name": "",
"path": "",
"sortOrder": 0,
"xver": 0
},
"InboundOption": {
"id": 1,
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true
},
"Msg": {
"msg": "",
"obj": null,
"success": false
},
"Node": {
"address": "node1.example.com",
"allowPrivateAddress": false,
"apiToken": "abcdef0123456789",
"basePath": "/",
"clientCount": 27,
"configDirty": false,
"configDirtyAt": 0,
"cpuPct": 23.5,
"createdAt": 1700000000,
"depletedCount": 1,
"enable": true,
"guid": "",
"id": 1,
"inboundCount": 5,
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
"memPct": 45.1,
"name": "de-fra-1",
"onlineCount": 3,
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
"port": 2053,
"remark": "",
"scheme": "https",
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"updatedAt": 1700000000,
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
},
"OutboundTraffics": {
"down": 0,
"id": 0,
"tag": "",
"total": 0,
"up": 0
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
"latencyMs": 42,
"memPct": 45.2,
"panelVersion": "v3.x.x",
"status": "online",
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
},
"Setting": {
"id": 0,
"key": "",
"value": ""
},
"User": {
"id": 0,
"password": "",
"username": ""
}
};
File diff suppressed because it is too large Load Diff
+45 -4
View File
@@ -1,5 +1,9 @@
// Code generated by tools/openapigen. DO NOT EDIT. // Code generated by tools/openapigen. DO NOT EDIT.
export type LoginStatus = number;
export type ProcessState = string;
export type Protocol = string; export type Protocol = string;
export type SubLinkProvider = unknown;
export type transportBits = number;
export interface AllSetting { export interface AllSetting {
datepicker: string; datepicker: string;
@@ -34,7 +38,9 @@ export interface AllSetting {
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subDomain: string; subDomain: string;
subEmailInRemark: boolean; subEmailInRemark: boolean;
@@ -42,9 +48,8 @@ export interface AllSetting {
subEnableRouting: boolean; subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFragment: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonNoises: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
@@ -121,7 +126,9 @@ export interface AllSettingView {
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subDomain: string; subDomain: string;
subEmailInRemark: boolean; subEmailInRemark: boolean;
@@ -129,9 +136,8 @@ export interface AllSettingView {
subEnableRouting: boolean; subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFragment: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonNoises: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
@@ -177,6 +183,14 @@ export interface ApiToken {
token: string; token: string;
} }
export interface ApiTokenView {
createdAt: number;
enabled: boolean;
id: number;
name: string;
token?: string;
}
export interface Client { export interface Client {
auth?: string; auth?: string;
comment: string; comment: string;
@@ -278,6 +292,7 @@ export interface Inbound {
lastTrafficResetTime: number; lastTrafficResetTime: number;
listen: string; listen: string;
nodeId?: number | null; nodeId?: number | null;
originNodeGuid?: string;
port: number; port: number;
protocol: Protocol; protocol: Protocol;
remark: string; remark: string;
@@ -308,6 +323,16 @@ export interface InboundFallback {
xver: number; xver: number;
} }
export interface InboundOption {
id: number;
port: number;
protocol: string;
remark: string;
ssMethod: string;
tag: string;
tlsFlowCapable: boolean;
}
export interface Msg { export interface Msg {
msg: string; msg: string;
obj: unknown; obj: unknown;
@@ -320,10 +345,13 @@ export interface Node {
apiToken: string; apiToken: string;
basePath: string; basePath: string;
clientCount: number; clientCount: number;
configDirty: boolean;
configDirtyAt: number;
cpuPct: number; cpuPct: number;
createdAt: number; createdAt: number;
depletedCount: number; depletedCount: number;
enable: boolean; enable: boolean;
guid: string;
id: number; id: number;
inboundCount: number; inboundCount: number;
lastError: string; lastError: string;
@@ -333,12 +361,14 @@ export interface Node {
name: string; name: string;
onlineCount: number; onlineCount: number;
panelVersion: string; panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string; pinnedCertSha256: string;
port: number; port: number;
remark: string; remark: string;
scheme: string; scheme: string;
status: string; status: string;
tlsVerifyMode: string; tlsVerifyMode: string;
transitive?: boolean;
updatedAt: number; updatedAt: number;
uptimeSecs: number; uptimeSecs: number;
xrayVersion: string; xrayVersion: string;
@@ -352,6 +382,17 @@ export interface OutboundTraffics {
up: number; up: number;
} }
export interface ProbeResultUI {
cpuPct: number;
error: string;
latencyMs: number;
memPct: number;
panelVersion: string;
status: string;
uptimeSecs: number;
xrayVersion: string;
}
export interface Setting { export interface Setting {
id: number; id: number;
key: string; key: string;
+56 -4
View File
@@ -1,8 +1,20 @@
// Code generated by tools/openapigen. DO NOT EDIT. // Code generated by tools/openapigen. DO NOT EDIT.
import { z } from 'zod'; import { z } from 'zod';
export const LoginStatusSchema = z.number().int();
export type LoginStatus = z.infer<typeof LoginStatusSchema>;
export const ProcessStateSchema = z.string();
export type ProcessState = z.infer<typeof ProcessStateSchema>;
export const ProtocolSchema = z.string(); export const ProtocolSchema = z.string();
export type Protocol = z.infer<typeof ProtocolSchema>; export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const transportBitsSchema = z.number().int();
export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({ export const AllSettingSchema = z.object({
datepicker: z.string(), datepicker: z.string(),
expireDiff: z.number().int().min(0), expireDiff: z.number().int().min(0),
@@ -36,7 +48,9 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEmailInRemark: z.boolean(), subEmailInRemark: z.boolean(),
@@ -44,9 +58,8 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFragment: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
@@ -124,7 +137,9 @@ export const AllSettingViewSchema = z.object({
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEmailInRemark: z.boolean(), subEmailInRemark: z.boolean(),
@@ -132,9 +147,8 @@ export const AllSettingViewSchema = z.object({
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFragment: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
@@ -182,6 +196,15 @@ export const ApiTokenSchema = z.object({
}); });
export type ApiToken = z.infer<typeof ApiTokenSchema>; export type ApiToken = z.infer<typeof ApiTokenSchema>;
export const ApiTokenViewSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
name: z.string(),
token: z.string().optional(),
});
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
export const ClientSchema = z.object({ export const ClientSchema = z.object({
auth: z.string().optional(), auth: z.string().optional(),
comment: z.string(), comment: z.string(),
@@ -291,6 +314,7 @@ export const InboundSchema = z.object({
lastTrafficResetTime: z.number().int(), lastTrafficResetTime: z.number().int(),
listen: z.string(), listen: z.string(),
nodeId: z.number().int().nullable().optional(), nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535), port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']), protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
remark: z.string(), remark: z.string(),
@@ -324,6 +348,17 @@ export const InboundFallbackSchema = z.object({
}); });
export type InboundFallback = z.infer<typeof InboundFallbackSchema>; export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const InboundOptionSchema = z.object({
id: z.number().int(),
port: z.number().int(),
protocol: z.string(),
remark: z.string(),
ssMethod: z.string(),
tag: z.string(),
tlsFlowCapable: z.boolean(),
});
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export const MsgSchema = z.object({ export const MsgSchema = z.object({
msg: z.string(), msg: z.string(),
obj: z.unknown(), obj: z.unknown(),
@@ -337,10 +372,13 @@ export const NodeSchema = z.object({
apiToken: z.string(), apiToken: z.string(),
basePath: z.string(), basePath: z.string(),
clientCount: z.number().int(), clientCount: z.number().int(),
configDirty: z.boolean(),
configDirtyAt: z.number().int(),
cpuPct: z.number(), cpuPct: z.number(),
createdAt: z.number().int(), createdAt: z.number().int(),
depletedCount: z.number().int(), depletedCount: z.number().int(),
enable: z.boolean(), enable: z.boolean(),
guid: z.string(),
id: z.number().int(), id: z.number().int(),
inboundCount: z.number().int(), inboundCount: z.number().int(),
lastError: z.string(), lastError: z.string(),
@@ -350,12 +388,14 @@ export const NodeSchema = z.object({
name: z.string(), name: z.string(),
onlineCount: z.number().int(), onlineCount: z.number().int(),
panelVersion: z.string(), panelVersion: z.string(),
parentGuid: z.string().optional(),
pinnedCertSha256: z.string(), pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
remark: z.string(), remark: z.string(),
scheme: z.enum(['http', 'https']), scheme: z.enum(['http', 'https']),
status: z.string(), status: z.string(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
transitive: z.boolean().optional(),
updatedAt: z.number().int(), updatedAt: z.number().int(),
uptimeSecs: z.number().int(), uptimeSecs: z.number().int(),
xrayVersion: z.string(), xrayVersion: z.string(),
@@ -371,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({
}); });
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>; export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
export const ProbeResultUISchema = z.object({
cpuPct: z.number(),
error: z.string(),
latencyMs: z.number().int(),
memPct: z.number(),
panelVersion: z.string(),
status: z.string(),
uptimeSecs: z.number().int(),
xrayVersion: z.string(),
});
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
export const SettingSchema = z.object({ export const SettingSchema = z.object({
id: z.number().int(), id: z.number().int(),
key: z.string(), key: z.string(),
+1 -1
View File
@@ -142,7 +142,7 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
} }
async function fetchDefaults(): Promise<Record<string, unknown>> { async function fetchDefaults(): Promise<Record<string, unknown>> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings'); const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj || {}; return validated.obj || {};
+1 -1
View File
@@ -22,7 +22,7 @@ async function loadOnce(): Promise<void> {
} }
pending = (async () => { pending = (async () => {
try { try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings'); const msg = await HttpUtil.post('/panel/api/setting/defaultSettings');
if (msg?.success) { if (msg?.success) {
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings'); const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
cachedValue = validated.obj?.datepicker || 'gregorian'; cachedValue = validated.obj?.datepicker || 'gregorian';
+7 -7
View File
@@ -72,7 +72,7 @@ export interface UseXraySettingResult {
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>; type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
async function fetchXrayConfig(): Promise<XrayConfigPayload> { async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string'); if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
let parsed: unknown; let parsed: unknown;
@@ -91,7 +91,7 @@ async function fetchXrayConfig(): Promise<XrayConfigPayload> {
} }
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> { async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }); const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic'); const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : []; return Array.isArray(validated.obj) ? validated.obj : [];
@@ -200,7 +200,7 @@ export function useXraySetting(): UseXraySettingResult {
mutationFn: async () => { mutationFn: async () => {
const sentXraySetting = xraySettingRef.current; const sentXraySetting = xraySettingRef.current;
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL; const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
const msg = await HttpUtil.post('/panel/xray/update', { const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting, xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl, outboundTestUrl: sentTestUrl,
}); });
@@ -217,7 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
const resetTrafficMut = useMutation({ const resetTrafficMut = useMutation({
mutationFn: (tag: string) => mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }), HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => { onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() }); if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
}, },
@@ -228,7 +228,7 @@ export function useXraySetting(): UseXraySettingResult {
const msg = await HttpUtil.post('/panel/api/server/restartXrayService'); const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
if (!msg?.success) return msg; if (!msg?.success) return msg;
await PromiseUtil.sleep(500); await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult'); const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
const validated = parseMsg(r, z.string(), 'xray/getXrayResult'); const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
if (validated?.success) setRestartResult(validated.obj || ''); if (validated?.success) setRestartResult(validated.obj || '');
return msg; return msg;
@@ -237,7 +237,7 @@ export function useXraySetting(): UseXraySettingResult {
const resetDefaultMut = useMutation({ const resetDefaultMut = useMutation({
mutationFn: async (): Promise<Msg<XraySettingsValue>> => { mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig'); const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig'); return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
}, },
onSuccess: (msg) => { onSuccess: (msg) => {
@@ -264,7 +264,7 @@ export function useXraySetting(): UseXraySettingResult {
[index]: { testing: true, result: null, mode: effMode }, [index]: { testing: true, result: null, mode: effMode },
})); }));
try { try {
const raw = await HttpUtil.post('/panel/xray/testOutbound', { const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
outbound: JSON.stringify(outbound), outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode, mode: effMode,
@@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils'; import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives'; import { OutboundProtocols } from '@/schemas/primitives';
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
// paths under `name`; the parent modal owns the Form instance.
//
// Naming convention inside Form.List: AntD prefixes Form.Item `name`
// with the Form.List's own `name`. So Form.Items inside the render
// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
// Form.Lists also use relative names. Using absolute paths here would
// double up the prefix and silently route reads/writes to the wrong
// storage path.
export interface FinalMaskFormProps { export interface FinalMaskFormProps {
name: NamePath; name: NamePath;
network: string; network: string;
protocol: string; protocol: string;
form: FormInstance; form: FormInstance;
// When true, all sections (TCP / UDP / QUIC) are shown regardless of
// network/protocol. Used by the global sub-JSON finalmask editor where
// the masks apply to every stream rather than one specific transport.
showAll?: boolean;
} }
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
@@ -32,10 +26,10 @@ function asPath(name: NamePath): (string | number)[] {
function defaultTcpMaskSettings(type: string): Record<string, unknown> { function defaultTcpMaskSettings(type: string): Record<string, unknown> {
switch (type) { switch (type) {
case 'fragment': case 'fragment':
return { packets: '1-3', length: '', delay: '', maxSplit: '' }; return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
case 'sudoku': case 'sudoku':
return { return {
password: '', ascii: '', customTable: '', customTables: '', password: '', ascii: '', customTable: '', customTables: [''],
paddingMin: 0, paddingMax: 0, paddingMin: 0, paddingMax: 0,
}; };
case 'header-custom': case 'header-custom':
@@ -99,12 +93,12 @@ function defaultUdpHop(): Record<string, unknown> {
return { ports: '20000-50000', interval: '5-10' }; return { ports: '20000-50000', interval: '5-10' };
} }
export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) { export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
const base = asPath(name); const base = asPath(name);
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
const showTcp = TCP_NETWORKS.includes(network); const showTcp = showAll || TCP_NETWORKS.includes(network);
const showUdp = isHysteria || network === 'kcp'; const showUdp = showAll || isHysteria || network === 'kcp';
const showQuic = isHysteria || network === 'xhttp'; const showQuic = showAll || isHysteria || network === 'xhttp';
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
const hasQuicParams = quicParams != null; const hasQuicParams = quicParams != null;
@@ -216,8 +210,12 @@ function TcpMaskItem({
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label="Length" name={[fieldName, 'settings', 'length']}> <Form.Item
<Input /> label="Length"
name={[fieldName, 'settings', 'length']}
rules={[{ validator: validateFragmentLength }]}
>
<Input placeholder="e.g. 100-200" />
</Form.Item> </Form.Item>
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}> <Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
<Input /> <Input />
@@ -234,7 +232,9 @@ function TcpMaskItem({
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item> <Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item> <Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item> <Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item> <Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}> <Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
@@ -263,6 +263,18 @@ function TcpMaskItem({
// Walks a deep object path safely. Used inside shouldUpdate which gets // Walks a deep object path safely. Used inside shouldUpdate which gets
// the whole form values blob; we need to compare a deep field across // the whole form values blob; we need to compare a deep field across
// prev/curr without crashing on missing intermediates. // prev/curr without crashing on missing intermediates.
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
if (str.length === 0) {
return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
}
const min = Number(str.split('-')[0]);
if (!Number.isFinite(min) || min <= 0) {
return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
}
return Promise.resolve();
}
function getDeep(obj: unknown, path: (string | number)[]): unknown { function getDeep(obj: unknown, path: (string | number)[]): unknown {
let cur: unknown = obj; let cur: unknown = obj;
for (const key of path) { for (const key of path) {
@@ -10,6 +10,7 @@ import {
import type { StreamSettings } from '@/schemas/api/inbound'; import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives'; import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod'; import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
// Plain-data adapter between the panel's stored inbound row shape and // Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
if (Array.isArray(settingsPruned.clients)) { if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients); settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
} }
const streamPruned = values.streamSettings let streamPruned = values.streamSettings
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>) ? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
: undefined; : undefined;
if (streamPruned) stripTlsCertUseFile(streamPruned); if (streamPruned) {
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
stripTlsCertUseFile(streamPruned);
}
dropLegacyOptionalEmpties(settingsPruned, streamPruned); dropLegacyOptionalEmpties(settingsPruned, streamPruned);
const payload: WireInboundPayload = { const payload: WireInboundPayload = {
up: values.up, up: values.up,
+3
View File
@@ -137,6 +137,7 @@ function applyExternalProxyTLSObj(
if (alpn.length > 0) obj.alpn = alpn; if (alpn.length > 0) obj.alpn = alpn;
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256); const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
if (pins.length > 0) obj.pcs = pins; if (pins.length > 0) obj.pcs = pins;
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
} }
export interface GenVmessLinkInput { export interface GenVmessLinkInput {
@@ -232,6 +233,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName; if (tlsSettings.serverName.length > 0) obj.sni = tlsSettings.serverName;
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint; if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(','); if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) { if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(','); obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
} }
@@ -279,6 +281,7 @@ function applyExternalProxyTLSParams(
if (alpn.length > 0) params.set('alpn', alpn); if (alpn.length > 0) params.set('alpn', alpn);
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256); const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
if (pins.length > 0) params.set('pcs', pins); if (pins.length > 0) params.set('pcs', pins);
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
} }
export interface GenVlessLinkInput { export interface GenVlessLinkInput {
@@ -1,4 +1,5 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { Wireguard } from '@/utils'; import { Wireguard } from '@/utils';
import type { import type {
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
userLevel: s.userLevel || undefined, userLevel: s.userLevel || undefined,
proxyProtocol: s.proxyProtocol || undefined, proxyProtocol: s.proxyProtocol || undefined,
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined, fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
noises: s.noises.length > 0 ? s.noises : undefined, noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
finalRules: s.finalRules.length > 0 finalRules: s.finalRules && s.finalRules.length > 0
? s.finalRules.map((r) => ({ ? s.finalRules.map((r) => ({
action: r.action, action: r.action,
network: r.network || undefined, network: r.network || undefined,
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
if (!xmuxEnabled) delete cleaned.xmux; if (!xmuxEnabled) delete cleaned.xmux;
next.xhttpSettings = dropEmptyStrings(cleaned); next.xhttpSettings = dropEmptyStrings(cleaned);
} }
return next; return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
} }
function muxAllowed(values: OutboundFormValues): boolean { function muxAllowed(values: OutboundFormValues): boolean {
@@ -203,6 +203,8 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
tls.fingerprint = params.get('fp') ?? ''; tls.fingerprint = params.get('fp') ?? '';
const alpn = params.get('alpn'); const alpn = params.get('alpn');
if (alpn) tls.alpn = alpn.split(','); if (alpn) tls.alpn = alpn.split(',');
tls.echConfigList = params.get('ech') ?? '';
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
} else if (stream.security === 'reality') { } else if (stream.security === 'reality') {
const reality = stream.realitySettings as Raw; const reality = stream.realitySettings as Raw;
reality.serverName = params.get('sni') ?? ''; reality.serverName = params.get('sni') ?? '';
@@ -0,0 +1,225 @@
// Shapes the streamSettings subtree that 3x-ui persists to match what
// xray-core actually consumes. The panel's Zod defaults mirror the full
// SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
// (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
// them anyway bloats configs and — for sockopt — can inject doc-example
// values like tcpWindowClamp: 600 that throttle throughput.
export type StreamWireSide = 'inbound' | 'outbound';
const PACKET_UP_FIELDS = [
'scMaxEachPostBytes',
'scMinPostsIntervalMs',
'scMaxBufferedPosts',
] as const;
const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
const PLACEMENT_STRING_FIELDS = [
'sessionPlacement',
'sessionKey',
'seqPlacement',
'seqKey',
'uplinkDataPlacement',
'uplinkDataKey',
'uplinkHTTPMethod',
'xPaddingKey',
'xPaddingHeader',
'xPaddingPlacement',
'xPaddingMethod',
] as const;
function isRecord(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === 'object' && !Array.isArray(v);
}
function nonEmptyString(v: unknown): v is string {
return typeof v === 'string' && v.trim() !== '';
}
function hasMeaningfulHeaders(headers: unknown): boolean {
return isRecord(headers) && Object.keys(headers).length > 0;
}
/** Validates REALITY inbound `target` / `dest` (must include a port). */
export function validateRealityTarget(target: string): string | undefined {
const trimmed = target.trim();
if (!trimmed) {
return 'pages.inbounds.form.realityTargetRequired';
}
// Unix socket destinations (rare, but valid in xray-core).
if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
return undefined;
}
// Pure port → localhost:port in xray-core.
if (/^\d+$/.test(trimmed)) {
const port = Number(trimmed);
if (port >= 1 && port <= 65535) return undefined;
return 'pages.inbounds.form.realityTargetInvalidPort';
}
const lastColon = trimmed.lastIndexOf(':');
if (lastColon <= 0 || lastColon === trimmed.length - 1) {
return 'pages.inbounds.form.realityTargetNeedsPort';
}
const portPart = trimmed.slice(lastColon + 1);
if (!/^\d+$/.test(portPart)) {
return 'pages.inbounds.form.realityTargetInvalidPort';
}
const port = Number(portPart);
if (port < 1 || port > 65535) {
return 'pages.inbounds.form.realityTargetInvalidPort';
}
return undefined;
}
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) {
const v = obj[key];
if (v === '' || v == null) delete obj[key];
}
}
function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) {
if (obj[key] === false) delete obj[key];
}
}
function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) {
if (obj[key] === 0) delete obj[key];
}
}
export function normalizeXhttpForWire(
raw: Record<string, unknown>,
side: StreamWireSide,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
delete out.enableXmux;
if (side === 'inbound') {
delete out.xmux;
delete out.scMinPostsIntervalMs;
delete out.uplinkChunkSize;
}
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
if (!hasMeaningfulHeaders(out.headers)) {
delete out.headers;
}
if (out.xPaddingObfsMode !== true) {
delete out.xPaddingObfsMode;
dropEmptyStrings(out, [
'xPaddingKey',
'xPaddingHeader',
'xPaddingPlacement',
'xPaddingMethod',
]);
}
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
if (out.noSSEHeader !== true) delete out.noSSEHeader;
if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
if (mode === 'stream-one') {
for (const key of PACKET_UP_FIELDS) delete out[key];
for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
} else if (mode === 'stream-up') {
for (const key of PACKET_UP_FIELDS) delete out[key];
if (side === 'outbound') {
delete out.scStreamUpServerSecs;
}
} else if (mode === 'packet-up') {
delete out.scStreamUpServerSecs;
}
return out;
}
export function normalizeSockoptForWire(
raw: Record<string, unknown>,
): Record<string, unknown> | undefined {
const out: Record<string, unknown> = { ...raw };
dropZeroNumbers(out, [
'tcpWindowClamp',
'tcpMaxSeg',
'tcpUserTimeout',
'tcpKeepAliveIdle',
'tcpKeepAliveInterval',
'mark',
]);
dropFalseFlags(out, [
'acceptProxyProtocol',
'tcpFastOpen',
'tcpMptcp',
'penetrate',
'V6Only',
]);
if (out.tproxy === 'off') delete out.tproxy;
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
if (nonEmptyString(out.interface) === false) delete out.interface;
if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
delete out.trustedXForwardedFor;
}
if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
delete out.customSockopt;
}
const he = out.happyEyeballs;
if (isRecord(he)) {
const heOut: Record<string, unknown> = { ...he };
if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
if (heOut.interleave === 1) delete heOut.interleave;
if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
if (Object.keys(heOut).length === 0) {
delete out.happyEyeballs;
} else {
out.happyEyeballs = heOut;
}
}
if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
if (Object.keys(out).length === 0) return undefined;
return out;
}
export function normalizeStreamSettingsForWire(
stream: Record<string, unknown>,
opts: { side: StreamWireSide },
): Record<string, unknown> {
const out: Record<string, unknown> = { ...stream };
const xhttp = out.xhttpSettings;
if (isRecord(xhttp)) {
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
}
const sockopt = out.sockopt;
if (isRecord(sockopt)) {
const normalized = normalizeSockoptForWire(sockopt);
if (normalized) {
out.sockopt = normalized;
} else {
delete out.sockopt;
}
}
return out;
}
+3
View File
@@ -40,6 +40,7 @@ export type DBInboundInit = Partial<{
sniffing: RawJsonField; sniffing: RawJsonField;
clientStats: ClientStats[]; clientStats: ClientStats[];
nodeId: number | null; nodeId: number | null;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null; fallbackParent: FallbackParentRef | null;
}>; }>;
@@ -83,6 +84,7 @@ export class DBInbound {
sniffing: RawJsonField; sniffing: RawJsonField;
clientStats: ClientStats[]; clientStats: ClientStats[];
nodeId: number | null; nodeId: number | null;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null; fallbackParent: FallbackParentRef | null;
private _clientStatsMap: Map<string, ClientStats> | null = null; private _clientStatsMap: Map<string, ClientStats> | null = null;
@@ -108,6 +110,7 @@ export class DBInbound {
this.sniffing = ""; this.sniffing = "";
this.clientStats = []; this.clientStats = [];
this.nodeId = null; this.nodeId = null;
this.originNodeGuid = "";
this.fallbackParent = null; this.fallbackParent = null;
if (data == null) { if (data == null) {
return; return;
+3 -2
View File
@@ -55,10 +55,11 @@ export class AllSetting {
subURI = ''; subURI = '';
subJsonURI = ''; subJsonURI = '';
subClashURI = ''; subClashURI = '';
subJsonFragment = ''; subClashEnableRouting = false;
subJsonNoises = ''; subClashRules = '';
subJsonMux = ''; subJsonMux = '';
subJsonRules = ''; subJsonRules = '';
subJsonFinalMask = '';
timeLocation = 'Local'; timeLocation = 'Local';
@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
docExpansion="list" docExpansion="list"
deepLinking={false} deepLinking={false}
tryItOutEnabled tryItOutEnabled
persistAuthorization
/> />
</div> </div>
</Layout.Content> </Layout.Content>
+54 -40
View File
@@ -38,6 +38,8 @@ export interface Endpoint {
response?: string; response?: string;
errorResponse?: string; errorResponse?: string;
errorStatus?: number; errorStatus?: number;
responseSchema?: string;
responseSchemaArray?: boolean;
} }
export interface SubscriptionHeader { export interface SubscriptionHeader {
@@ -107,22 +109,22 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/inbounds/list', path: '/panel/api/inbounds/list',
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.', summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
response: responseSchema: 'Inbound',
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}', responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/inbounds/list/slim', path: '/panel/api/inbounds/list/slim',
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).', summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
response: response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}', '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/inbounds/options', path: '/panel/api/inbounds/options',
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). 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.', summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), 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.',
response: responseSchema: 'InboundOption',
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "protocol": "vless",\n "port": 443,\n "tlsFlowCapable": true\n }\n ]\n}', responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
@@ -307,6 +309,11 @@ export const sections: readonly Section[] = [
path: '/panel/api/server/getDb', path: '/panel/api/server/getDb',
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.', summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
}, },
{
method: 'GET',
path: '/panel/api/server/getMigration',
summary: '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.',
},
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewUUID', path: '/panel/api/server/getNewUUID',
@@ -319,6 +326,12 @@ export const sections: readonly Section[] = [
summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.', summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}', response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}',
}, },
{
method: 'GET',
path: '/panel/api/server/descendants',
summary: 'Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.',
response: '{\n "success": true,\n "obj": [\n {\n "guid": "c3d4-...",\n "parentGuid": "a1b2-...",\n "name": "Node3",\n "address": "10.0.0.3",\n "status": "online"\n }\n ]\n}',
},
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewX25519Cert', path: '/panel/api/server/getNewX25519Cert',
@@ -677,15 +690,15 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/clients/onlinesByNode', path: '/panel/api/clients/onlinesByGuid',
summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.', summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
response: '{\n "success": true,\n "obj": {\n "0": ["user1"],\n "3": ["user1", "user2"]\n }\n}', response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/clients/activeInbounds', path: '/panel/api/clients/activeInbounds',
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.', summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
response: '{\n "success": true,\n "obj": {\n "0": ["inbound-443", "inbound-8443"]\n }\n}', response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["in-443-tcp", "in-8443-tcp"]\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
@@ -700,7 +713,7 @@ export const sections: readonly Section[] = [
params: [ params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' }, { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
], ],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}', responseSchema: 'ClientTraffic',
}, },
{ {
method: 'GET', method: 'GET',
@@ -737,7 +750,8 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/nodes/list', path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.', summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false,\n "status": "online",\n "lastHeartbeat": 1700000000,\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 23.5,\n "memPct": 45.1,\n "uptimeSecs": 86400,\n "lastError": "",\n "inboundCount": 5,\n "clientCount": 27,\n "onlineCount": 3,\n "depletedCount": 1,\n "createdAt": 1700000000,\n "updatedAt": 1700000000\n }\n ]\n}', responseSchema: 'Node',
responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
@@ -794,7 +808,7 @@ export const sections: readonly Section[] = [
path: '/panel/api/nodes/test', path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.', summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}', body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}', responseSchema: 'ProbeResultUI',
}, },
{ {
method: 'POST', method: 'POST',
@@ -903,28 +917,28 @@ export const sections: readonly Section[] = [
id: 'settings', id: 'settings',
title: 'Settings', title: 'Settings',
description: description:
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.', 'Panel configuration and user credentials. All endpoints live under /panel/api/setting and require a logged-in session or Bearer token.',
endpoints: [ endpoints: [
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/all', path: '/panel/api/setting/all',
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.', summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}', response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/defaultSettings', path: '/panel/api/setting/defaultSettings',
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.', summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/update', path: '/panel/api/setting/update',
summary: '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.', summary: '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.',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}', body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/updateUser', path: '/panel/api/setting/updateUser',
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.', summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
params: [ params: [
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' }, { name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
@@ -936,12 +950,12 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/restartPanel', path: '/panel/api/setting/restartPanel',
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.', summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/setting/getDefaultJsonConfig', path: '/panel/api/setting/getDefaultJsonConfig',
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.', summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
}, },
], ],
@@ -951,28 +965,28 @@ export const sections: readonly Section[] = [
id: 'api-tokens', id: 'api-tokens',
title: 'API Tokens', title: 'API Tokens',
description: 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 <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.', '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 <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request — the token is a full-admin credential.',
endpoints: [ endpoints: [
{ {
method: 'GET', method: 'GET',
path: '/panel/setting/apiTokens', path: '/panel/api/setting/apiTokens',
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.', summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}', response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/apiTokens/create', path: '/panel/api/setting/apiTokens/create',
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.', summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
params: [ params: [
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' }, { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
], ],
body: '{\n "name": "central-panel-a"\n}', body: '{\n "name": "central-panel-a"\n}',
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}', responseSchema: 'ApiTokenView',
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}', errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/apiTokens/delete/:id', path: '/panel/api/setting/apiTokens/delete/:id',
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.', summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' }, { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
@@ -981,7 +995,7 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/setting/apiTokens/setEnabled/:id', path: '/panel/api/setting/apiTokens/setEnabled/:id',
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.', summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' }, { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
@@ -997,32 +1011,32 @@ export const sections: readonly Section[] = [
id: 'xray-settings', id: 'xray-settings',
title: 'Xray Settings', title: 'Xray Settings',
description: description:
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.', 'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
endpoints: [ endpoints: [
{ {
method: 'POST', method: 'POST',
path: '/panel/xray/', path: '/panel/api/xray/',
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.', summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}', response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"in-443-tcp\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/xray/getDefaultJsonConfig', path: '/panel/api/xray/getDefaultJsonConfig',
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).', summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/api/setting/getDefaultJsonConfig).',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/xray/getOutboundsTraffic', path: '/panel/api/xray/getOutboundsTraffic',
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.', summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/xray/getXrayResult', path: '/panel/api/xray/getXrayResult',
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.', summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/xray/update', path: '/panel/api/xray/update',
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.', summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
params: [ params: [
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' }, { name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
@@ -1031,7 +1045,7 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/xray/warp/:action', path: '/panel/api/xray/warp/:action',
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.', summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
params: [ params: [
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' }, { name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
@@ -1042,7 +1056,7 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/xray/nord/:action', path: '/panel/api/xray/nord/:action',
summary: 'Manage NordVPN integration. The action parameter selects the operation.', summary: 'Manage NordVPN integration. The action parameter selects the operation.',
params: [ params: [
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' }, { name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
@@ -1053,7 +1067,7 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/xray/resetOutboundsTraffic', path: '/panel/api/xray/resetOutboundsTraffic',
summary: 'Reset traffic counters for a specific outbound by tag.', summary: 'Reset traffic counters for a specific outbound by tag.',
params: [ params: [
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' }, { name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
@@ -1062,7 +1076,7 @@ export const sections: readonly Section[] = [
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/xray/testOutbound', path: '/panel/api/xray/testOutbound',
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.', summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
params: [ params: [
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' }, { name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
@@ -1109,7 +1123,7 @@ export const sections: readonly Section[] = [
{ {
method: 'GET', method: 'GET',
path: '/{clashPath}:subid', path: '/{clashPath}:subid',
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.', summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
params: [ params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }, { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
], ],
@@ -89,6 +89,15 @@ export default function ClientBulkAddModal({
[form.inboundIds, flowCapableIds], [form.inboundIds, flowCapableIds],
); );
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
useEffect(() => { useEffect(() => {
if (!showFlow && form.flow) { if (!showFlow && form.flow) {
@@ -153,7 +162,9 @@ export default function ClientBulkAddModal({
email, email,
subId: form.subId || RandomUtil.randomLowerAndNum(16), subId: form.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(), id: RandomUtil.randomUUID(),
password: RandomUtil.randomLowerAndNum(16), password: ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16), auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (form.flow || '') : '', flow: showFlow ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB), totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
@@ -249,7 +260,7 @@ export default function ClientBulkAddModal({
)} )}
{form.emailMethod < 2 && ( {form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}> <Form.Item label={t('pages.clients.clientCount')}>
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} /> <InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
</Form.Item> </Form.Item>
)} )}
+25 -1
View File
@@ -228,6 +228,21 @@ export default function ClientFormModal({
return ids; return ids;
}, [inbounds]); }, [inbounds]);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
function regeneratePassword() {
update('password', ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16));
}
const showFlow = useMemo( const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)), () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds], [form.inboundIds, flowCapableIds],
@@ -257,6 +272,15 @@ export default function ClientFormModal({
} }
}, [showReverseTag, form.reverseTag]); }, [showReverseTag, form.reverseTag]);
useEffect(() => {
if (!ss2022Method) return;
setForm((prev) => (
RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
? prev
: { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
));
}, [ss2022Method]);
const inboundOptions = useMemo( const inboundOptions = useMemo(
() => (inbounds || []) () => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || '')) .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
@@ -433,7 +457,7 @@ export default function ClientFormModal({
<Form.Item label={t('pages.clients.password')}> <Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} /> <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} /> <Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
</Col> </Col>
+2 -4
View File
@@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
import './ClientsPage.css'; import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState'; const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
function UngroupIcon() { function UngroupIcon() {
return ( return (
@@ -276,10 +277,7 @@ export default function ClientsPage() {
const activeCount = activeFilterCount(filters); const activeCount = activeFilterCount(filters);
useEffect(() => { useEffect(() => {
if (pageSize > 0) { setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
setTablePageSize(pageSize);
}
}, [pageSize]); }, [pageSize]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
@@ -875,6 +875,7 @@ export default function InboundFormModal({
colon={false} colon={false}
labelCol={{ sm: { span: 8 } }} labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }} wrapperCol={{ sm: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange} onValuesChange={onValuesChange}
> >
<Tabs items={[ <Tabs items={[
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives'; import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
interface RealityFormProps { interface RealityFormProps {
saving: boolean; saving: boolean;
@@ -44,10 +45,24 @@ export default function RealityForm({
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.form.target')}> <Form.Item
label={t('pages.inbounds.form.target')}
extra={t('pages.inbounds.form.realityTargetHint')}
>
<Space.Compact block> <Space.Compact block>
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle> <Form.Item
<Input style={{ width: 'calc(100% - 32px)' }} /> name={['streamSettings', 'realitySettings', 'target']}
noStyle
rules={[
{
validator: async (_, value) => {
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
if (errKey) throw new Error(t(errKey));
},
},
]}
>
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
</Form.Item> </Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} /> <Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact> </Space.Compact>
@@ -16,6 +16,7 @@ const newEntry = () => ({
fingerprint: '', fingerprint: '',
alpn: [], alpn: [],
pinnedPeerCertSha256: [], pinnedPeerCertSha256: [],
echConfigList: '',
}); });
function Field({ label, children }: { label: ReactNode; children: ReactNode }) { function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -92,9 +93,9 @@ export default function ExternalProxyForm({
/> />
</Form.Item> </Form.Item>
</Field> </Field>
<Field label={t('host')}> <Field label={t('pages.inbounds.address')}>
<Form.Item name={[field.name, 'dest']} noStyle> <Form.Item name={[field.name, 'dest']} noStyle>
<Input placeholder={t('host')} /> <Input placeholder={t('pages.inbounds.address')} />
</Form.Item> </Form.Item>
</Field> </Field>
<Field label={t('pages.inbounds.port')}> <Field label={t('pages.inbounds.port')}>
@@ -125,7 +126,7 @@ export default function ExternalProxyForm({
<div className="ext-proxy-grid ext-proxy-grid--tls"> <div className="ext-proxy-grid ext-proxy-grid--tls">
<Field label="SNI"> <Field label="SNI">
<Form.Item name={[field.name, 'sni']} noStyle> <Form.Item name={[field.name, 'sni']} noStyle>
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} /> <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</Form.Item> </Form.Item>
</Field> </Field>
<Field label={t('pages.inbounds.form.fingerprint')}> <Field label={t('pages.inbounds.form.fingerprint')}>
@@ -157,6 +158,11 @@ export default function ExternalProxyForm({
</Form.Item> </Form.Item>
</Field> </Field>
</div> </div>
<Field label={t('pages.inbounds.form.echConfig')}>
<Form.Item name={[field.name, 'echConfigList']} noStyle>
<Input placeholder={t('pages.inbounds.form.echConfig')} />
</Form.Item>
</Field>
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}> <Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle> <Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
@@ -60,6 +60,7 @@ export default function SockoptForm({
<Form.Item <Form.Item
name={['streamSettings', 'sockopt', 'tcpWindowClamp']} name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
label={t('pages.inbounds.form.tcpWindowClamp')} label={t('pages.inbounds.form.tcpWindowClamp')}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
> >
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
@@ -120,7 +120,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
// node's own paths (fetched through the central panel), not this panel's. // node's own paths (fetched through the central panel), not this panel's.
const msg = typeof nodeId === 'number' const msg = typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true }) ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/setting/all', undefined, { silent: true }); : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) { if (!msg?.success) {
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty')); messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
return; return;
+41 -35
View File
@@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise<string[]> {
return Array.isArray(validated.obj) ? validated.obj : []; return Array.isArray(validated.obj) ? validated.obj : [];
} }
// Online emails grouped by node id (local panel = key 0), used to scope the // Online emails grouped by the panelGuid of the node that physically hosts each
// per-inbound online rollup so a client online on one node is not shown // client, used to scope the per-inbound online rollup so a client online on one
// online on every node's inbounds. // node is not shown online on every node's inbounds — and a client on a
async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> { // sub-node is attributed to that sub-node, not the node it syncs through (#4983).
const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true }); async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode'); const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {}; return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
} }
@@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {}; return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
} }
function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> { function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
const map = new Map<number, Set<string>>(); const map = new Map<string, Set<string>>();
for (const [key, emails] of Object.entries(data)) { for (const [key, emails] of Object.entries(data)) {
if (!Array.isArray(emails)) continue; if (!Array.isArray(emails)) continue;
map.set(Number(key), new Set(emails)); map.set(key, new Set(emails));
} }
return map; return map;
} }
@@ -96,7 +97,7 @@ async function fetchLastOnlineMap(): Promise<Record<string, number>> {
} }
async function fetchDefaultSettings(): Promise<DefaultsPayload> { async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings'); const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj ?? {}; return validated.obj ?? {};
@@ -117,9 +118,9 @@ export function useInbounds() {
staleTime: Infinity, staleTime: Infinity,
}); });
const onlinesByNodeQuery = useQuery({ const onlinesByGuidQuery = useQuery({
queryKey: keys.clients.onlinesByNode(), queryKey: keys.clients.onlinesByGuid(),
queryFn: fetchOnlineClientsByNode, queryFn: fetchOnlineClientsByGuid,
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -182,16 +183,17 @@ export function useInbounds() {
const onlineClientsRef = useRef<string[]>([]); const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients; onlineClientsRef.current = onlineClients;
// Online emails keyed by node id (local inbounds = key 0). The rollup // Online emails keyed by the hosting node's panelGuid. The rollup reads this
// reads this so each inbound only counts clients online on its own node. // so each inbound only counts clients online on the node that physically
const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map()); // hosts it, attributing a sub-node's clients to that sub-node (#4983).
const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
// Recently-active inbound tags keyed by node id. A node missing from this // Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
// map means "no per-inbound activity reported" (e.g. remote nodes), so the // missing from this map means "no per-inbound activity reported" (e.g. remote
// rollup leaves that node's inbounds ungated and falls back to the email // nodes), so the rollup leaves that node's inbounds ungated and falls back to
// signal. A present node gates: a client only counts online on an inbound // the email signal. A present GUID gates: a client only counts online on an
// whose tag carried traffic this window. // inbound whose tag carried traffic this window.
const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map()); const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({}); const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
@@ -209,13 +211,17 @@ export function useInbounds() {
const comments = new Map<string, string>(); const comments = new Map<string, string>();
const now = Date.now(); const now = Date.now();
const nodeId = dbInbound.nodeId ?? 0; // Attribution key: the GUID of the node that physically hosts this
const nodeOnline = onlineByNodeRef.current.get(nodeId); // inbound. Local inbounds carry the panel's own GUID (filled server-side);
// a node-managed inbound carries its origin node's GUID, or falls back to
// the master-local synthetic id for an old-build node without one (#4983).
const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
const nodeOnline = onlineByGuidRef.current.get(guid);
// A node absent from the active map reports no per-inbound activity, so // A node absent from the active map reports no per-inbound activity, so
// leave its inbounds ungated. When present, only mark a client online on // leave its inbounds ungated. When present, only mark a client online on
// this inbound if its tag actually carried traffic — that's what stops a // this inbound if its tag actually carried traffic — that's what stops a
// multi-inbound client lighting up every inbound it's attached to. // multi-inbound client lighting up every inbound it's attached to.
const activeForNode = activeByNodeRef.current.get(nodeId); const activeForNode = activeByGuidRef.current.get(guid);
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag); const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
if (dbInbound.enable) { if (dbInbound.enable) {
@@ -305,15 +311,15 @@ export function useInbounds() {
}, [onlinesQuery.data]); }, [onlinesQuery.data]);
useEffect(() => { useEffect(() => {
if (onlinesByNodeQuery.data) { if (onlinesByGuidQuery.data) {
onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data); onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
rebuildClientCount(); rebuildClientCount();
} }
}, [onlinesByNodeQuery.data, rebuildClientCount]); }, [onlinesByGuidQuery.data, rebuildClientCount]);
useEffect(() => { useEffect(() => {
if (activeInboundsQuery.data) { if (activeInboundsQuery.data) {
activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data); activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
rebuildClientCount(); rebuildClientCount();
} }
}, [activeInboundsQuery.data, rebuildClientCount]); }, [activeInboundsQuery.data, rebuildClientCount]);
@@ -336,7 +342,7 @@ export function useInbounds() {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }), queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }), queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }), queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }), queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }), queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }), queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
@@ -367,16 +373,16 @@ export function useInbounds() {
const applyTrafficEvent = useCallback( const applyTrafficEvent = useCallback(
(payload: unknown) => { (payload: unknown) => {
if (!payload || typeof payload !== 'object') return; if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> }; const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
if (Array.isArray(p.onlineClients)) { if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients; onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients); setOnlineClients(p.onlineClients);
} }
if (p.onlineByNode && typeof p.onlineByNode === 'object') { if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode); onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
} }
if (p.activeInbounds && typeof p.activeInbounds === 'object') { if (p.activeInbounds && typeof p.activeInbounds === 'object') {
activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds); activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
} }
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') { if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! })); setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
+15 -1
View File
@@ -25,6 +25,10 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb'; window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
} }
function exportMigration() {
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getMigration';
}
function importDb() { function importDb() {
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
@@ -48,7 +52,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
} }
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` }); onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
const restart = await HttpUtil.post('/panel/setting/restartPanel'); const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
if (restart?.success) { if (restart?.success) {
await PromiseUtil.sleep(5000); await PromiseUtil.sleep(5000);
window.location.reload(); window.location.reload();
@@ -82,6 +86,16 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} /> <Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
</div> </div>
<div className="backup-item">
<div className="backup-meta">
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
<div className="backup-description">
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
</div>
</div>
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
</div>
<div className="backup-item"> <div className="backup-item">
<div className="backup-meta"> <div className="backup-meta">
<div className="backup-title">{t('pages.index.importDatabase')}</div> <div className="backup-title">{t('pages.index.importDatabase')}</div>
+1 -1
View File
@@ -87,7 +87,7 @@ export default function IndexPage() {
const [loadingTip, setLoadingTip] = useState(t('loading')); const [loadingTip, setLoadingTip] = useState(t('loading'));
useEffect(() => { useEffect(() => {
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => { HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable); if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
}); });
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => { HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
+72 -15
View File
@@ -15,6 +15,7 @@ import {
import type { BadgeProps } from 'antd'; import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
ApartmentOutlined,
ClusterOutlined, ClusterOutlined,
CloudDownloadOutlined, CloudDownloadOutlined,
DeleteOutlined, DeleteOutlined,
@@ -56,7 +57,7 @@ function isUpdateEligible(n: NodeRecord): boolean {
interface NodeRow extends NodeRecord { interface NodeRow extends NodeRecord {
url: string; url: string;
key: number; key: string | number;
} }
function badgeStatus(status?: string): BadgeProps['status'] { function badgeStatus(status?: string): BadgeProps['status'] {
@@ -131,14 +132,49 @@ export default function NodeList({
const [statsNode, setStatsNode] = useState<NodeRow | null>(null); const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const dataSource = useMemo<NodeRow[]>( // Map a node GUID to its display name so a transitive sub-node can show which
() => nodes.map((n) => ({ // parent it is reached through (#4983).
const nameByGuid = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
return m;
}, [nodes]);
// Order direct nodes first, each immediately followed by its transitive
// sub-nodes, so the table reads as a parent -> child tree without colliding
// with the per-row history expander (transitive nodes carry id 0).
const dataSource = useMemo<NodeRow[]>(() => {
const toRow = (n: NodeRecord): NodeRow => ({
...n, ...n,
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`, url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
key: n.id, key: n.transitive ? `t-${n.guid || ''}` : n.id,
})), });
[nodes], const childrenByParent = new Map<string, NodeRecord[]>();
); for (const n of nodes) {
if (n.transitive && n.parentGuid) {
const arr = childrenByParent.get(n.parentGuid) || [];
arr.push(n);
childrenByParent.set(n.parentGuid, arr);
}
}
const ordered: NodeRow[] = [];
const added = new Set<string>();
const push = (n: NodeRecord) => {
const row = toRow(n);
ordered.push(row);
added.add(String(row.key));
};
for (const n of nodes) {
if (n.transitive) continue;
push(n);
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
}
// Transitive nodes whose parent isn't in the list still get shown.
for (const n of nodes) {
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
}
return ordered;
}, [nodes]);
function toggleExpanded(id: number) { function toggleExpanded(id: number) {
setExpandedIds((prev) => { setExpandedIds((prev) => {
@@ -153,7 +189,11 @@ export default function NodeList({
title: t('pages.nodes.actions'), title: t('pages.nodes.actions'),
align: 'center', align: 'center',
width: 190, width: 190,
render: (_value, record) => ( render: (_value, record) => record.transitive ? (
<Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</Tooltip>
) : (
<Space> <Space>
<Tooltip title={t('pages.nodes.probe')}> <Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} /> <Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
@@ -177,7 +217,9 @@ export default function NodeList({
dataIndex: 'enable', dataIndex: 'enable',
align: 'center', align: 'center',
width: 80, width: 80,
render: (_value, record) => ( render: (_value, record) => record.transitive ? (
<span style={{ opacity: 0.4 }}>—</span>
) : (
<Switch <Switch
checked={!!record.enable} checked={!!record.enable}
size="small" size="small"
@@ -190,8 +232,11 @@ export default function NodeList({
dataIndex: 'name', dataIndex: 'name',
ellipsis: true, ellipsis: true,
render: (_value, record) => ( render: (_value, record) => (
<div className="name-cell"> <div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
<span className="name">{record.name}</span> <span className="name">
{record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
{record.name}
</span>
{record.remark && <span className="remark">{record.remark}</span>} {record.remark && <span className="remark">{record.remark}</span>}
</div> </div>
), ),
@@ -316,7 +361,7 @@ export default function NodeList({
width: 120, width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat), render: (_value, record) => relativeTime(record.lastHeartbeat),
}, },
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]); ], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
return ( return (
<Card size="small" hoverable> <Card size="small" hoverable>
@@ -340,7 +385,18 @@ export default function NodeList({
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
) : ( ) : (
dataSource.map((record) => ( dataSource.map((record) => record.transitive ? (
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
<div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</div>
</div>
</div>
) : (
<div key={record.id} className="node-card"> <div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}> <div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} /> <RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
@@ -501,8 +557,8 @@ export default function NodeList({
rowKey="id" rowKey="id"
rowSelection={dataSource.length > 1 ? { rowSelection={dataSource.length > 1 ? {
selectedRowKeys: selectedIds, selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys as number[]), onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }), getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
} : undefined} } : undefined}
locale={{ locale={{
emptyText: ( emptyText: (
@@ -514,6 +570,7 @@ export default function NodeList({
}} }}
expandable={{ expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />, expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}} }}
/> />
)} )}
+5 -5
View File
@@ -96,7 +96,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
const sendUpdateUser = useCallback(async () => { const sendUpdateUser = useCallback(async () => {
setUpdating(true); setUpdating(true);
try { try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg; const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
if (msg?.success) { if (msg?.success) {
await HttpUtil.post('/logout'); await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/'; const basePath = window.X_UI_BASE_PATH || '/';
@@ -124,7 +124,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
const loadApiTokens = useCallback(async () => { const loadApiTokens = useCallback(async () => {
setApiTokensLoading(true); setApiTokensLoading(true);
try { try {
const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>; const msg = await HttpUtil.get('/panel/api/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []); if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
} finally { } finally {
setApiTokensLoading(false); setApiTokensLoading(false);
@@ -156,7 +156,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
} }
setCreating(true); setCreating(true);
try { try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>; const msg = await HttpUtil.post('/panel/api/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
if (msg?.success) { if (msg?.success) {
setCreateOpen(false); setCreateOpen(false);
await loadApiTokens(); await loadApiTokens();
@@ -178,7 +178,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
cancelText: t('cancel'), cancelText: t('cancel'),
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg; const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg;
if (msg?.success) await loadApiTokens(); if (msg?.success) await loadApiTokens();
}, },
}); });
@@ -186,7 +186,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
async function toggleTokenEnabled(row: ApiTokenRow) { async function toggleTokenEnabled(row: ApiTokenRow) {
const target = !row.enabled; const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg; const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
if (msg?.success) { if (msg?.success) {
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r))); setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
} }
+1 -1
View File
@@ -142,7 +142,7 @@ export default function SettingsPage() {
onOk: async () => { onOk: async () => {
setSpinning(true); setSpinning(true);
try { try {
const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg; const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg;
if (!msg?.success) return; if (!msg?.success) return;
await PromiseUtil.sleep(5000); await PromiseUtil.sleep(5000);
window.location.replace(rebuildUrlAfterRestart()); window.location.replace(rebuildUrlAfterRestart());
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react';
import { Form } from 'antd';
import { FinalMaskForm } from '@/lib/xray/forms/transport';
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
interface SubJsonFinalMaskFormProps {
value: string;
onChange: (next: string) => void;
}
function hasValue(v: unknown): boolean {
if (v == null) return false;
if (Array.isArray(v)) return v.some(hasValue);
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
if (typeof v === 'string') return v.length > 0;
return true;
}
function parseFinalMask(raw: string): FinalMaskStreamSettings {
try {
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
} catch {
return { tcp: [], udp: [] };
}
return { tcp: [], udp: [] };
}
export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
const [form] = Form.useForm();
const [initial] = useState(() => parseFinalMask(value));
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
useEffect(() => {
if (finalmask === undefined) return;
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
if (next !== value) onChangeRef.current(next);
}, [finalmask, value]);
return (
<Form
form={form}
layout="horizontal"
labelCol={{ flex: '160px' }}
wrapperCol={{ flex: 'auto' }}
colon={false}
initialValues={{ finalmask: initial }}
>
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
</Form>
);
}
@@ -1,8 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button,
Card,
Input, Input,
InputNumber, InputNumber,
Select, Select,
@@ -10,19 +8,17 @@ import {
Tabs, Tabs,
} from 'antd'; } from 'antd';
import { import {
DeleteOutlined,
PartitionOutlined, PartitionOutlined,
PlusOutlined, RocketOutlined,
ScissorOutlined,
SendOutlined, SendOutlined,
SettingOutlined, SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath'; import { sanitizePath, normalizePath } from './uriPath';
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
import './SubscriptionFormatsTab.css'; import './SubscriptionFormatsTab.css';
interface SubscriptionFormatsTabProps { interface SubscriptionFormatsTabProps {
@@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
updateSetting: (patch: Partial<AllSetting>) => void; updateSetting: (patch: Partial<AllSetting>) => void;
} }
const DEFAULT_FRAGMENT = {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
};
const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
];
const DEFAULT_MUX = { const DEFAULT_MUX = {
enabled: true, enabled: true,
concurrency: 8, concurrency: 8,
@@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const fragment = allSetting.subJsonFragment !== '';
const noisesEnabled = allSetting.subJsonNoises !== '';
const muxEnabled = allSetting.subJsonMux !== ''; const muxEnabled = allSetting.subJsonMux !== '';
const directEnabled = allSetting.subJsonRules !== ''; const directEnabled = allSetting.subJsonRules !== '';
const fragmentObj = useMemo(
() => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
[allSetting.subJsonFragment, fragment],
);
function setFragmentEnabled(v: boolean) {
updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
}
function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
if (value === '') return;
const next = { ...fragmentObj, [key]: value };
updateSetting({ subJsonFragment: JSON.stringify(next) });
}
const noisesArray = useMemo(
() => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
[allSetting.subJsonNoises, noisesEnabled],
);
function setNoisesEnabled(v: boolean) {
updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
}
function setNoisesArray(next: typeof DEFAULT_NOISES) {
if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
}
function addNoise() {
setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
}
function removeNoise(index: number) {
const next = [...noisesArray];
next.splice(index, 1);
setNoisesArray(next);
}
function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
const next = [...noisesArray];
next[index] = { ...next[index], [field]: value };
setNoisesArray(next);
}
const muxObj = useMemo( const muxObj = useMemo(
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), () => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
[allSetting.subJsonMux, muxEnabled], [allSetting.subJsonMux, muxEnabled],
@@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile), label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
<Switch checked={fragment} onChange={setFragmentEnabled} /> <SubJsonFinalMaskForm
</SettingListItem> value={allSetting.subJsonFinalMask}
{fragment && ( onChange={(v) => updateSetting({ subJsonFinalMask: v })}
<div className="format-settings"> />
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
onChange={(e) => setFragmentField('packets', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
<Input value={fragmentObj.length} placeholder="100-200"
onChange={(e) => setFragmentField('length', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
<Input value={fragmentObj.interval} placeholder="10-20"
onChange={(e) => setFragmentField('interval', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
<Input value={fragmentObj.maxSplit} placeholder="300-400"
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
</SettingListItem>
</div>
)}
</> </>
), ),
}, },
{ {
key: '3', key: '3',
label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
</SettingListItem>
{noisesEnabled && (
<div className="format-settings-list">
{noisesArray.map((noise, index) => (
<Card
key={index}
size="small"
className="noise-card"
title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
extra={noisesArray.length > 1 ? (
<Button
size="small"
danger
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => removeNoise(index)}
/>
) : null}
styles={{ body: { padding: 0 } }}
>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
<Select
value={noise.type}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'type', v)}
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
<Input value={noise.packet} placeholder="5-10"
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
<Input value={noise.delay} placeholder="10-20"
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
<Select
value={noise.applyTo}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</Card>
))}
<Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
{t('pages.settings.subFormats.addNoise')}
</Button>
</div>
)}
</>
),
},
{
key: '4',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile), label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: ( children: (
<> <>
@@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
), ),
}, },
{ {
key: '5', key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile), label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: ( children: (
<> <>
@@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..." <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} /> onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem> </SettingListItem>
<Divider>Clash / Mihomo</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
</> </>
), ),
}, },
+1 -1
View File
@@ -120,7 +120,7 @@ export default function SubPage() {
if (!subUrl) return ''; if (!subUrl) return '';
const separator = subUrl.includes('?') ? '&' : '?'; const separator = subUrl.includes('?') ? '&' : '?';
const rawUrl = subUrl + separator + 'flag=shadowrocket'; const rawUrl = subUrl + separator + 'flag=shadowrocket';
const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const base64Url = btoa(rawUrl);
const remark = encodeURIComponent(subTitle || sId || 'Subscription'); const remark = encodeURIComponent(subTitle || sId || 'Subscription');
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`; return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
}, []); }, []);
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives'; import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel'; import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -84,6 +85,49 @@ export default function BasicsTab({
| { domainStrategy?: string } | { domainStrategy?: string }
| undefined)?.domainStrategy ?? 'AsIs'; | undefined)?.domainStrategy ?? 'AsIs';
const directFreedomOutbound = templateSettings?.outbounds?.find(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
const directHappyEyeballs = (() => {
const sockopt = (directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined)
?.sockopt;
const raw = sockopt?.happyEyeballs;
if (raw == null || typeof raw !== 'object') return null;
return HappyEyeballsSchema.parse(raw);
})();
const setDirectHappyEyeballs = useCallback(
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
if (idx < 0) {
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
idx = tt.outbounds.length - 1;
}
const ob = tt.outbounds[idx];
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
if (next == null) {
delete sockopt.happyEyeballs;
} else {
sockopt.happyEyeballs = next;
}
if (Object.keys(sockopt).length === 0) {
delete stream.sockopt;
} else {
stream.sockopt = sockopt;
}
if (Object.keys(stream).length === 0) {
delete ob.streamSettings;
} else {
ob.streamSettings = stream;
}
});
},
[mutate],
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs'; const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const log = (templateSettings?.log || {}) as Record<string, unknown>; const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>; const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
@@ -124,6 +168,53 @@ export default function BasicsTab({
/> />
} }
/> />
<SettingListItem
title={t('pages.xray.FreedomHappyEyeballs')}
description={t('pages.xray.FreedomHappyEyeballsDesc')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs != null}
onChange={(checked) => {
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
}}
/>
}
/>
{directHappyEyeballs != null && (
<>
<SettingListItem
title={t('pages.inbounds.form.tryDelayMs')}
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
paddings="small"
control={
<InputNumber
min={0}
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={(v) => setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: typeof v === 'number' ? v : 0,
})}
/>
}
/>
<SettingListItem
title={t('pages.inbounds.form.prioritizeIPv6')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs.prioritizeIPv6}
onChange={(checked) => setDirectHappyEyeballs({
...directHappyEyeballs,
prioritizeIPv6: checked,
})}
/>
}
/>
</>
)}
<SettingListItem <SettingListItem
title={t('pages.xray.RoutingStrategy')} title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')} description={t('pages.xray.RoutingStrategyDesc')}
@@ -350,6 +350,7 @@ export default function OutboundFormModal({
colon={false} colon={false}
labelCol={{ md: { span: 8 } }} labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }} wrapperCol={{ md: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange} onValuesChange={onValuesChange}
> >
<Tabs <Tabs
@@ -171,6 +171,7 @@ export default function SockoptForm({
<Form.Item <Form.Item
label={t('pages.inbounds.form.tcpWindowClamp')} label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']} name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
> >
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
@@ -88,14 +88,14 @@ export default function NordModal({
}, [filteredServers]); }, [filteredServers]);
const fetchCountries = useCallback(async () => { const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<string>('/panel/xray/nord/countries'); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj)); if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
}, []); }, []);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/data'); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
if (msg?.success) { if (msg?.success) {
const next = msg.obj ? JSON.parse(msg.obj) : null; const next = msg.obj ? JSON.parse(msg.obj) : null;
setNordData(next); setNordData(next);
@@ -113,7 +113,7 @@ export default function NordModal({
async function login() { async function login() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token }); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj)); setNordData(JSON.parse(msg.obj));
await fetchCountries(); await fetchCountries();
@@ -126,7 +126,7 @@ export default function NordModal({
async function saveKey() { async function saveKey() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey }); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj)); setNordData(JSON.parse(msg.obj));
await fetchCountries(); await fetchCountries();
@@ -139,7 +139,7 @@ export default function NordModal({
async function logout() { async function logout() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post('/panel/xray/nord/del'); const msg = await HttpUtil.post('/panel/api/xray/nord/del');
if (msg?.success) { if (msg?.success) {
onRemoveOutbound(nordOutboundIndex); onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' }); onRemoveRoutingRules({ prefix: 'nord-' });
@@ -166,7 +166,7 @@ export default function NordModal({
setServerId(null); setServerId(null);
setCityId(null); setCityId(null);
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId }); const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
if (!msg?.success || !msg.obj) return; if (!msg?.success || !msg.obj) return;
const data = JSON.parse(msg.obj); const data = JSON.parse(msg.obj);
const locations = data.locations || []; const locations = data.locations || [];
@@ -111,7 +111,7 @@ export default function WarpModal({
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/warp/data'); const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
if (msg?.success) { if (msg?.success) {
const raw = msg.obj; const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null); setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
@@ -133,7 +133,7 @@ export default function WarpModal({
setLoading(true); setLoading(true);
try { try {
const keys = Wireguard.generateKeypair(); const keys = Wireguard.generateKeypair();
const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys); const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
const resp = JSON.parse(msg.obj); const resp = JSON.parse(msg.obj);
setWarpData(resp.data); setWarpData(resp.data);
@@ -148,7 +148,7 @@ export default function WarpModal({
async function getConfig() { async function getConfig() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/warp/config'); const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
const parsed = JSON.parse(msg.obj); const parsed = JSON.parse(msg.obj);
setWarpConfig(parsed); setWarpConfig(parsed);
@@ -164,7 +164,7 @@ export default function WarpModal({
setLoading(true); setLoading(true);
setLicenseError(''); setLicenseError('');
try { try {
const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus }); const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
setWarpData(JSON.parse(msg.obj)); setWarpData(JSON.parse(msg.obj));
setWarpConfig(null); setWarpConfig(null);
@@ -180,7 +180,7 @@ export default function WarpModal({
async function delConfig() { async function delConfig() {
setLoading(true); setLoading(true);
try { try {
const msg = await HttpUtil.post('/panel/xray/warp/del'); const msg = await HttpUtil.post('/panel/api/xray/warp/del');
if (msg?.success) { if (msg?.success) {
setWarpData(null); setWarpData(null);
setWarpConfig(null); setWarpConfig(null);
+2 -1
View File
@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
protocol: z.string().optional(), protocol: z.string().optional(),
port: z.number().optional(), port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(), tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(),
}).loose(); }).loose();
export const InboundOptionsSchema = z.array(InboundOptionSchema); export const InboundOptionsSchema = z.array(InboundOptionSchema);
@@ -182,7 +183,7 @@ export const ClientBulkAddFormSchema = z.object({
lastNum: z.number().int().min(1), lastNum: z.number().int().min(1),
emailPrefix: z.string(), emailPrefix: z.string(),
emailPostfix: z.string(), emailPostfix: z.string(),
quantity: z.number().int().min(1).max(100), quantity: z.number().int().min(1).max(1000),
subId: z.string(), subId: z.string(),
group: z.string(), group: z.string(),
comment: z.string(), comment: z.string(),
+5
View File
@@ -26,6 +26,11 @@ export const NodeRecordSchema = z.object({
allowPrivateAddress: z.boolean().optional(), allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(), pinnedCertSha256: z.string().optional(),
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
// whether it's a read-only transitive sub-node surfaced from a downstream node.
guid: z.string().optional(),
parentGuid: z.string().optional(),
transitive: z.boolean().optional(),
}).loose(); }).loose();
export const NodeListSchema = z.array(NodeRecordSchema); export const NodeListSchema = z.array(NodeRecordSchema);
@@ -23,5 +23,6 @@ export const ExternalProxyEntrySchema = z.object({
), ),
alpn: z.array(AlpnSchema).optional(), alpn: z.array(AlpnSchema).optional(),
pinnedPeerCertSha256: z.array(z.string()).optional(), pinnedPeerCertSha256: z.array(z.string()).optional(),
echConfigList: z.string().optional(),
}); });
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>; export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
tcpMptcp: z.boolean().default(false), tcpMptcp: z.boolean().default(false),
penetrate: z.boolean().default(false), penetrate: z.boolean().default(false),
domainStrategy: SockoptDomainStrategySchema.default('AsIs'), domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
tcpMaxSeg: z.number().int().min(0).default(1440), // 0 = omit on the wire; xray-core skips sockopt fields <= 0 and uses OS defaults.
// Non-zero defaults here previously came from the xray docs *example* (clamp 600,
// maxSeg 1440, userTimeout 10000) and were written into every config when the
// panel sockopt switch was enabled, throttling long-haul links.
tcpMaxSeg: z.number().int().min(0).default(0),
dialerProxy: z.string().default(''), dialerProxy: z.string().default(''),
tcpKeepAliveInterval: z.number().int().min(0).default(45), tcpKeepAliveInterval: z.number().int().min(0).default(0),
tcpKeepAliveIdle: z.number().int().min(0).default(45), tcpKeepAliveIdle: z.number().int().min(0).default(0),
tcpUserTimeout: z.number().int().min(0).default(10000), tcpUserTimeout: z.number().int().min(0).default(0),
tcpcongestion: TcpCongestionSchema.default('bbr'), tcpcongestion: TcpCongestionSchema.default('bbr'),
V6Only: z.boolean().default(false), V6Only: z.boolean().default(false),
tcpWindowClamp: z.number().int().min(0).default(600), tcpWindowClamp: z.number().int().min(0).default(0),
interface: z.string().default(''), interface: z.string().default(''),
trustedXForwardedFor: z.array(z.string()).default([]), trustedXForwardedFor: z.array(z.string()).default([]),
addressPortStrategy: AddressPortStrategySchema.default('none'), addressPortStrategy: AddressPortStrategySchema.default('none'),
+3 -2
View File
@@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
subURI: z.string().optional(), subURI: z.string().optional(),
subJsonURI: z.string().optional(), subJsonURI: z.string().optional(),
subClashURI: z.string().optional(), subClashURI: z.string().optional(),
subJsonFragment: z.string().optional(), subClashEnableRouting: z.boolean().optional(),
subJsonNoises: z.string().optional(), subClashRules: z.string().optional(),
subJsonMux: z.string().optional(), subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(), subJsonRules: z.string().optional(),
subJsonFinalMask: z.string().optional(),
timeLocation: z.string().optional(), timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(), ldapEnable: z.boolean().optional(),
ldapHost: z.string().optional(), ldapHost: z.string().optional(),
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
"mark": 0, "mark": 0,
"penetrate": false, "penetrate": false,
"tcpFastOpen": false, "tcpFastOpen": false,
"tcpKeepAliveIdle": 45, "tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 45, "tcpKeepAliveInterval": 0,
"tcpMaxSeg": 1440, "tcpMaxSeg": 0,
"tcpMptcp": false, "tcpMptcp": false,
"tcpUserTimeout": 10000, "tcpUserTimeout": 0,
"tcpWindowClamp": 600, "tcpWindowClamp": 0,
"tcpcongestion": "bbr", "tcpcongestion": "bbr",
"tproxy": "off", "tproxy": "off",
"trustedXForwardedFor": [], "trustedXForwardedFor": [],
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
"mark": 255, "mark": 255,
"penetrate": true, "penetrate": true,
"tcpFastOpen": false, "tcpFastOpen": false,
"tcpKeepAliveIdle": 45, "tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 45, "tcpKeepAliveInterval": 0,
"tcpMaxSeg": 1440, "tcpMaxSeg": 0,
"tcpMptcp": false, "tcpMptcp": false,
"tcpUserTimeout": 10000, "tcpUserTimeout": 0,
"tcpWindowClamp": 600, "tcpWindowClamp": 0,
"tcpcongestion": "bbr", "tcpcongestion": "bbr",
"tproxy": "tproxy", "tproxy": "tproxy",
"trustedXForwardedFor": [], "trustedXForwardedFor": [],
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import type { ZodType } from 'zod';
import { EXAMPLES } from '@/generated/examples';
import * as zodSchemas from '@/generated/zod';
const registry = zodSchemas as unknown as Record<string, ZodType>;
const names = Object.keys(EXAMPLES);
describe('generated response examples', () => {
it('has at least one example to validate', () => {
expect(names.length).toBeGreaterThan(0);
});
it('pairs every example with a generated zod schema', () => {
const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
expect(missing).toEqual([]);
});
it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
const schema = registry[`${name}Schema`];
const result = schema.safeParse(EXAMPLES[name]);
if (!result.success) {
throw new Error(
`EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
);
}
expect(result.success).toBe(true);
});
});
@@ -360,6 +360,21 @@ describe('parseVlessLink — extra / fm / x_padding_bytes (B20)', () => {
const stream = parsed!.streamSettings as Record<string, unknown>; const stream = parsed!.streamSettings as Record<string, unknown>;
expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto'); expect((stream.xhttpSettings as Record<string, unknown>).mode).toBe('auto');
}); });
it('round-trips ech and pcs from a TLS vless link', () => {
const ech = 'AFb+DQBSAAAgACAL7gYwrvaSFCIEs34G3SkfpuIbjMuYQxAiJsPK1oO7cwAkAAEAAQABAAIAAQADAAIAAQACAAIAAgADAAMAAQADAAIAAwADAAMxMjMAAA==';
const pcs = '6fbc15ba46dfed152ad6c8d2129dd774707dd667a9ab4965476fa0f79ba82670';
const link = 'vless://e3d307ae-c074-4aa3-af08-4f9e0f1d298b@localhost:15282?'
+ 'alpn=h3&ech=' + encodeURIComponent(ech) + '&encryption=none&fp=firefox&host=&'
+ 'mode=packet-up&path=%2F&pcs=' + pcs + '&security=tls&sni=123&type=xhttp#i5sboxj07w';
const parsed = parseVlessLink(link);
expect(parsed).not.toBeNull();
const tls = (parsed!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;
expect(tls.echConfigList).toBe(ech);
expect(tls.pinnedPeerCertSha256).toBe(pcs);
expect(tls.serverName).toBe('123');
expect(tls.fingerprint).toBe('firefox');
});
}); });
describe('parseWireguardLink', () => { describe('parseWireguardLink', () => {
@@ -0,0 +1,243 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
import {
normalizeSockoptForWire,
normalizeStreamSettingsForWire,
normalizeXhttpForWire,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
describe('validateRealityTarget', () => {
it('accepts host:port and bare port', () => {
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
expect(validateRealityTarget('443')).toBeUndefined();
});
it('rejects host without port', () => {
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
});
});
describe('normalizeXhttpForWire stream-one', () => {
it('drops packet-up and stream-up-only fields on inbound', () => {
const out = normalizeXhttpForWire({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
scMaxBufferedPosts: 30,
scStreamUpServerSecs: '20-80',
enableXmux: false,
headers: {},
}, 'inbound');
expect(out).toMatchObject({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
});
expect(out).not.toHaveProperty('scMaxEachPostBytes');
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
expect(out).not.toHaveProperty('scMaxBufferedPosts');
expect(out).not.toHaveProperty('scStreamUpServerSecs');
expect(out).not.toHaveProperty('enableXmux');
expect(out).not.toHaveProperty('headers');
});
it('keeps xmux on outbound stream-one', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xPaddingBytes: '100-1000',
xmux: { maxConcurrency: '16-32' },
scMaxEachPostBytes: '1000000',
}, 'outbound');
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
expect(out).not.toHaveProperty('scMaxEachPostBytes');
});
});
describe('normalizeSockoptForWire', () => {
it('omits doc-example defaults that throttle throughput', () => {
const out = normalizeSockoptForWire({
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
domainStrategy: 'AsIs',
tproxy: 'off',
mark: 0,
});
expect(out).toEqual({
tcpFastOpen: true,
tcpcongestion: 'bbr',
});
});
it('preserves happyEyeballs on freedom-style outbound', () => {
const out = normalizeSockoptForWire({
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
});
expect(out?.happyEyeballs).toMatchObject({
tryDelayMs: 150,
prioritizeIPv6: true,
});
expect(out?.domainStrategy).toBe('UseIP');
});
});
describe('normalizeStreamSettingsForWire reality', () => {
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['abcd'],
settings: {
publicKey: 'pub',
fingerprint: 'chrome',
spiderX: '/',
},
},
}, { side: 'inbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.target).toBe('play.google.com:443');
expect(reality.privateKey).toBe('priv');
const settings = reality.settings as Record<string, unknown>;
expect(settings.publicKey).toBe('pub');
expect(settings.spiderX).toBe('/');
});
it('passes client realitySettings through unchanged on outbound', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
publicKey: 'pub',
fingerprint: 'chrome',
serverName: 'play.google.com',
shortId: 'abcd',
spiderX: '/x',
},
}, { side: 'outbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.publicKey).toBe('pub');
expect(reality.serverName).toBe('play.google.com');
expect(reality.spiderX).toBe('/x');
});
});
describe('inbound formValuesToWirePayload integration', () => {
it('emits lean stream-one xhttp + sockopt on save', () => {
const values = {
remark: 't',
enable: true,
port: 443,
listen: '0.0.0.0',
tag: 'in-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'vless',
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
streamSettings: {
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['44003d86dc1e'],
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
},
xhttpSettings: {
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
enableXmux: false,
},
sockopt: {
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
},
},
} as InboundFormValues;
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
const sockopt = stream.sockopt as Record<string, unknown>;
const reality = stream.realitySettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
expect(sockopt.tcpFastOpen).toBe(true);
const realitySettings = reality.settings as Record<string, unknown>;
expect(realitySettings.publicKey).toBe('pub');
});
});
describe('freedom outbound sockopt wire payload', () => {
it('preserves happyEyeballs on direct freedom outbound', () => {
const wire = outboundToWire({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: 'UseIP' },
streamSettings: {
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
},
},
} as Parameters<typeof outboundToWire>[0]);
expect(wire.streamSettings).toMatchObject({
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
},
},
});
});
});
+24 -8
View File
@@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import i18next from 'i18next';
import { getMessage } from './messageBus'; import { getMessage } from './messageBus';
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown }; type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
@@ -32,6 +33,14 @@ export class HttpUtil {
} }
const messageType = msg.success ? 'success' : 'error'; const messageType = msg.success ? 'success' : 'error';
getMessage()[messageType](msg.msg); getMessage()[messageType](msg.msg);
if (
msg.success &&
msg.obj &&
typeof msg.obj === 'object' &&
(msg.obj as { nodePending?: unknown }).nodePending === true
) {
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
}
} }
static _respToMsg(resp: AxiosResponse | undefined): Msg { static _respToMsg(resp: AxiosResponse | undefined): Msg {
@@ -174,15 +183,22 @@ export class RandomUtil {
} }
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string { static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
let length = 32; const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
if (method === '2022-blake3-aes-128-gcm') {
length = 16;
}
const array = new Uint8Array(length); const array = new Uint8Array(length);
window.crypto.getRandomValues(array); window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array)); return Base64.alternativeEncode(String.fromCharCode(...array));
} }
static isShadowsocks2022Password(password: string, method: string): boolean {
if (!method || method.substring(0, 4) !== '2022') return true;
const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
try {
return window.atob(password).length === expected;
} catch {
return false;
}
}
static randomBase64(length: number = 16): string { static randomBase64(length: number = 16): string {
const array = new Uint8Array(length); const array = new Uint8Array(length);
window.crypto.getRandomValues(array); window.crypto.getRandomValues(array);
@@ -858,13 +874,13 @@ export class LanguageManager {
}); });
if (LanguageManager.isSupportLanguage(lang)) { if (LanguageManager.isSupportLanguage(lang)) {
CookieManager.setCookie('lang', lang); CookieManager.setCookie('lang', lang, 365);
} else { } else {
CookieManager.setCookie('lang', 'en-US'); CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload(); window.location.reload();
} }
} else { } else {
CookieManager.setCookie('lang', 'en-US'); CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload(); window.location.reload();
} }
@@ -875,7 +891,7 @@ export class LanguageManager {
if (!LanguageManager.isSupportLanguage(language)) { if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US'; language = 'en-US';
} }
CookieManager.setCookie('lang', language); CookieManager.setCookie('lang', language, 365);
window.location.reload(); window.location.reload();
} }
+1 -1
View File
@@ -22,7 +22,7 @@ function resolveDBPath() {
return '/etc/x-ui/x-ui.db'; return '/etc/x-ui/x-ui.db';
} }
const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token']; const PANEL_API_PREFIXES = ['panel/api/', 'panel/csrf-token'];
let cachedBasePath = '/'; let cachedBasePath = '/';
+24 -8
View File
@@ -297,7 +297,7 @@ setup_ssl_certificate() {
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}" echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}" echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
rm -rf ~/.acme.sh/${domain} 2> /dev/null rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
rm -rf "$certPath" 2> /dev/null rm -rf "$certPath" 2> /dev/null
return 1 return 1
fi fi
@@ -431,8 +431,8 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}" echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}" echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null rm -rf ${certDir} 2> /dev/null
return 1 return 1
fi fi
@@ -451,8 +451,8 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}" echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null rm -rf ${certDir} 2> /dev/null
return 1 return 1
fi fi
@@ -524,14 +524,30 @@ ssl_cert_issue() {
echo -e "${green}Your domain is: ${domain}, checking it...${plain}" echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}" SSL_ISSUED_DOMAIN="${domain}"
# detect existing certificate and reuse it if present # detect existing certificate and reuse it only if its files are actually
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
# certs under ${domain}; a failed issuance can leave a domain entry in --list
# with no usable cert files, which must not be reused (it produces a 0-byte
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0 local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
local acmeCertDir=""
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
acmeCertDir=~/.acme.sh/${domain}_ecc
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
acmeCertDir=~/.acme.sh/${domain}
fi
if [[ -n "${acmeCertDir}" ]]; then
cert_exists=1 cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}") local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}" echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo" [[ -n "${certInfo}" ]] && echo "$certInfo"
else else
echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
fi
if [[ ${cert_exists} -eq 0 ]]; then
echo -e "${green}Your domain is ready for issuing certificates now...${plain}" echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi fi
@@ -563,7 +579,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}" echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1 return 1
else else
@@ -617,7 +633,7 @@ ssl_cert_issue() {
else else
echo -e "${red}Installing certificate failed, exiting.${plain}" echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi fi
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1 return 1
+26 -3
View File
@@ -466,8 +466,14 @@ func main() {
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError) migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
var migrateDsn string var migrateDsn string
var migrateSrc string var migrateSrc string
var migrateDump string
var migrateRestore string
var migrateOut string
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)") migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)") migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
migrateDbCmd.StringVar(&migrateDump, "dump", "", "Write a portable SQL text dump of --src to this file (.db -> .dump)")
migrateDbCmd.StringVar(&migrateRestore, "restore", "", "Rebuild a SQLite database from this SQL text dump (.dump -> .db); requires --out")
migrateDbCmd.StringVar(&migrateOut, "out", "", "Destination SQLite file for --restore (must not already exist)")
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
var port int var port int
@@ -512,7 +518,7 @@ func main() {
fmt.Println("Commands:") fmt.Println("Commands:")
fmt.Println(" run run web panel") fmt.Println(" run run web panel")
fmt.Println(" migrate migrate form other/old x-ui") fmt.Println(" migrate migrate form other/old x-ui")
fmt.Println(" migrate-db copy data from the SQLite file into a PostgreSQL database") fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
fmt.Println(" setting set settings") fmt.Println(" setting set settings")
} }
@@ -541,14 +547,31 @@ func main() {
if src == "" { if src == "" {
src = config.GetDBPath() src = config.GetDBPath()
} }
if migrateDsn == "" { switch {
fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable") case migrateDump != "":
if err := database.DumpSQLite(src, migrateDump); err != nil {
fmt.Println("dump failed:", err)
os.Exit(1)
}
fmt.Printf("Dumped %s -> %s\n", src, migrateDump)
case migrateRestore != "":
if migrateOut == "" {
fmt.Println("--out is required when using --restore: the destination .db path (must not exist)")
return return
} }
if err := database.RestoreSQLite(migrateRestore, migrateOut); err != nil {
fmt.Println("restore failed:", err)
os.Exit(1)
}
fmt.Printf("Restored %s -> %s\n", migrateRestore, migrateOut)
case migrateDsn != "":
if err := database.MigrateData(src, migrateDsn); err != nil { if err := database.MigrateData(src, migrateDsn); err != nil {
fmt.Println("migration failed:", err) fmt.Println("migration failed:", err)
os.Exit(1) os.Exit(1)
} }
default:
fmt.Println("nothing to do: pass --dump <file>, --restore <file> --out <db>, or --dsn <postgres-dsn>")
}
case "setting": case "setting":
err := settingCmd.Parse(os.Args[2:]) err := settingCmd.Parse(os.Args[2:])
if err != nil { if err != nil {
+16 -11
View File
@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubUpdates = "10" SubUpdates = "10"
} }
SubJsonFragment, err := s.settingService.GetSubJsonFragment()
if err != nil {
SubJsonFragment = ""
}
SubJsonNoises, err := s.settingService.GetSubJsonNoises()
if err != nil {
SubJsonNoises = ""
}
SubJsonMux, err := s.settingService.GetSubJsonMux() SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil { if err != nil {
SubJsonMux = "" SubJsonMux = ""
@@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonRules = "" SubJsonRules = ""
} }
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
}
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
}
SubClashRules, err := s.settingService.GetSubClashRules()
if err != nil {
SubClashRules = ""
}
SubTitle, err := s.settingService.GetSubTitle() SubTitle, err := s.settingService.GetSubTitle()
if err != nil { if err != nil {
SubTitle = "" SubTitle = ""
@@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil return engine, nil
+111 -12
View File
@@ -15,17 +15,13 @@ import (
type SubClashService struct { type SubClashService struct {
inboundService service.InboundService inboundService service.InboundService
enableRouting bool
clashRules string
SubService *SubService SubService *SubService
} }
type ClashConfig struct { func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
Proxies []map[string]any `yaml:"proxies"` return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
ProxyGroups []map[string]any `yaml:"proxy-groups"`
Rules []string `yaml:"rules"`
}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
} }
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
} }
proxyNames = append(proxyNames, "DIRECT") proxyNames = append(proxyNames, "DIRECT")
config := ClashConfig{ config := map[string]any{
Proxies: proxies, "proxies": proxies,
ProxyGroups: []map[string]any{{ "proxy-groups": []map[string]any{{
"name": "PROXY", "name": "PROXY",
"type": "select", "type": "select",
"proxies": proxyNames, "proxies": proxyNames,
}}, }},
Rules: []string{"MATCH,PROXY"}, "rules": []string{"MATCH,PROXY"},
}
if s.enableRouting {
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
return "", "", err
}
} }
finalYAML, err := yaml.Marshal(config) finalYAML, err := yaml.Marshal(config)
@@ -554,3 +556,100 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src) maps.Copy(dst, src)
return dst return dst
} }
func mergeClashRulesYAML(base map[string]any, raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var custom any
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
mergeClashRules(base, linesToClashRules(raw))
return nil
}
switch typed := custom.(type) {
case []any:
mergeClashRules(base, typed)
case map[string]any:
for key, value := range typed {
if key == "rules" {
if ruleList, ok := asAnySlice(value); ok {
mergeClashRules(base, ruleList)
}
continue
}
base[key] = value
}
default:
mergeClashRules(base, linesToClashRules(raw))
}
return nil
}
func mergeClashRules(base map[string]any, customRules []any) {
if len(customRules) == 0 {
return
}
baseRules, _ := asAnySlice(base["rules"])
if hasClashMatchRule(customRules) {
base["rules"] = customRules
return
}
merged := make([]any, 0, len(customRules)+len(baseRules))
merged = append(merged, customRules...)
merged = append(merged, baseRules...)
base["rules"] = merged
}
func asAnySlice(value any) ([]any, bool) {
switch typed := value.(type) {
case []any:
return typed, true
case []string:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
case []map[string]any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
default:
return nil, false
}
}
func hasClashMatchRule(rules []any) bool {
for _, rule := range rules {
ruleText, ok := rule.(string)
if !ok {
continue
}
parts := strings.SplitN(ruleText, ",", 2)
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
return true
}
}
return false
}
func linesToClashRules(raw string) []any {
lines := strings.Split(raw, "\n")
rules := make([]any, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rules = append(rules, line)
}
return rules
}
+5 -4
View File
@@ -62,10 +62,11 @@ func NewSUBController(
showInfo bool, showInfo bool,
rModel string, rModel string,
update string, update string,
jsonFragment string,
jsonNoise string,
jsonMux string, jsonMux string,
jsonRules string, jsonRules string,
jsonFinalMask string,
clashEnableRouting bool,
clashRules string,
subTitle string, subTitle string,
subSupportUrl string, subSupportUrl string,
subProfileUrl string, subProfileUrl string,
@@ -90,8 +91,8 @@ func NewSUBController(
updateInterval: update, updateInterval: update,
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(sub), subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
} }
a.initRouter(g) a.initRouter(g)
return a return a
+82 -71
View File
@@ -21,7 +21,7 @@ var defaultJson string
type SubJsonService struct { type SubJsonService struct {
configJson map[string]any configJson map[string]any
defaultOutbounds []json_util.RawMessage defaultOutbounds []json_util.RawMessage
fragmentOrNoises bool finalMask string
mux string mux string
inboundService service.InboundService inboundService service.InboundService
@@ -29,7 +29,7 @@ type SubJsonService struct {
} }
// NewSubJsonService creates a new JSON subscription service with the given configuration. // NewSubJsonService creates a new JSON subscription service with the given configuration.
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService { func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any var configJson map[string]any
var defaultOutbounds []json_util.RawMessage var defaultOutbounds []json_util.RawMessage
json.Unmarshal([]byte(defaultJson), &configJson) json.Unmarshal([]byte(defaultJson), &configJson)
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
} }
} }
fragmentOrNoises := false
if fragment != "" || noises != "" {
fragmentOrNoises = true
defaultOutboundsSettings := map[string]any{
"domainStrategy": "UseIP",
"redirect": "",
}
if fragment != "" {
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
}
if noises != "" {
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
}
defaultDirectOutbound := map[string]any{
"protocol": "freedom",
"settings": defaultOutboundsSettings,
"tag": "direct_out",
}
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
defaultOutbounds = append(defaultOutbounds, jsonBytes)
}
if rules != "" { if rules != "" {
var newRules []any var newRules []any
routing, _ := configJson["routing"].(map[string]any) routing, _ := configJson["routing"].(map[string]any)
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
return &SubJsonService{ return &SubJsonService{
configJson: configJson, configJson: configJson,
defaultOutbounds: defaultOutbounds, defaultOutbounds: defaultOutbounds,
fragmentOrNoises: fragmentOrNoises, finalMask: finalMask,
mux: mux, mux: mux,
SubService: subService, SubService: subService,
} }
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
} }
delete(streamSettings, "sockopt") delete(streamSettings, "sockopt")
if s.fragmentOrNoises { if s.finalMask != "" {
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`) s.applyGlobalFinalMask(streamSettings)
} }
// remove proxy protocol // remove proxy protocol
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
return streamSettings return streamSettings
} }
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
var fm map[string]any
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
return
}
merged := mergeFinalMask(streamSettings["finalmask"], fm)
if len(merged) > 0 {
streamSettings["finalmask"] = merged
}
}
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any { func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
netSettings, ok := setting.(map[string]any) netSettings, ok := setting.(map[string]any)
if ok { if ok {
@@ -272,6 +258,9 @@ func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok { if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
tlsData["fingerprint"] = fingerprint tlsData["fingerprint"] = fingerprint
} }
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
tlsData["echConfigList"] = ech
}
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 { if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
tlsData["pinnedPeerCertSha256"] = pins tlsData["pinnedPeerCertSha256"] = pins
} }
@@ -307,17 +296,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage { func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
outbound := Outbound{} outbound := Outbound{}
usersData := make([]UserVnext, 1)
usersData[0].ID = client.ID
usersData[0].Email = client.Email
usersData[0].Security = client.Security
vnextData := make([]VnextSetting, 1)
vnextData[0] = VnextSetting{
Address: inbound.Listen,
Port: inbound.Port,
Users: usersData,
}
outbound.Protocol = string(inbound.Protocol) outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy" outbound.Tag = "proxy"
@@ -325,8 +303,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
security := client.Security
if security == "" {
security = "auto"
}
outbound.Settings = map[string]any{ outbound.Settings = map[string]any{
"vnext": vnextData, "address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"security": security,
"level": 8,
} }
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
@@ -347,24 +334,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
json.Unmarshal([]byte(inbound.Settings), &inboundSettings) json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
encryption, _ := inboundSettings["encryption"].(string) encryption, _ := inboundSettings["encryption"].(string)
user := map[string]any{ settings := map[string]any{
"id": client.ID,
"level": 8,
"encryption": encryption,
}
if client.Flow != "" {
user["flow"] = client.Flow
}
vnext := map[string]any{
"address": inbound.Listen, "address": inbound.Listen,
"port": inbound.Port, "port": inbound.Port,
"users": []any{user}, "id": client.ID,
"encryption": encryption,
"level": 8,
} }
if client.Flow != "" {
outbound.Settings = map[string]any{ settings["flow"] = client.Flow
"vnext": []any{vnext},
} }
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
} }
@@ -400,9 +380,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
outbound.Settings = map[string]any{
"servers": serverData, settings := map[string]any{
"address": serverData[0].Address,
"port": serverData[0].Port,
"password": serverData[0].Password,
"level": 8,
} }
if inbound.Protocol == model.Shadowsocks {
settings["method"] = serverData[0].Method
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
@@ -442,7 +430,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
newStream["hysteriaSettings"] = outHyStream newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok { if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
newStream["finalmask"] = finalmask newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
} }
newStream["network"] = "hysteria" newStream["network"] = "hysteria"
@@ -454,6 +442,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
return result return result
} }
func mergeFinalMask(base any, extra map[string]any) map[string]any {
merged := map[string]any{}
if baseMap, ok := base.(map[string]any); ok {
for key, value := range baseMap {
switch key {
case "tcp", "udp":
if masks, ok := value.([]any); ok {
merged[key] = append([]any(nil), masks...)
}
default:
merged[key] = value
}
}
}
for key, value := range extra {
switch key {
case "tcp", "udp":
baseMasks, _ := merged[key].([]any)
extraMasks, _ := value.([]any)
if len(extraMasks) > 0 {
merged[key] = append(baseMasks, extraMasks...)
}
case "quicParams":
if _, exists := merged[key]; !exists {
merged[key] = value
}
default:
merged[key] = value
}
}
return merged
}
type Outbound struct { type Outbound struct {
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Tag string `json:"tag"` Tag string `json:"tag"`
@@ -462,18 +485,6 @@ type Outbound struct {
Settings map[string]any `json:"settings,omitempty"` Settings map[string]any `json:"settings,omitempty"`
} }
type VnextSetting struct {
Address string `json:"address"`
Port int `json:"port"`
Users []UserVnext `json:"users"`
}
type UserVnext struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Security string `json:"security,omitempty"`
}
type ServerSetting struct { type ServerSetting struct {
Password string `json:"password"` Password string `json:"password"`
Level int `json:"level"` Level int `json:"level"`
+148
View File
@@ -0,0 +1,148 @@
package sub
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func hasDirectOutOutbound(svc *SubJsonService) bool {
for _, raw := range svc.defaultOutbounds {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
continue
}
if outbound["tag"] == "direct_out" {
return true
}
}
return false
}
func outboundSettings(t *testing.T, raw []byte) map[string]any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal outbound: %v", err)
}
settings, _ := parsed["settings"].(map[string]any)
if settings == nil {
t.Fatal("outbound has no settings")
}
return settings
}
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
}
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
}
finalmask, _ := stream["finalmask"].(map[string]any)
if finalmask == nil {
t.Fatal("streamSettings is missing finalmask")
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
}
udp, _ := finalmask["udp"].([]any)
if len(udp) != 1 {
t.Fatalf("udp masks len = %d, want 1", len(udp))
}
quic, _ := finalmask["quicParams"].(map[string]any)
if quic == nil || quic["congestion"] != "bbr" {
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
}
}
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, nil)
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]}
}`)
finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 2 {
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
}
a, _ := tcp[0].(map[string]any)
b, _ := tcp[1].(map[string]any)
if a["type"] != "sudoku" || b["type"] != "fragment" {
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
}
}
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
}
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out sockopt must never be set")
}
}
func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext")
}
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
t.Fatalf("flat vless settings wrong: %#v", settings)
}
}
func TestSubJsonServiceVmessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
client := model.Client{ID: "uuid-2"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext")
}
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
t.Fatalf("flat vmess settings wrong: %#v", settings)
}
}
func TestSubJsonServiceServerFlattened(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
if _, ok := settings["servers"]; ok {
t.Fatal("trojan outbound must not use servers array")
}
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
t.Fatalf("flat trojan settings wrong: %#v", settings)
}
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
if ssSettings["method"] != "aes-256-gcm" {
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
}
}
+50
View File
@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
return ip != nil && ip.IsLoopback() return ip != nil && ip.IsLoopback()
} }
// listenIsInternalOnly reports whether a bind address is reachable only from
// the same host — a loopback IP or a unix-domain socket. Such an inbound can't
// be dialed directly by a remote client, so when it is the child side of a
// fallback its share link must be projected through the master. A public or
// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises
// itself.
func listenIsInternalOnly(listen string) bool {
if listen == "" {
return false
}
if listen[0] == '@' || listen[0] == '/' {
return true
}
return isLoopbackHost(listen)
}
// GetSubs retrieves subscription links for a given subscription ID and host. // GetSubs retrieves subscription links for a given subscription ID and host.
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) { func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
s.PrepareForRequest(host) s.PrepareForRequest(host)
@@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
// Returns true when a projection happened; sub services call this before // Returns true when a projection happened; sub services call this before
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the // generating links so a child VLESS-WS bound to 127.0.0.1 emits the
// master's :443 + TLS state instead of its own loopback endpoint. // master's :443 + TLS state instead of its own loopback endpoint.
//
// Projection only applies to a child that is not directly reachable on its
// own listen (loopback or a unix-domain socket). An inbound on a public or
// wildcard listen is reachable on its own port, so it advertises its own
// port + security even when a stale fallback rule still names it as a child —
// otherwise its share link would leak the master's port and Reality/TLS
// settings (#4987).
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool { func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
if inbound == nil { if inbound == nil {
return false return false
} }
if !listenIsInternalOnly(inbound.Listen) {
return false
}
db := database.GetDB() db := database.GetDB()
var master *model.Inbound var master *model.Inbound
@@ -894,6 +920,11 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
params["fp"], _ = fpValue.(string) params["fp"], _ = fpValue.(string)
} }
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
if ech, _ := echValue.(string); ech != "" {
params["ech"] = ech
}
}
if pins, ok := pinnedSha256List(tlsSettings); ok { if pins, ok := pinnedSha256List(tlsSettings); ok {
params["pcs"] = strings.Join(pins, ",") params["pcs"] = strings.Join(pins, ",")
} }
@@ -919,6 +950,11 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok {
obj["fp"], _ = fpValue.(string) obj["fp"], _ = fpValue.(string)
} }
if echValue, ok := searchKey(tlsSettings, "echConfigList"); ok {
if ech, _ := echValue.(string); ech != "" {
obj["ech"] = ech
}
}
if pins, ok := pinnedSha256List(tlsSettings); ok { if pins, ok := pinnedSha256List(tlsSettings); ok {
obj["pcs"] = strings.Join(pins, ",") obj["pcs"] = strings.Join(pins, ",")
} }
@@ -1043,6 +1079,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
obj["pcs"] = joinAnyStrings(pins) obj["pcs"] = joinAnyStrings(pins)
} }
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
obj["ech"] = ech
}
} }
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) { func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
@@ -1061,6 +1100,9 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok { if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
params["pcs"] = joinAnyStrings(pins) params["pcs"] = joinAnyStrings(pins)
} }
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
params["ech"] = ech
}
} }
// applyExternalProxyHysteriaParams overrides the cert pin for a single // applyExternalProxyHysteriaParams overrides the cert pin for a single
@@ -1133,6 +1175,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
} }
settings["pinnedPeerCertSha256"] = pins settings["pinnedPeerCertSha256"] = pins
} }
if ech, ok := ep["echConfigList"].(string); ok && ech != "" {
settings, _ := tlsSettings["settings"].(map[string]any)
if settings == nil {
settings = map[string]any{}
tlsSettings["settings"] = settings
}
settings["echConfigList"] = ech
}
} }
func externalProxySNI(ep map[string]any) (string, bool) { func externalProxySNI(ep map[string]any) (string, bool) {
+60
View File
@@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) {
} }
} }
func TestListenIsInternalOnly(t *testing.T) {
// Reachable only from the same host -> a fallback child here must be
// projected through its master.
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
for _, v := range internalOnly {
if !listenIsInternalOnly(v) {
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
}
}
// Directly reachable on its own port -> never projected, even if a stale
// fallback rule names it as a child (#4987).
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
for _, v := range reachable {
if listenIsInternalOnly(v) {
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
}
}
}
func TestResolveInboundAddress(t *testing.T) { func TestResolveInboundAddress(t *testing.T) {
const reqHost = "sub.example.com" const reqHost = "sub.example.com"
@@ -575,6 +594,47 @@ func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T)
} }
} }
func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
const ech = "ech-config-base64"
t.Run("url params", func(t *testing.T) {
params := map[string]string{"security": "tls"}
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
applyExternalProxyTLSParams(ep, params, "tls")
if params["ech"] != ech {
t.Fatalf("ech param = %q, want %q", params["ech"], ech)
}
})
t.Run("vmess obj", func(t *testing.T) {
obj := map[string]any{}
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
applyExternalProxyTLSObj(ep, obj, "tls")
if obj["ech"] != ech {
t.Fatalf("ech obj = %v, want %q", obj["ech"], ech)
}
})
t.Run("json stream settings", func(t *testing.T) {
stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
applyExternalProxyTLSToStream(ep, stream, "tls")
settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
if settings["echConfigList"] != ech {
t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
}
})
t.Run("non-tls security drops ech", func(t *testing.T) {
params := map[string]string{}
ep := map[string]any{"echConfigList": ech}
applyExternalProxyTLSParams(ep, params, "none")
if _, ok := params["ech"]; ok {
t.Fatalf("ech must not be set when security != tls")
}
})
}
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) { func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
stream := map[string]any{ stream := map[string]any{
"security": "tls", "security": "tls",
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
aliasByName := make(map[string]Alias, len(aliases))
for _, a := range aliases {
aliasByName[a.Name] = a
}
gen := &exampleGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.forSchema(s, map[string]bool{})
}
payload, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type exampleGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
obj := make(map[string]any, len(s.Fields))
for _, f := range s.Fields {
obj[f.JSONName] = g.forField(f, visited)
}
return obj
}
func (g *exampleGen) forField(f Field, visited map[string]bool) any {
if f.Example != "" {
return coerceExample(f.Example, baseKind(f.Type))
}
if v, ok := firstOneOf(f.Validate); ok {
return v
}
bk := baseKind(f.Type)
if bk.Kind == KindInt || bk.Kind == KindNumber {
if v, ok := numericFloor(bk.Kind, f.Validate); ok {
return v
}
}
return g.forType(f.Type, visited)
}
func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return "2025-01-01T00:00:00Z"
}
return ""
case KindInt, KindNumber:
return 0
case KindBool:
return false
case KindArray:
if isVisitedRef(*t.Element, visited) {
return []any{}
}
return []any{g.forType(*t.Element, visited)}
case KindMap:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
return nil
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.forType(alias.Underlying, visited)
}
schema, ok := g.byName[t.Name]
if !ok || visited[t.Name] {
return map[string]any{}
}
next := cloneVisited(visited)
next[t.Name] = true
return g.forSchema(schema, next)
}
return nil
}
func baseKind(t TypeRef) TypeRef {
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
return *t.Inner
}
return t
}
func isVisitedRef(t TypeRef, visited map[string]bool) bool {
return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
}
func cloneVisited(in map[string]bool) map[string]bool {
out := make(map[string]bool, len(in)+1)
for k, v := range in {
out[k] = v
}
return out
}
func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
for _, r := range rules {
if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
return coerceExample(r.Param, TypeRef{Kind: kind}), true
}
}
return nil, false
}
func firstOneOf(rules []ValidateRule) (string, bool) {
for _, r := range rules {
if r.Name == "oneof" {
fields := strings.Fields(r.Param)
if len(fields) > 0 {
return fields[0], true
}
}
}
return "", false
}
func coerceExample(ex string, t TypeRef) any {
switch t.Kind {
case KindInt:
if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
return n
}
return 0
case KindNumber:
if n, err := strconv.ParseFloat(ex, 64); err == nil {
return n
}
return 0
case KindBool:
return ex == "true"
case KindString:
return ex
default:
var parsed any
if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
return parsed
}
return ex
}
}
const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
)
func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
aliasByName := make(map[string]Alias, len(aliases))
for _, a := range aliases {
aliasByName[a.Name] = a
}
gen := &schemaGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.objectSchema(s)
}
payload, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type schemaGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *schemaGen) objectSchema(s Schema) map[string]any {
props := make(map[string]any, len(s.Fields))
var required []string
for _, f := range s.Fields {
props[f.JSONName] = g.fieldSchema(f)
if !f.Optional {
required = append(required, f.JSONName)
}
}
obj := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
sort.Strings(required)
obj["required"] = required
}
if s.Doc != "" {
obj["description"] = s.Doc
}
return obj
}
func (g *schemaGen) fieldSchema(f Field) map[string]any {
sch := g.typeSchema(f.Type)
if ref, ok := sch["$ref"]; ok {
if f.Doc == "" && f.Example == "" {
return sch
}
wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
if f.Doc != "" {
wrap["description"] = f.Doc
}
if f.Example != "" {
wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
}
return wrap
}
applyConstraints(sch, f.Type, f.Validate)
if f.Doc != "" {
sch["description"] = f.Doc
}
if f.Example != "" {
sch["example"] = coerceExample(f.Example, baseKind(f.Type))
}
return sch
}
func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return map[string]any{"type": "string", "format": "date-time"}
}
return map[string]any{"type": "string"}
case KindInt:
return map[string]any{"type": "integer"}
case KindNumber:
return map[string]any{"type": "number"}
case KindBool:
return map[string]any{"type": "boolean"}
case KindArray:
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
case KindMap:
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
case KindAny, KindUnknown, KindRaw:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
inner := g.typeSchema(*t.Inner)
if ref, ok := inner["$ref"]; ok {
return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
}
inner["nullable"] = true
return inner
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.typeSchema(alias.Underlying)
}
if _, ok := g.byName[t.Name]; ok {
return map[string]any{"$ref": "#/components/schemas/" + t.Name}
}
return map[string]any{}
}
return map[string]any{}
}
func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
base := baseKind(t)
numeric := base.Kind == KindInt || base.Kind == KindNumber
str := base.Kind == KindString
for _, r := range rules {
switch r.Name {
case "gte":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
}
case "lte":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
}
case "gt":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
sch["exclusiveMinimum"] = true
}
case "lt":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
sch["exclusiveMaximum"] = true
}
case "min":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
} else if str {
if n, err := strconv.Atoi(r.Param); err == nil {
sch["minLength"] = n
}
}
case "max":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
} else if str {
if n, err := strconv.Atoi(r.Param); err == nil {
sch["maxLength"] = n
}
}
case "oneof":
vals := strings.Fields(r.Param)
if len(vals) > 0 {
enum := make([]any, len(vals))
for i, v := range vals {
enum[i] = v
}
sch["enum"] = enum
}
case "email":
if str {
sch["format"] = "email"
}
case "url":
if str {
sch["format"] = "uri"
}
}
}
}
+22
View File
@@ -69,6 +69,14 @@ func run(root, outDir string) error {
"ClientTraffic", "ClientTraffic",
), ),
}, },
{
Path: resolveRel(root, "web/service"),
StructAllow: setOf(
"InboundOption",
"ApiTokenView",
"ProbeResultUI",
),
},
} }
schemas, aliases, err := walkPackages(requests) schemas, aliases, err := walkPackages(requests)
@@ -94,6 +102,14 @@ func run(root, outDir string) error {
if err := emitTypes(typesBuf, schemas, aliases); err != nil { if err := emitTypes(typesBuf, schemas, aliases); err != nil {
return err return err
} }
examplesBuf := &bytes.Buffer{}
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
return err
}
schemasBuf := &bytes.Buffer{}
if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil { if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
return err return err
@@ -101,6 +117,12 @@ func run(root, outDir string) error {
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil { if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
return err return err
} }
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
return err
}
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target) fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
return nil return nil
+3 -1
View File
@@ -27,6 +27,7 @@ type Field struct {
Skip bool Skip bool
Validate []ValidateRule Validate []ValidateRule
Doc string Doc string
Example string
} }
type TypeRef struct { type TypeRef struct {
@@ -59,10 +60,11 @@ type ValidateRule struct {
Param string Param string
} }
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) { func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
tag := reflect.StructTag(strings.Trim(raw, "`")) tag := reflect.StructTag(strings.Trim(raw, "`"))
json = tag.Get("json") json = tag.Get("json")
validate = tag.Get("validate") validate = tag.Get("validate")
example = tag.Get("example")
if g := tag.Get("gorm"); g != "" { if g := tag.Get("gorm"); g != "" {
for part := range strings.SplitSeq(g, ";") { for part := range strings.SplitSeq(g, ";") {
if strings.TrimSpace(part) == "-" { if strings.TrimSpace(part) == "-" {
+3 -1
View File
@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
if fld.Tag != nil { if fld.Tag != nil {
tag = fld.Tag.Value tag = fld.Tag.Value
} }
jsonTag, validateTag, gormDash := parseStructTag(tag) jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
if gormDash && jsonTag == "" { if gormDash && jsonTag == "" {
return nil return nil
} }
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type), Optional: omitempty || isPointer(fld.Type),
Validate: validate, Validate: validate,
Doc: doc, Doc: doc,
Example: exampleTag,
}) })
} }
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type), Optional: omitempty || isPointer(fld.Type),
Validate: validate, Validate: validate,
Doc: doc, Doc: doc,
Example: exampleTag,
}) })
} }
+39 -2
View File
@@ -582,12 +582,14 @@ prompt_and_setup_ssl() {
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)" echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)" echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)" echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths." echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
read -rp "Choose an option (default 2 for IP): " ssl_choice read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3) # Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
ssl_choice="2" ssl_choice="2"
fi fi
@@ -706,6 +708,41 @@ prompt_and_setup_ssl() {
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1 systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
;; ;;
4)
echo ""
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
echo -e "${yellow}Only safe when:${plain}"
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
echo ""
SSL_SCHEME="http"
SSL_HOST="${server_ip}"
local bind_local=""
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
SSL_HOST="127.0.0.1"
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
echo ""
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
echo -e " Standard SSH command:"
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
echo -e " If using an SSH key:"
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
echo -e " Then open in your browser:"
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
echo ""
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
else
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
fi
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
echo -e "${green}✓ SSL setup skipped.${plain}"
;;
*) *)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}" echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}" SSL_HOST="${server_ip}"
+8
View File
@@ -17,6 +17,8 @@ type APIController struct {
inboundController *InboundController inboundController *InboundController
serverController *ServerController serverController *ServerController
nodeController *NodeController nodeController *NodeController
settingController *SettingController
xraySettingController *XraySettingController
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
apiTokenService service.ApiTokenService apiTokenService service.ApiTokenService
@@ -79,6 +81,12 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
NewCustomGeoController(api.Group("/custom-geo"), customGeo) NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Settings + Xray config management live under the API surface too, so the
// same API token drives them. Paths are /panel/api/setting/* and
// /panel/api/xray/*.
a.settingController = NewSettingController(api)
a.xraySettingController = NewXraySettingController(api)
// Extra routes // Extra routes
api.POST("/backuptotgbot", a.BackuptoTgbot) api.POST("/backuptotgbot", a.BackuptoTgbot)
} }
+2 -2
View File
@@ -96,9 +96,9 @@ func TestAPIRoutesDocumented(t *testing.T) {
case "node.go": case "node.go":
basePath = "/panel/api/nodes" basePath = "/panel/api/nodes"
case "setting.go": case "setting.go":
basePath = "/panel/setting" basePath = "/panel/api/setting"
case "xray_setting.go": case "xray_setting.go":
basePath = "/panel/xray" basePath = "/panel/api/xray"
case "custom_geo.go": case "custom_geo.go":
basePath = "/panel/api/custom-geo" basePath = "/panel/api/custom-geo"
case "websocket.go": case "websocket.go":
+27 -9
View File
@@ -3,6 +3,8 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
@@ -16,6 +18,21 @@ func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients) websocket.BroadcastInvalidate(websocket.MessageTypeClients)
} }
func parseInboundIdsQuery(raw string) []int {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
ids = append(ids, id)
}
}
return ids
}
type ClientController struct { type ClientController struct {
clientService service.ClientService clientService service.ClientService
inboundService service.InboundService inboundService service.InboundService
@@ -55,7 +72,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/ips/:email", a.getIps) g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps) g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines) g.POST("/onlines", a.onlines)
g.POST("/onlinesByNode", a.onlinesByNode) g.POST("/onlinesByGuid", a.onlinesByGuid)
g.POST("/activeInbounds", a.activeInbounds) g.POST("/activeInbounds", a.activeInbounds)
g.POST("/lastOnline", a.lastOnline) g.POST("/lastOnline", a.lastOnline)
} }
@@ -115,7 +132,7 @@ func (a *ClientController) create(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
@@ -129,12 +146,13 @@ func (a *ClientController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated) inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
@@ -172,7 +190,7 @@ func (a *ClientController) attach(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
@@ -399,12 +417,12 @@ func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil) jsonObj(c, a.inboundService.GetOnlineClients(), nil)
} }
func (a *ClientController) onlinesByNode(c *gin.Context) { func (a *ClientController) onlinesByGuid(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil) jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
} }
func (a *ClientController) activeInbounds(c *gin.Context) { func (a *ClientController) activeInbounds(c *gin.Context) {
jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil) jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
} }
func (a *ClientController) lastOnline(c *gin.Context) { func (a *ClientController) lastOnline(c *gin.Context) {
@@ -452,7 +470,7 @@ func (a *ClientController) detach(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil) jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
if needRestart { if needRestart {
a.xrayService.SetToNeedRestart() a.xrayService.SetToNeedRestart()
} }
+33
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"bytes" "bytes"
"embed" "embed"
"encoding/json"
htmlpkg "html" htmlpkg "html"
"net/http" "net/http"
"strings" "strings"
@@ -34,10 +35,42 @@ func ServeOpenAPISpec(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"}) c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return return
} }
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
// under a non-root web base path, Swagger UI "Try it out" and external
// generators must target that prefix, so rewrite the single server entry to
// the runtime base path before serving.
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
logger.Warning("openapi.json: could not inject base path:", err)
} else {
body = rebuilt
}
}
c.Header("Cache-Control", "public, max-age=300") c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", body) c.Data(http.StatusOK, "application/json; charset=utf-8", body)
} }
// withServerBasePath rewrites the spec's `servers` entry so requests target the
// panel's configured web base path. Only the top-level `servers` field is
// replaced; every other field is preserved verbatim via json.RawMessage.
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
var doc map[string]json.RawMessage
if err := json.Unmarshal(spec, &doc); err != nil {
return nil, err
}
servers, err := json.Marshal([]map[string]string{{
"url": strings.TrimSuffix(basePath, "/"),
"description": "Current panel",
}})
if err != nil {
return nil, err
}
doc["servers"] = servers
return json.Marshal(doc)
}
func serveDistPage(c *gin.Context, name string) { func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name) body, err := distFS.ReadFile("dist/" + name)
if err != nil { if err != nil {
+42
View File
@@ -0,0 +1,42 @@
package controller
import (
"encoding/json"
"testing"
)
func TestWithServerBasePath(t *testing.T) {
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
out, err := withServerBasePath(spec, "/test/")
if err != nil {
t.Fatalf("withServerBasePath: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
servers, ok := doc["servers"].([]any)
if !ok || len(servers) != 1 {
t.Fatalf("servers = %v, want one entry", doc["servers"])
}
srv, _ := servers[0].(map[string]any)
if srv["url"] != "/test" {
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
}
if doc["openapi"] != "3.0.3" {
t.Errorf("openapi field not preserved: %v", doc["openapi"])
}
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
t.Errorf("paths content not preserved verbatim")
}
}
func TestWithServerBasePathInvalidJSON(t *testing.T) {
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
t.Errorf("expected error on invalid spec, got nil")
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
} }
func (a *NodeController) list(c *gin.Context) { func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetAll() nodes, err := a.nodeService.GetNodeTree()
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err) jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return return
+28
View File
@@ -53,8 +53,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson) g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb) g.GET("/getDb", a.getDb)
g.GET("/getMigration", a.getMigration)
g.GET("/getNewUUID", a.getNewUUID) g.GET("/getNewUUID", a.getNewUUID)
g.GET("/getWebCertFiles", a.getWebCertFiles) g.GET("/getWebCertFiles", a.getWebCertFiles)
g.GET("/descendants", a.descendants)
g.GET("/getNewX25519Cert", a.getNewX25519Cert) g.GET("/getNewX25519Cert", a.getNewX25519Cert)
g.GET("/getNewmldsa65", a.getNewmldsa65) g.GET("/getNewmldsa65", a.getNewmldsa65)
g.GET("/getNewmlkem768", a.getNewmlkem768) g.GET("/getNewmlkem768", a.getNewmlkem768)
@@ -300,6 +302,24 @@ func (a *ServerController) getDb(c *gin.Context) {
c.Writer.Write(db) c.Writer.Write(db)
} }
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
func (a *ServerController) getMigration(c *gin.Context) {
data, filename, err := a.serverService.GetMigration()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
return
}
if !filenameRegex.MatchString(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Writer.Write(data)
}
// importDB imports a database file and restarts the Xray service. // importDB imports a database file and restarts the Xray service.
func (a *ServerController) importDB(c *gin.Context) { func (a *ServerController) importDB(c *gin.Context) {
file, _, err := c.Request.FormFile("db") file, _, err := c.Request.FormFile("db")
@@ -315,6 +335,14 @@ func (a *ServerController) importDB(c *gin.Context) {
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil) jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
} }
// descendants publishes read-only summaries of the nodes this panel manages so
// a parent panel can surface them as transitive sub-nodes in a chained
// topology. Called by the parent via the node's API token (#4983).
func (a *ServerController) descendants(c *gin.Context) {
data, err := (&service.NodeService{}).LocalDescendants()
jsonObj(c, data, err)
}
// getWebCertFiles returns this panel's own web TLS certificate and key file // getWebCertFiles returns this panel's own web TLS certificate and key file
// paths. The central panel calls it on a node (via the node's API token) so // paths. The central panel calls it on a node (via the node's API token) so
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist // "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
+10
View File
@@ -182,6 +182,16 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
c.JSON(http.StatusOK, m) c.JSON(http.StatusOK, m)
} }
// pendingNodeObj returns a response object flagging that the save committed
// locally but a backing node was offline/disabled, so the change will be
// mirrored to the node once it reconnects. Returns nil when nothing is pending.
func pendingNodeObj(pending bool) any {
if pending {
return gin.H{"nodePending": true}
}
return nil
}
// pureJsonMsg sends a pure JSON message response with custom status code. // pureJsonMsg sends a pure JSON message response with custom status code.
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) { func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
c.JSON(statusCode, entity.Msg{ c.JSON(statusCode, entity.Msg{
+1 -7
View File
@@ -10,12 +10,9 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// XUIController is the main controller for the X-UI panel, managing sub-controllers. // XUIController is the main controller for the X-UI panel, serving the SPA shell.
type XUIController struct { type XUIController struct {
BaseController BaseController
settingController *SettingController
xraySettingController *XraySettingController
} }
// NewXUIController creates a new XUIController and initializes its routes. // NewXUIController creates a new XUIController and initializes its routes.
@@ -49,9 +46,6 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
// so they fetch the session token via this endpoint at startup and replay it // so they fetch the session token via this endpoint at startup and replay it
// on subsequent unsafe requests through axios. // on subsequent unsafe requests through axios.
g.GET("/csrf-token", a.csrfToken) g.GET("/csrf-token", a.csrfToken)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
} }
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an // panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
+3 -2
View File
@@ -83,10 +83,11 @@ type AllSetting struct {
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
// LDAP settings // LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
+29 -8
View File
@@ -17,6 +17,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray" "github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm"
) )
// IPWithTimestamp tracks an IP address with its last seen timestamp // IPWithTimestamp tracks an IP address with its last seen timestamp
@@ -184,6 +186,22 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
shouldCleanLog := false shouldCleanLog := false
for email, ipTimestamps := range inboundClientIps { for email, ipTimestamps := range inboundClientIps {
// The access log can still reference a client that was just renamed
// or deleted; its email no longer matches any inbound. Skip it (and
// drop any orphaned tracking row) instead of recreating a row and
// logging an ERROR every run until the log rotates out the old email
// (#4963).
inbound, err := j.getInboundByEmail(email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
j.delInboundClientIps(email)
} else {
j.checkError(err)
}
continue
}
// Convert to IPWithTimestamp slice // Convert to IPWithTimestamp slice
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps)) ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
for ip, timestamp := range ipTimestamps { for ip, timestamp := range ipTimestamps {
@@ -196,7 +214,7 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
continue continue
} }
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
} }
return shouldCleanLog return shouldCleanLog
@@ -311,14 +329,17 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
return nil return nil
} }
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool { // delInboundClientIps drops the inbound_client_ips tracking row for an email
// Get the inbound configuration // that no longer maps to any inbound (a renamed or deleted client), so stale
inbound, err := j.getInboundByEmail(clientEmail) // access-log entries don't keep a ghost row alive (#4963).
if err != nil { func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err) db := database.GetDB()
return false if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
j.checkError(err)
} }
}
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
if inbound.Settings == "" { if inbound.Settings == "" {
logger.Debug("wrong data:", inbound) logger.Debug("wrong data:", inbound)
return false return false
@@ -411,7 +432,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
inboundClientIps.Ips = string(jsonIps) inboundClientIps.Ips = string(jsonIps)
db := database.GetDB() db := database.GetDB()
err = db.Save(inboundClientIps).Error err := db.Save(inboundClientIps).Error
if err != nil { if err != nil {
logger.Error("failed to save inboundClientIps:", err) logger.Error("failed to save inboundClientIps:", err)
return false return false
@@ -195,7 +195,11 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
{IP: "128.71.1.1", Timestamp: now}, {IP: "128.71.1.1", Timestamp: now},
} }
shouldCleanLog := j.updateInboundClientIps(row, email, live, true) inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
if shouldCleanLog { if shouldCleanLog {
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3") t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
@@ -244,7 +248,11 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
{IP: "192.0.2.9", Timestamp: now}, {IP: "192.0.2.9", Timestamp: now},
} }
shouldCleanLog := j.updateInboundClientIps(row, email, live, true) inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
if !shouldCleanLog { if !shouldCleanLog {
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit") t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
@@ -321,6 +329,32 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
} }
} }
// #4963: a stale access-log entry for a renamed/deleted client (its email no
// longer maps to any inbound) must not create or resurrect an
// inbound_client_ips row, and must drop any orphan left behind — instead of
// spamming "failed to fetch inbound settings" every run.
func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
fakeFail2BanClient(t)
const staleEmail = "renamed-away"
// No inbound references staleEmail. Pre-seed an orphan tracking row to
// confirm the job removes it rather than leaving it to error forever.
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
writeXrayAccessLog(t, staleEmail, "203.0.113.5")
NewCheckClientIpJob().Run()
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
t.Fatalf("count InboundClientIps: %v", err)
}
if count != 0 {
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
}
}
// readIpLimitLogPath reads the 3xipl.log path the same way the job // readIpLimitLogPath reads the 3xipl.log path the same way the job
// does via xray.GetIPLimitLogPath but without importing xray here // does via xray.GetIPLimitLogPath but without importing xray here
// just for the path helper (which would pull a lot more deps into the // just for the path helper (which would pull a lot more deps into the
+11 -1
View File
@@ -59,7 +59,7 @@ func (j *NodeHeartbeatJob) Run() {
if !websocket.HasClients() { if !websocket.HasClients() {
return return
} }
updated, err := j.nodeService.GetAll() updated, err := j.nodeService.GetNodeTree()
if err != nil { if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err) logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return return
@@ -79,4 +79,14 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil { if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr) logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
} }
// Learn the nodes this node manages so the panel can surface them as
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
// be spent. Drop them when the node is unreachable.
if patch.Status == "online" {
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
j.nodeService.RefreshDescendants(dctx, n)
dcancel()
} else {
j.nodeService.ClearDescendants(n.Id)
}
} }
+23 -6
View File
@@ -15,6 +15,7 @@ import (
const ( const (
nodeTrafficSyncConcurrency = 8 nodeTrafficSyncConcurrency = 8
nodeTrafficSyncRequestTimeout = 4 * time.Second nodeTrafficSyncRequestTimeout = 4 * time.Second
nodeReconcileTimeout = 30 * time.Second
) )
type NodeTrafficSyncJob struct { type NodeTrafficSyncJob struct {
@@ -124,8 +125,8 @@ func (j *NodeTrafficSyncJob) Run() {
} }
websocket.BroadcastTraffic(map[string]any{ websocket.BroadcastTraffic(map[string]any{
"onlineClients": online, "onlineClients": online,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(), "onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(), "activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnline, "lastOnlineMap": lastOnline,
}) })
@@ -151,21 +152,37 @@ func (j *NodeTrafficSyncJob) Run() {
} }
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) { func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
defer cancel()
rt, err := mgr.RemoteFor(n) rt, err := mgr.RemoteFor(n)
if err != nil { if err != nil {
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err) logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
return return
} }
if n.ConfigDirty {
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
reconcileCancel()
if reconcileErr != nil {
logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
return
}
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
}
j.structural.set()
}
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
defer cancel()
snap, err := rt.FetchTrafficSnapshot(ctx) snap, err := rt.FetchTrafficSnapshot(ctx)
if err != nil { if err != nil {
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err) logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
j.inboundService.ClearNodeOnlineClients(n.Id) j.inboundService.ClearNodeOnlineClients(n.Id)
return return
} }
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap) _, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
if err != nil { if err != nil {
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err) logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
return return
+2 -2
View File
@@ -107,8 +107,8 @@ func (j *XrayTrafficJob) Run() {
"traffics": traffics, "traffics": traffics,
"clientTraffics": clientTraffics, "clientTraffics": clientTraffics,
"onlineClients": onlineClients, "onlineClients": onlineClients,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(), "onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(), "activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnlineMap, "lastOnlineMap": lastOnlineMap,
}) })
+61 -9
View File
@@ -191,6 +191,19 @@ func (r *Remote) cacheDel(tag string) {
delete(r.remoteIDByTag, tag) delete(r.remoteIDByTag, tag)
} }
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
if err := r.refreshRemoteIDs(ctx); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
tags := make([]string, 0, len(r.remoteIDByTag))
for tag := range r.remoteIDByTag {
tags = append(tags, tag)
}
return tags, nil
}
func (r *Remote) refreshRemoteIDs(ctx context.Context) error { func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil) env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
if err != nil { if err != nil {
@@ -286,15 +299,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
return nil return nil
} }
// DeleteUser is idempotent: master's per-inbound Delete loop may call it func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
// multiple times for the same node, and "not found" on the follow-ups is
// the expected success path.
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
if email == "" { if email == "" {
return nil return nil
} }
_, err := r.do(ctx, http.MethodPost, id, err := r.resolveRemoteID(ctx, ib.Tag)
"panel/api/clients/del/"+url.PathEscape(email), nil) if err != nil {
return nil
}
body := map[string]any{"inboundIds": []int{id}}
_, err = r.do(ctx, http.MethodPost,
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
if err == nil { if err == nil {
return nil return nil
} }
@@ -304,12 +319,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
return err return err
} }
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error { func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" { if oldEmail == "" {
oldEmail = payload.Email oldEmail = payload.Email
} }
if _, err := r.do(ctx, http.MethodPost, id, err := r.resolveRemoteID(ctx, ib.Tag)
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil { if err != nil {
return err
}
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
"?inboundIds=" + strconv.Itoa(id)
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
return err return err
} }
return nil return nil
@@ -350,6 +370,24 @@ func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
return &files, nil return &files, nil
} }
// GetDescendants fetches the node's read-only summaries of the nodes IT
// manages, so this panel can surface them as transitive sub-nodes in a chained
// topology (#4983). Best-effort: an old-build node without the endpoint returns
// an error the caller ignores.
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
if err != nil {
return nil, err
}
var out []model.NodeSummary
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &out); err != nil {
return nil, fmt.Errorf("decode descendants: %w", err)
}
}
return out, nil
}
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error { func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
_, err := r.do(ctx, http.MethodPost, _, err := r.do(ctx, http.MethodPost,
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil) "panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
@@ -364,6 +402,12 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
type TrafficSnapshot struct { type TrafficSnapshot struct {
Inbounds []*model.Inbound Inbounds []*model.Inbound
OnlineEmails []string OnlineEmails []string
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
// its panelGuid plus every descendant under theirs). Preferred over the flat
// OnlineEmails so the master can attribute deeply nested clients to the real
// node across a chain (#4983). Empty when the node is an old build without
// the per-GUID endpoint — OnlineEmails is the fallback then.
OnlineTree map[string][]string
LastOnlineMap map[string]int64 LastOnlineMap map[string]int64
} }
@@ -378,12 +422,20 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
return nil, fmt.Errorf("decode inbound list: %w", err) return nil, fmt.Errorf("decode inbound list: %w", err)
} }
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
// node is an old build without the per-GUID endpoint (#4983).
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
if err == nil && len(envTree.Obj) > 0 {
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
}
if len(snap.OnlineTree) == 0 {
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil) envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil { if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err) logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 { } else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails) _ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
} }
}
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil) envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
if err != nil { if err != nil {
+216
View File
@@ -0,0 +1,216 @@
package service
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray"
"github.com/op/go-logging"
)
func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
t.Helper()
db := database.GetDB()
rows := make([]xray.ClientTraffic, len(clients))
for i := range clients {
rows[i] = xray.ClientTraffic{
InboundId: inboundId,
Email: clients[i].Email,
Enable: true,
Total: clients[i].TotalGB,
ExpiryTime: clients[i].ExpiryTime,
}
}
if err := db.CreateInBatches(rows, 1000).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
}
// TestAllAPIsPostgresScale exercises every client/inbound/group service method
// reachable from the REST API at 100k/200k clients, asserting none crash on the
// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
func TestAllAPIsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
settingSvc := &SettingService{}
const userId = 1
const m = 2000
sizes := []int{50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(ib2).Error; err != nil {
t.Fatalf("create inbound2: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
run := func(name string, fn func() error) {
start := time.Now()
if err := fn(); err != nil {
t.Fatalf("%s: %v", name, err)
}
t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
}
run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
emails := make([]string, n)
for i := 0; i < n; i++ {
emails[i] = clients[i].Email
}
emailsM := emails[:m]
run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
run("ListPaged+search", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
return err
})
run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
run("UpdateByEmail", func() error {
upd := clients[n/3]
upd.Comment = "touched"
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
return err
})
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
depEmails := emails[:1000]
for _, batch := range chunkStrings(depEmails, sqlInChunk) {
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
t.Fatalf("mark depleted: %v", err)
}
}
run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
})
}
}
// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
// find one client) vs new path (UUID/subId read from the indexed clients table).
func TestGetClientTrafficByEmailABScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
const reps = 10
sizes := []int{50000, 100000, 200000}
oldImpl := func(email string) error {
tr, client, err := inboundSvc.GetClientByEmail(email)
if err != nil {
return err
}
if tr != nil && client != nil {
tr.UUID = client.ID
tr.SubId = client.SubID
}
return nil
}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
start := time.Now()
for i := 0; i < reps; i++ {
if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
t.Fatalf("new GetClientTrafficByEmail: %v", err)
}
}
newDur := time.Since(start) / reps
start = time.Now()
for i := 0; i < reps; i++ {
if err := oldImpl(targets[i%len(targets)]); err != nil {
t.Fatalf("old GetClientTrafficByEmail: %v", err)
}
}
oldDur := time.Since(start) / reps
t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
})
}
}

Some files were not shown because too many files have changed in this diff Show More