Compare commits

...

50 Commits

Author SHA1 Message Date
MHSanaei 340d0df9fc fix(sub): wrap JSON-subscription SS/Trojan outbound in servers[] array
The flat top-level address/method/password form only parses on recent
xray-core; older bundled cores (e.g. in v2rayN) reject it. Restore the standard
"servers" array used through 2.9.x so the JSON subscription connects across all
xray-core versions. VMess/VLESS keep the flat vnext fallback, which is long
established in xray-core.
2026-06-17 14:11:44 +02:00
MHSanaei 982595968d fix(inbound): regenerate SS-2022 client PSKs on method key-size change
Switching a Shadowsocks-2022 inbound between ciphers of different key sizes
(e.g. aes-256 <-> aes-128) resized the server PSK but left existing client PSKs
at the old length. xray rejects a wrong-length uPSK, so links stopped
connecting. Regenerate mismatched client keys on inbound add/update, mirroring
the single-client form's existing self-heal. Affected clients must re-subscribe.
2026-06-17 14:11:35 +02:00
MHSanaei 21e9b94bb4 fix(sub): emit Shadowsocks http-header links as SIP002 obfs-local plugin
v2rayN's SS parser only reads the SIP002 `plugin` query param; it ignores the
xray-native type/headerType/host/path, so an SS link with a TCP http header
imported as plain SS and failed to connect. Re-encode the http header as
`plugin=obfs-local;obfs=http;obfs-host=<host>`, which v2rayN maps to an
xray tcp/http-header outbound. Mirrored in the frontend link generator.

Note: v2rayN carries only the host and forces request path "/", so this matches
an inbound whose header path is "/" (the default); xray validates path, not host.
2026-06-17 14:11:25 +02:00
MHSanaei 5038fa1cec i18n: sync 12 locales with en-US — add missing Hosts/subscription keys 2026-06-17 12:19:05 +02:00
Sanaei 709b332d17 feat(hosts): managed Hosts for per-host subscription link overrides (#5409)
* test(sub): characterize current link output (externalProxy + single-link baselines)

Phase 0 of the Hosts feature. Locks current subscription-link output for the
externalProxy paths (vless/vmess/trojan/ss exact, reality/hysteria by Contains)
so the upcoming ShareEndpoint refactor can be proven behavior-preserving. These
must stay green and unedited through every later phase.

* refactor(sub): unify external-proxy link building behind ShareEndpoint (TDD, snapshot-locked)

Phase 1 of the Hosts feature. Collapse the duplicated externalProxy link
builders (param-form for vless/trojan/ss, object-form for vmess) onto a single
ShareEndpoint abstraction so Phase 4 can add Host-driven links with ~zero new
branching.

Design: an externalProxy-derived endpoint carries the original entry map and
applies it through the UNCHANGED applyExternalProxyTLS{Params,Obj} helpers, so
output is provably byte-identical. buildExternalProxyURLLinks /
buildVmessExternalProxyLinks become thin adapters; the genVless/Trojan/SS/Vmess
call sites are untouched. genHysteriaLink is deliberately left on its own path
(hex pinSHA256, not pcs). The no-externalProxy default tails are unchanged.

TDD: N1-N4 (externalProxyToEndpoint, inboundDefaultEndpoint, buildEndpointLinks,
buildEndpointVmessLinks) written failing-first against stubs, then implemented.

Mutation sanity (performed + reverted): dropping the ep-carry in
externalProxyToEndpoint makes the Phase-0 C1/C2 characterization snapshots go
red (TLS overrides vanish), proving the snapshots guard the emitted output.

Gate: go test ./internal/sub/... and go test ./... green with ZERO edits to the
Phase-0 snapshots; go build ./... green on linux and windows; go vet clean.

* feat(model): Host entity + automigrate + openapi codegen (TDD)

Phase 2 of the Hosts feature. Adds the Host GORM model: an override endpoint
attached to an inbound (address/port + TLS/transport/clash overrides + sub
scoping), superseding the legacy externalProxy array functionally while leaving
it intact.

- model.Host with snake_case column tags, json serializer for slices, text for
  free-JSON (mux/sockopt/xhttp), validate tags (remark 1-40, port 0-65535,
  security + mihomoIpVersion enums); TableName "hosts". NodeGuids column is added
  now but unused (host->node scoping deferred to v2).
- Registered in BOTH initModels() (db.go) and migrationModels() (migrate_data.go);
  the latter is required for cross-DB migration and is easy to miss. PG sequence
  resync iterates the initModels slice, so it is covered automatically.
- pruneOrphanedHosts() deletes hosts whose inbound_id has no inbound, called
  alongside pruneOrphanedClientInbounds().
- openapigen manifest: Host added to StructAllow with MuxParams/SockoptParams/
  XhttpExtraParams -> KindAny; regenerated frontend/src/generated/* + openapi.json.

TDD: TestHostTableName, TestHostValidation, TestHostAutoMigrateCreatesColumns
(+ _Postgres), TestPruneOrphanedHosts written failing-first against a wrong-name,
untagged, unregistered stub, then implemented.

Gate: go test ./... green on SQLite AND a real Postgres DSN (local container);
go build/vet/gofmt clean; npm run gen succeeds with the new Host type/schema/
example/zod; npm run typecheck + npm run test (542) green.

* feat(api): Host CRUD service + controller + routes (TDD)

Phase 3 of the Hosts feature.

- service/host.go (HostService, empty struct + database.GetDB() like
  ClientService): GetHosts, GetHostsByInbound, GetHost, AddHost (verifies the
  inbound exists — no hard FK), UpdateHost (inbound + sort order immutable here),
  DeleteHost, SetHostEnable, SetHostsEnable, DeleteHosts, ReorderHosts (single
  driver-safe transaction), GetAllTags.
- controller/host.go mirrors NodeController: routes under /panel/api/hosts
  (list/get/byInbound/tags + add/update/del/setEnable/reorder + bulk/setEnable,
  bulk/del), binds via middleware.BindAndValidate so the model validate tags are
  enforced, {success,msg,obj} envelopes.
- Wired the hosts group into api.go after nodes (inherits checkAPIAuth + CSRF).
- DelInbound now cascades: deleting an inbound deletes its hosts.
- Documented all 11 routes in api-docs endpoints.ts (referencing the generated
  Host schema) and regenerated openapi.json; extended TestAPIRoutesDocumented's
  controller->basePath switch for host.go. Backend en toast keys added.

TDD: service tests (Add/GetByInbound, RejectsUnknownInbound, Reorder, Set/Bulk
enable, DeleteHosts, DeleteInboundCascadesHosts, GetAllTags) written failing-
first against a nil-returning stub; controller test (AddListGetDelete envelope
round-trip + AuthInherited 401) added.

Gate: go test ./internal/web/... + go test ./... green; npm run gen + typecheck
+ lint + test (542) + build green.

* feat(sub): render subscription links from hosts; legacy fallback when none (TDD, mutation-checked)

Phase 4 of the Hosts feature. Inserts host resolution between inbound and link
across all three subscription formats.

Mechanism: hostEndpoints(inbound, format) loads the inbound's enabled hosts
(filtered by ExcludeFromSubTypes, ordered by sort_order then id) and projects
each onto the externalProxy entry shape the raw/json/clash renderers already
consume. So a host fans out one link/proxy reusing the exact existing rendering
(address/port/security/sni/fp/alpn/pins/ech) with zero new TLS code. Host header
and path overrides are applied additively in the raw builders (no-op for legacy
externalProxy, which never carries those keys — characterization snapshots stay
green). Clash ip-version (MihomoIpVersion) is set last on the proxy.

Integration points:
- getSubs (raw): per inbound, hostEndpoints AFTER projectThroughFallbackMaster;
  len>0 -> linkFromHosts (renders only the hosts), else legacy GetLink.
- GetJson/GetClash: inject the host endpoints into the inbound's externalProxy
  before the existing getConfig/getProxies loop.
- Precedence: hosts win over any legacy externalProxy (injection replaces it).

Backward compat: a zero-host inbound takes the legacy path -> byte-identical
output (all Phase-0 characterization snapshots unchanged).

TDD: 9 cycles (zero-hosts identical, N-links-ordered with host/path override,
disabled skipped, host-vs-externalProxy precedence, no-dedup, sort composes with
SubSortIndex, host-over-fallback, resolve-via-client-inbounds, ExcludeFromSubTypes
per format) written failing-first against unwired helpers, then wired green.

Mutation sanity (performed + reverted, documented here):
- zero-hosts fallback: flipping the len(hostEps)>0 guard to >=0 makes
  TestSub_ZeroHosts_IdenticalOutput go red (host path yields "" for no hosts).
- no-dedup: adding a remark-dedup in hostEndpoints makes TestSub_NHosts_NoDedup
  go red (two distinct hosts collapse to one link).

Gate: go test ./internal/sub/... + go test ./... green with ZERO edits to the
Phase-0 snapshots; go build green on linux and windows; go vet + gofmt clean.

* feat(migration): seed hosts from inbound externalProxy (TDD, idempotent, dual-driver)

Phase 5 of the Hosts feature. One-time migration so existing installs surface
their legacy externalProxy entries as first-class Host rows.

- seedHostsFromExternalProxy() is self-gated on a HistoryOfSeeders
  "HostsFromExternalProxy" row (run-once) and wired into runSeeders. For each
  inbound it parses StreamSettings, reads externalProxy[], and creates one Host
  per entry: forceTls->Security (unknown->same), dest->Address, port->Port,
  remark->Remark (generated when blank, capped at 40), sni/fingerprint/alpn/
  pinnedPeerCertSha256/echConfigList copied; SortOrder=index; InboundId set.
- Additive: externalProxy is left intact in StreamSettings (rollback-safe; the
  sub layer prefers hosts when present, §Phase 4).
- Postgres: GORM db.Create advances hosts_id_seq via the sequence, so no extra
  resync is needed beyond the existing startup resync.

TDD: field-mapping, idempotency (second run no-op), no-externalProxy->no-hosts,
externalProxy-kept-intact written failing-first against a stub; plus a
Postgres counterpart that skips without XUI_DB_DSN.

Gate: go test ./internal/web/service/... ./internal/database/... green on SQLite;
the *_Postgres tests green against a real Postgres container; go build green on
linux and windows; go vet + gofmt clean. (Running the whole database package
under XUI_DB_TYPE=postgres is not supported — the SQLite-path tests share the one
DSN — so only the t.Skip-gated *_Postgres tests run with the env set.)

* feat(ui): Hosts page + schema + query hooks + link preview helper (TDD on schema/helpers)

Phase 6 of the Hosts feature — the admin UI.

- schemas/api/host.ts: HostFormSchema (validation: remark 1-40, tags ^[A-Z0-9_:]+$
  ≤10×≤36, port 0-65535, security/mihomoIpVersion enums, alpn/fingerprint reused
  from the shared primitives) + a loose HostRecordSchema/HostListSchema for reads.
- lib/hosts/host-link.ts: hostToExternalProxyEntry — the frontend mirror of the
  backend hostToExternalProxyMap (security->forceTls, sni override rules, port
  inherit), for share-link previews.
- api/queries/useHostsQuery.ts + useHostMutations.ts (mirror the node hooks):
  list/get + add/update/del/setEnable/reorder/bulk; queryKeys.hosts.* added;
  mutations invalidate keys.hosts.root().
- pages/hosts/{HostsPage,HostList,HostFormModal}.tsx (+CSS) mirroring pages/nodes:
  list with remark · address:port · inbound · security · tags · enable Switch ·
  per-inbound move up/down (reorder) · bulk enable/disable/delete; form grouped
  into Basic / Advanced / Clash / Subscription-scope sections.
- Route '/hosts' + sidebar item (Global icon); menu.hosts + pages.hosts.* added to
  the en-US bundle (other locales fall back to English until translated).

TDD: HostFormSchema (10 cases) and hostToExternalProxyEntry (6 cases) written
failing-first, then implemented. UI verified by lint/typecheck/test/build.

Deferred (documented enhancement): the live in-form share-link preview (needs
inbound+client context) and a per-host host/path override in JSON/Clash output
(raw already overrides; JSON/Clash inherit the inbound's host/path).

Gate: cd frontend && npm run lint && npm run typecheck && npm run test (557) &&
npm run build all green; go build ./... + go test ./... still green.

* refactor(ui): remove the External Proxy form from the inbound stream settings

Hosts supersede the legacy externalProxy: the subscription renders from hosts
(hosts win when both exist) and the migration converts existing externalProxy
entries to hosts. externalProxy's only real consumers were the subscription
(now covered) and this form's preview — the backend per-client copy-link never
used it — so removing the editor has no functional regression.

- Drop ExternalProxyForm + toggleExternalProxy from InboundFormModal and delete
  the orphaned form component + its export; remove its block test + snapshot.
- KEEP the externalProxy schema field and backend parsing/link-generation: an
  existing inbound's externalProxy still round-trips through the form (not
  silently destroyed on edit) and still renders if a host was removed.

Gate: cd frontend && npm run typecheck + lint + test (556) + build green.

* fix(ui): use Alert `title` instead of deprecated `message` (antd 6)

Ant Design 6 deprecated <Alert message=> in favor of <Alert title=>; the panel
was mid-migration (21 Alerts already on title). Renamed the 7 remaining stragglers
across 5 files (SubLinksModal, InboundFormModal, sockopt, EmailTab, TelegramTab),
silencing the runtime deprecation warning. description= is unchanged.

Pre-existing warning, surfaced while testing Hosts — not introduced by it.

Gate: npm run typecheck + lint + test (556) + build green.

* style(ui): align Hosts page with Clients/Inbounds cards + reorder columns

- page-shell.css never listed .hosts-page, so the Hosts page got no content
  padding / transparent-layout / summary-card spacing. Add a .hosts-page shell
  block (background, dark/ultra vars, content-area + summary-card padding). This
  is the actual "card spacing" bug.
- HostList: match the Clients/Inbounds list card — hoverable + the toolbar moved
  into the card title as a .card-toolbar (Add when nothing selected; selected
  count + bulk enable/disable/delete on selection). Re-declare .card-toolbar in
  HostList.css since the shared rule lives in a lazily-loaded page stylesheet.
- Reorder table columns as requested: Actions, Enable, then Remark, Endpoint,
  Inbound, Security, Tags. Added scroll x for narrow screens.
- HostsPage: add a summary card (Total / Enabled / Disabled) like the other
  pages. New i18n keys: pages.hosts.selectedCount + pages.hosts.summary.*.

Gate: npm run typecheck + lint + test (556) + build green.

* style(ui): use Tabs instead of Collapse in the Add/Edit Host form

The Basic / Advanced / Clash / Subscription-scope sections are now tabs. Each
pane sets forceRender so all fields stay mounted — required because the form
uses preserve=false, so an unmounted tab's values would otherwise be dropped on
submit (and a required field on a hidden tab still blocks submit).

Gate: npm run typecheck + lint + test (556) + build green.

* style(ui): split Host form into Security + Advanced tabs; drop unused JSON fields

- Remove the Mux/Sockopt/XHTTP raw-JSON fields from the Host form: they were not
  wired into link generation and the inbound's structured editors are inbound-
  specific (not reusable). The DB columns + read schema + generated type stay, so
  they can get proper editors later. (HostFormSchema drops them; HostRecordSchema
  keeps them.)
- Reorganize tabs to Basic / Security / Advanced / Clash / Subscription scope:
  Security holds the TLS/cert fields (security, sni, sni-overrides, alpn,
  fingerprint, pins, verify-by-name, ech); Advanced now holds the transport
  overrides (host header, path).
- i18n: add pages.hosts.sections.security; drop the 3 unused field labels.

Gate: npm run typecheck + lint + test (556) + build green.

* style(ui): restore Mux/Sockopt/XHTTP fields in the Host Advanced tab

Put the three free-JSON override fields back, in the Advanced tab next to host
header / path (as JSON inputs — the inbound's structured editors aren't reusable
here). Re-added to HostFormSchema + defaults + the i18n labels.

Gate: npm run typecheck + lint + test (556) + build green.

* feat(hosts): add allowInsecure (rendered) + serverDescription/mihomoX25519/vlessRouteId fields

Closes most of the Remnawave-host gap analysis.

- model.Host: + allowInsecure, serverDescription (≤64), vlessRouteId (0-65535),
  mihomoX25519. Auto-migrated (SQLite + Postgres verified); openapi regenerated.
- allowInsecure is fully RENDERED into subscription output (TDD):
  - raw link: allowInsecure=1 (TLS/Reality, skipped for none) via the endpoint
    builder;
  - JSON/Clash: applyExternalProxyTLSToStream writes tlsSettings.settings.
    allowInsecure, and clash applySecurity now emits skip-cert-verify for the tls
    case (it previously only did so for Hysteria — a pre-existing gap, so inbound
    allowInsecure now renders for vless/trojan/ss clash too).
- Frontend: the four fields added to the Host form (allowInsecure → Security,
  serverDescription → Basic, vlessRouteId → Advanced, mihomoX25519 → Clash);
  serverDescription shown under the remark in the list. Schema + i18n updated.

serverDescription / vlessRouteId / mihomoX25519 are stored + editable; their
deeper rendering (and per-host mux/sockopt/xhttp into JSON/Clash, plus a per-host
xray JSON template) are tracked as follow-ups.

Gate: go test ./... green (SQLite + Postgres for the host schema/migration);
go build linux+windows; go vet + gofmt clean; npm run gen + typecheck + lint +
test (556) + build green; generated files in sync.

* feat(sub): render host sockopt + xhttp-extra params into JSON/Clash output (TDD)

A host's sockoptParams and xhttpExtraParams (free-JSON) now take effect:
applyHostStreamOverrides injects sockopt into the per-host stream (re-added since
the base stream strips it) and merges xhttpExtraParams into xhttpSettings, called
in both getConfig (JSON) and getProxies (Clash) right after the per-host TLS
apply. No-op for legacy externalProxy entries (keys absent) — characterization
snapshots unchanged.

mux rendering is outbound-level (overrides outbound.Mux) and needs a genVless/
genVnext/genServer signature change — deferred, along with the per-host xray
JSON template.

Gate: go test ./internal/sub/... + go test ./... green (snapshots unchanged);
go build + vet + gofmt clean.

* feat(sub): render host muxParams as a per-host JSON outbound mux override (TDD)

genVnext/genVless/genServer take a muxOverride: a host's muxParams (when valid
JSON) overrides the global mux on its JSON outbound; empty falls back to the
panel mux (behavior unchanged for non-host configs). Completes the host
mux/sockopt/xhttp trio. Test call sites updated for the new signature.

Gate: go test ./internal/sub/... + go test ./... green (snapshots unchanged);
go build + gofmt clean.

* style(ui): show Host security fields conditionally per security (like externalProxy)

* feat(sub): apply host SNI + fingerprint override for reality (TDD)

A reality host now overrides SNI and fingerprint while inheriting publicKey/
shortId from the inbound (reality keys can't be host-supplied). Previously the
reality link kept the inbound's serverName because the TLS appliers are gated to
security=="tls".

- raw: applyEndpointRealityParams sets sni/fp on the params for reality;
- JSON/Clash: applyHostStreamOverrides sets realitySettings.serverName +
  serverNames from the host SNI.

Gated to host endpoints via an isHost marker on the synthesized ep, so the legacy
externalProxy path stays byte-identical (characterization snapshots unchanged).
The marker is internal and never emitted.

Gate: go test ./internal/sub/... + go test ./... green; go build + vet + gofmt clean.

* fix(ui): start the Host inbound select unselected instead of showing 0

A new host left inboundId defaulting to 0, so the Select rendered "0". inboundId
is now optional in the form (undefined until chosen), so it shows its
placeholder ("Select an inbound"); the required rule still enforces a choice on
save. Port keeps 0 (means "inherit the inbound's port").

Gate: npm run typecheck + lint + build green.

* fix(ui): drop redundant :port suffix from the Host inbound select label

The inbound tag (e.g. in-59303-tcp) already carries the port, so the appended
":59303" was duplicated. Show just the remark/tag.

Gate: npm run typecheck + lint + build green.

* style(ui): apply the shared card hover shadows to the Hosts page

page-cards.css scoped its card styling + hover shadows to each page class but
not .hosts-page, so Hosts fell back to antd's default hoverable (a larger/blurry
shadow + pointer cursor). Add a .hosts-page block matching the other pages.

Gate: npm run build green.

* feat(hosts): move Tags to Basic tab, add Nodes field, accept VLESS route ranges

- Move the Tags field into the Host form's Basic tab and add a Nodes
  multi-select (visual-only assignment, backed by the existing node_guids
  column) so the Basic tab matches the reference layout.
- Replace the single-port vlessRouteId integer with a free-form vlessRoute
  string that accepts comma-separated ports/ranges (e.g. 53,443,1000-2000);
  format-validated on the frontend, stored verbatim on the backend.
- Regenerated frontend types/openapi from the changed model.

* feat(hosts): structured editors for Mux/Sockopt/XHTTP + new Final Mask

Replace the raw JSON textareas in the Host form's Advanced tab with the same
structured editors used elsewhere, under a nested tabbed layout (General / Mux /
Sockopt / XHTTP / Final Mask), mirroring the Sub-JSON settings tab:

- Mux: the Sub-JSON mux editor (enable + concurrency/xudpConcurrency/xudp443).
- Sockopt + XHTTP: reuse the outbound SockoptForm / XhttpForm, wrapped in an
  isolated form that serializes the edited subtree back to the host's JSON
  string (pruned so the override stays sparse).
- Final Mask: new host field (model + column + JSON-render wiring that merges
  the masks into the host's JSON-subscription stream), edited via the shared
  FinalMaskForm like the Sub-JSON Final Mask editor.

Each editor stays a controlled value/onChange component bound to its existing
host JSON string field; backend rendering of mux/sockopt/xhttp is unchanged.

* feat(hosts): drop XHTTP + Xray-JSON-template overrides; fix mobile form layout

Remove the host's XHTTP extra-params and Xray-JSON-template overrides entirely
(model fields + columns, JSON-subscription render paths incl. hostTemplateOutbound,
schema, form tab/field, i18n, openapi codegen, and their tests) — they did not
fit the host model. Mux, Sockopt and Final Mask stay as structured editors.

Mobile fixes for the Edit Host modal:
- responsive width (95vw on mobile, was a fixed 760px that overflowed the
  viewport and clipped the tabs/labels) + a scrollable body so the footer stays
  on screen;
- Mux fields use responsive Row/Col (stack on mobile) instead of a fixed-width
  label grid.

* fix(hosts): hide the spurious horizontal scrollbar in the Edit Host modal

Setting overflowY:auto on the modal body forced overflow-x to auto too (CSS
rule), so antd Row's negative gutter margins triggered a horizontal scrollbar.
Pin overflowX:hidden.

* feat(hosts): inbound-style responsive field layout + icon empty state

- Host form (main form + Mux/Sockopt/Final Mask editors) now use the inbound
  form's label layout: label beside the input on desktop (labelCol sm span 8 /
  wrapperCol sm span 14, right-aligned), stacked label-above-input on mobile.
  Rewrote HostMuxForm onto an internal antd Form so it follows the same layout
  instead of a manual grid.
- Empty hosts table now shows the host icon + the shared 'Nothing here yet'
  (noData) text, matching Nodes/Inbounds/Clients, replacing the bespoke
  'No hosts yet…' string.

* fix(hosts): avoid nested <form> in the Edit Host modal

The Mux/Sockopt/Final Mask editors each render their own antd Form inside the
host's main Form, producing an invalid nested <form> DOM node (hydration
warning). Render those inner forms with component={false} so they keep the form
instance/context but emit no <form> element.

* fix(hosts): make the Mux enable toggle work

The Switch's checked state came from Form.useWatch('mux'), but the mux object
field had no registered Form.Item while disabled, so setFieldValue never
notified the watcher and the toggle stayed off. Bind the Switch to a real
name='enabled' field (antd drives its checked state directly) and keep the
sub-fields registered via hidden={!enabled}, serialized to the flat mux JSON.

* refactor(hosts): reuse the outbound MuxForm instead of a bespoke Mux editor

The Mux fields duplicated the outbound MuxForm. Reuse it through the same
wrapper as Sockopt: generalize OutboundSubtreeJsonForm with defaultSubtree
(pre-fill on enable) and a serialize hook, and have HostMuxForm render MuxForm
at the ['mux'] path. The host keeps its inherit-when-off semantics by storing ''
unless mux.enabled. Also drops the now-unused enableSwitch path from the
wrapper (only the removed XHTTP editor used it).

* style(hosts): use default-width Port input like the inbound form

The host Port used width:100% (full width); the inbound's numeric inputs use
antd's default width. Drop the override so Port matches. The Mux number inputs
already use the default width via the reused MuxForm.

* refactor(sockopt): readable customSockopt editor as a shared component

The customSockopt rows were a single cramped Space.Compact line and duplicated
verbatim in the inbound and outbound sockopt forms. Extract a shared
CustomSockoptList that renders each entry as a titled group of labeled fields
(System / Level / Opt / Type / Value), matching the rest of the form, and use it
in both (and thus the host Sockopt editor).

* fix(finalmask): drop the empty Custom Tables tag on a new sudoku mask

The sudoku TCP-mask default seeded customTables: [''] (one empty string), which
rendered as a blank removable tag. Seed [] instead.

* fix(sockopt): make the outbound (and host) Sockopt client-only

Per the XTLS sockopt docs, tproxy / acceptProxyProtocol / V6Only /
trustedXForwardedFor only apply to an inbound (listening socket); they are
meaningless on an outbound/dialer. Drop them from the outbound SockoptForm
(which the host reuses). The Sockopt default object still seeds those keys, so
the host also strips them on serialize, keeping its override honest to the
server/client split. The inbound SockoptForm is left unchanged.

* fix(sockopt): make the inbound Sockopt server-only

Complete the server/client split: drop the outbound/dialer-only fields from the
inbound SockoptForm — dialerProxy, domainStrategy, interface, addressPortStrategy,
happyEyeballs, tcpMptcp (client-only since Go 1.24 auto-enables MPTCP on listen).
mark stays (xray applies SO_MARK on inbound sockets too). Update the form-blocks
snapshot to the server-side field set (intentional spec change).

* feat(hosts): populate Sockopt dialerProxy with the panel's outbound tags

The host Sockopt editor reused the outbound SockoptForm with outboundTags=[],
so the dialerProxy dropdown was empty. Feed it the panel's outbound tags via
the existing useOutboundTags hook (shares the cached xray-config query;
blackhole excluded), so a host can chain through a subscription outbound by tag.

* fix(hosts): empty-state styling on direct load + exclude balancers from dialerProxy

- .card-empty was only defined in lazily-loaded Clients/Inbounds/Nodes
  stylesheets, so a direct /hosts refresh rendered the empty table state
  unstyled (faint + uncentered) until another page was visited. Re-declare it
  in HostList.css so it's correct on first load.
- The Sockopt dialerProxy dropdown listed balancer tags (useOutboundTags merges
  them in for mtproto egress). dialerProxy chains a single outbound, so balancers
  aren't valid — switch to useOutboundTagGroups and use only the outbound group.

* fix(outbounds): icon + 'Nothing here yet' empty state; stop fading other pages

The Outbounds empty state was a faint '—', and OutboundsTab.css set the global
.card-empty to opacity:0.4 — which leaked onto whichever page's empty state was
shown after the Outbounds CSS had loaded (e.g. Hosts went faint after visiting
Outbounds). Render the icon + noData ('Nothing here yet') like the other lists,
and align .card-empty to the shared centered/secondary style (no opacity).

* fix(outbounds): custom empty state on the desktop table too

The desktop Outbounds Table had no locale.emptyText, so it showed antd's
default 'No data' box. Add the same ExportOutlined + noData empty state as the
card (mobile) view.

* style(sidebar): use ExportOutlined for the Outbounds nav item

The Outbounds sidebar item used UploadOutlined (an upload tray). Switch to
ExportOutlined, matching the outbound icon now used in the routing target and
the outbounds empty states.

* feat(hosts): icons on the form tabs (icon-only on mobile)

Wrap every Host form tab label (Basic/Security/Advanced/Clash/Subscription
scope and the nested General/Mux/Sockopt/Final Mask) with catTabLabel, so the
tabs show icon + text on desktop and just the icon (with a tooltip) on mobile,
matching the Settings/Xray tab bars.

* refactor(hosts): fold Exclude-from-formats into Advanced, drop the one-field tab

The Subscription scope tab held only excludeFromSubTypes after Tags moved to
Basic — a niche per-format scoping knob. Move it into the Advanced > General
sub-tab and remove the standalone tab (and its now-unused subScope label/icon).

* feat(sub): per-client remark template variables; drop the remark model & Show Usage Info

* fix(migration): cap seeded host remark at the model's 256-char limit, not 40
2026-06-17 12:06:55 +02:00
Sanaei 37c5e0bfd2 feat(node): node hardening — mTLS, hashed+zstd reconcile transport, per-node net metrics (#5382)
* fix(api-docs): document clientIpsByGuid route

Restores a green `go test ./...` baseline: TestAPIRoutesDocumented
flagged POST /panel/api/clients/clientIpsByGuid (added in 9385b6c6)
as undocumented in endpoints.ts.

* test(node): characterize current node TLS + API auth behavior

Phase 0 regression net for the mTLS work. These pass on unchanged
production code and lock the pre-mTLS contracts so later phases can be
proven additive:

- tlsConfigForNode: skip -> InsecureSkipVerify (no VerifyConnection);
  pin -> VerifyConnection installed.
- checkAPIAuth: bearer match -> Next + api_authed; unauthenticated ->
  401 (XHR) / 404; valid session -> Next.
- panel HTTPS listener with no ClientAuth accepts a client that presents
  no client certificate (the browsers-keep-working invariant).

* feat(crypto): node-auth CA + client-cert minting (TDD)

Stdlib-only ECDSA P-256 helpers for the node mTLS work:
- GenerateNodeCA: self-signed CA (IsCA, CertSign, path len 0)
- IssueClientCert: client-auth leaf (ExtKeyUsageClientAuth) signed by CA
- LoadCAFromPEM: parse a CA cert+key for issuing / trust-pool building

Tests assert the contract (leaf verifies against the issuing CA with
ExtKeyUsageClientAuth), seen failing on the assertion before impl.

* feat(node): lazy node mTLS CA + client cert in settings (TDD)

SettingService gains opt-in mTLS material, all stored as Setting rows
with empty defaults and kept out of entity.AllSetting (so private keys
never reach the settings UI/export):
- EnsureNodeMtlsCA: mint+persist the node-auth CA once, reuse thereafter
- EnsureMasterClientCert: issue the master client cert from the CA, idempotent
- NodeMtlsClientCAPool: ClientCAs trust pool for the listener; nil when
  unconfigured so the no-mTLS path is unchanged

Tests assert idempotency and that the client cert verifies against the CA
for client auth; seen failing on the assertion before impl.

* feat(node): mtls client TLS config + master-cert provider (TDD)

tlsConfigForNode gains an 'mtls' branch that presents the master client
certificate and verifies the node server against system roots (no
InsecureSkipVerify, no custom RootCAs). The cert is supplied via an
injected MasterClientCertProvider so runtime need not import service;
it fails closed when unconfigured. skip/pin contracts unchanged.

* feat(node): allow tokenless mtls nodes in remote do() (TDD)

mtls nodes authenticate with a client certificate, so the bearer token
becomes optional for them: do() no longer rejects an empty ApiToken when
TlsVerifyMode is mtls, and the Authorization header is omitted when no
token is set. Every other mode still requires a token (regression kept).

* feat(node): authenticate verified client certs in checkAPIAuth (TDD)

A completed mTLS handshake (non-empty r.TLS.VerifiedChains) now
authenticates an API request, equivalent to a valid bearer token, and
sets api_authed so the CSRF middleware lets cert-authed mutations
through. Bearer/session/reject paths unchanged. The accept-path assert
was mutation-checked (guard flipped -> test red -> reverted).

* feat(node): opt-in mTLS on the panel listener (TDD; mutation-checked)

web.go now applies VerifyClientCertIfGiven + ClientCAs to the HTTPS
listener when a node trust CA is configured, and wires the master client
cert provider for outbound mtls calls. With no CA the listener is
byte-identical to before (browsers unaffected).

applyNodeMtls is covered end-to-end: no-cert client handshakes (browsers
keep working), a CA-signed client cert verifies, a foreign-CA cert is
rejected at the handshake. Mutation-checked:
- RequireAndVerifyClientCert -> no-cert client rejected (red) -> reverted
- drop ClientCAs -> master cert no longer trusted (red) -> reverted

* feat(node): accept mtls verify-mode + CA reveal endpoint (TDD)

- model.Node.TlsVerifyMode validator now accepts 'mtls'
- normalize() preserves mtls and requires the node scheme to be https
  (fail closed), instead of clamping mtls back to verify
- NodeService.NodeMtlsCaCert + POST /panel/api/nodes/mtls/ca return this
  panel's node-auth CA cert (public) to paste into a node, minting the CA
  + master client cert on first call
- endpoints.ts documents the new route (doc-sync test)

No model column added (enum is a string), so no migration/codegen.

* feat(node): node mTLS UI + trust-CA setter (TDD)

Backend:
- NodeService.SetNodeMtlsTrustCA + POST /panel/api/nodes/mtls/trustCA
  store the CA this panel trusts for incoming node-API client certs
  (validates PEM, empty clears); applied on next restart
- endpoints.ts + regenerated openapi.json document both mtls routes

Frontend:
- node form: 'mtls' TLS-verify option + setup hint (zod enum updated)
- Nodes page 'Node mTLS' card: copy this panel's CA, and paste/save the
  trusted parent CA
- en-US i18n keys (other locales fall back to en-US)

Gates green: go build (native+windows), vet, go test ./...; frontend
typecheck, lint, vitest (541).

* style(node): gofmt web_mtls_test doc comment

* feat(node): hashed+zstd reconcile transport (TDD, negotiated, mixed-version safe)

Adds an integrity + compression envelope to node config pushes:
- internal/util/wirecodec: shared zstd codec (bomb-capped decode) +
  SHA-256 hashing + the header/capability constants
- Remote.do(): always attaches X-Config-Sha256 of the uncompressed body;
  zstd-compresses only when the node advertised support (learned from its
  X-3x-Node-Caps response header) and the body is >=1KiB
- ConfigEnvelopeMiddleware on /panel/api: advertises the cap, decompresses
  and verifies the hash (handler not invoked on mismatch) before binding

Mixed-version safe: old nodes never advertise the cap -> plain bodies;
the hash header is verify-if-present so any panel/node mix interoperates
(existing reconcile tests stay green). klauspost/compress promoted to a
direct dep. Hash-mismatch reject was mutation-checked (compare defeated
-> test red -> reverted).

* feat(node): per-node network throughput metrics (TDD)

The node status response already carries gopsutil netIO.up/down (summed
non-virtual interfaces), so no node-side change is needed:
- probe() parses netIO.up/down into HeartbeatPatch.NetUp/NetDown
- Node gains net_up/net_down columns (AutoMigrate); UpdateHeartbeat
  persists them and appends netUp/netDown to the per-node metric history
- NodeMetricKeys whitelists netUp/netDown so the history endpoint serves them
- NodeHistoryPanel renders Net Up/Down sparklines (KB/s, no 0-100 clamp)
- regenerated frontend types + openapi.json for the new Node fields

* feat(node): move node mTLS controls into a toolbar button + modal

The Node mTLS panel was an always-visible card cluttering the nodes
page. Replace it with a 'Node mTLS' button beside 'Add node' that opens
a modal with the same copy-CA + trusted-parent-CA controls; the modal
closes on a successful save. No backend/i18n changes.

* i18n(node): translate mTLS + net-metrics keys for all locales

Adds the node mTLS strings (tlsMtls, mtlsFormHint, mtls.* dialog + the
saveMtls toast) and the netUp/netDown chart labels to all 12 non-English
catalogs (ar, es, fa, id, ja, pt, ru, tr, uk, vi, zh-CN, zh-TW), matching
each catalog's existing terminology. Technical tokens (mTLS/TLS/CA/API/
KB/s) kept verbatim.

* fix(node): address Copilot review on node-hardening PR

- setting_mtls: fail closed on a half-present CA/master-cert pair instead of
  silently regenerating (which would rotate the CA and break fleet trust).
- config_envelope: reject non-zstd Content-Encoding on the envelope path
  rather than hashing/forwarding a still-encoded body to the handler.
- node mTLS: support tokenless mTLS end-to-end — apiToken is now
  required_unless tlsVerifyMode=mtls (model) with matching conditional
  validation in NodeFormSchema, so the runtime allowance is actually reachable.
- NodesPage: add a catch block to onSaveTrustCa so save failures surface.
2026-06-16 12:19:33 +02:00
MHSanaei f3eba04ed8 ci: use .nvmrc for setup-node version in codeql/release workflows 2026-06-15 23:50:05 +02:00
MHSanaei 9385b6c609 feat(nodes): per-node client IP attribution for IP-limit
Record each panel's own Xray IP observations under its panelGuid and merge each node's guid-keyed report on the master, so the panel can tell which node a client IP is connecting through (the flat inbound_client_ips union is pushed back to every node and cannot attribute). Adds the NodeClientIp model + migration, the clientIpsByGuid endpoint and node-sync merge, node-name labels in the client IP log, and cleanup on node deletion.
2026-06-15 23:50:05 +02:00
MHSanaei d882d6aa74 feat(inbounds): add Real client IP presets to capture visitor IP behind CDN/relay
Surface the existing sockopt knobs (acceptProxyProtocol, trustedXForwardedFor) as a guided 'Real client IP' preset selector in the inbound form, so the real visitor IP is recovered behind Cloudflare CDN or an L4 tunnel/relay instead of recording the intermediary address. Presets are mutually exclusive, warn on incompatible transports, and add tooltips, docs, and translations for all locales.
2026-06-15 23:50:04 +02:00
MHSanaei bbab83db17 refactor(frontend): stack client credential fields and use label hints on inbound form
Stack UUID/password/subId/auth/flow/security fields vertically in the client modal instead of two-column rows, and replace the inbound form's 'extra' help lines with hover tooltip hints on field labels.
2026-06-15 21:38:11 +02:00
MHSanaei dc781b28c4 chore(deps): bump telego to v1.10.0 2026-06-15 21:15:38 +02:00
MHSanaei 5b8504c756 chore(deps): bump frontend deps and override js-yaml to patch DoS advisory
Force swagger-ui-react's bundled js-yaml to ^4.2.0 (GHSA-h67p-54hq-rp68)
without downgrading swagger-ui-react. Also picks up minor bumps to antd,
axios, react-router-dom and dev deps.
2026-06-15 21:15:38 +02:00
MHSanaei c1fdcd98d2 fix(nodes): route 'load inbounds' through the connection outbound
Loading a node's inbound list bypassed the configured connection
outbound and dialed the remote panel directly, so a node only reachable
through that outbound timed out with 'context deadline exceeded' even
though Test Connection succeeded.

Extract the temporary loopback SOCKS5 bridge setup from ProbeWithOutbound
into a shared withOutboundBridge helper and route GetRemoteInboundOptions
through it when an outbound tag is set.
2026-06-15 21:13:27 +02:00
Sentiago eec030f86f feat(notifications): event bus architecture with Telegram and SMTP subscribers (#5326)
* feat(notifications): event bus architecture with Telegram and SMTP subscribers

- Event bus core with buffered channel, fan-out, panic recovery
- Telegram subscriber with HTML formatting and rate limiting
- Email subscriber with SMTP/TLS/STARTTLS support and stage diagnostics
- 5 event types: outbound.down/up, xray.crash, cpu.high, login.attempt
- CPU threshold checks per subscriber (tgCpu for TG, smtpCpu for Email)
- SystemMetricData struct for raw metric values in events
- i18n keys for en-US, ru-RU, and English defaults for other locales

* fix

* fix(notifications): repair crash/CPU alerts, harden secrets, add node alerts

Bug fixes:
- Xray crash notifications were permanently suppressed after the first crash:
  XrayStateTracker latched state="down" with no reset and no recovery event,
  so only the first crash per process lifetime ever notified. Removed the
  tracker; the existing 1/min rate limiter already dedupes crash-loop spam.
- Email CPU alerts could never fire unless Telegram was also enabled, because
  the CPU job was registered only inside the tgbot block. Register it whenever
  either Telegram or SMTP wants cpu.high (new cpuAlarmWanted gate) and relax
  the cadence to @every 1m (cpu.Percent already samples over a full minute).
- SMTP password (and, pre-existing, all other secrets) were shipped to the
  browser in plaintext: GetAllSettingView was dead code and /setting/all
  returned the raw model. Wire getAllSetting -> GetAllSettingView, redact
  smtpPassword with a hasSmtpPassword presence flag, and preserve it on blank
  save. Closes the leak for tgBotToken/ldapPassword/2FA token too.

Polish:
- email Send: use nil SMTP auth when no credentials (Go refuses PlainAuth over
  the unencrypted "none" transport).
- Remove unused EventClientDepleted; fix inaccurate bus.go doc comments; drop
  stale tgBotLoginNotify from the frontend schema; gofmt alignment.

Feature - node online/offline alerts:
- Emit node.down/node.up from the heartbeat job on a real status transition
  (with a startup-spam guard), reusing NodeHealthData. Formatted by both the
  Telegram and email subscribers and selectable in the settings UI.

Regenerated frontend types (hasSmtpPassword). New i18n keys added to en-US;
other locales fall back to English (bundle default) until translated.

* fix(settings): use antd Space orientation instead of deprecated direction

Ant Design 6 deprecated Space's `direction` prop in favor of `orientation`,
which logged a console warning from the Telegram/Email notification tabs. Brings
these two tabs in line with the rest of the codebase, which already uses
`orientation`.

* i18n(notifications): translate the notification feature into all locales

The notifications PR shipped ~99 new strings (SMTP settings, event labels,
Telegram/email message templates) as English placeholders in every non-English
locale. Translate them — plus the node-alert keys added during this review —
into all 12 locales: Arabic, Spanish, Persian, Indonesian, Japanese,
Portuguese-BR, Russian, Turkish, Ukrainian, Vietnamese, and Simplified/
Traditional Chinese.

Go-template placeholders ({{ .Tag }}, {{ .Name }}, etc.) are preserved exactly;
tgbot message values carry no leading status emoji (the bot/email code adds
those, so an emoji in the value would duplicate it); product/protocol names
(SMTP, STARTTLS, TLS, CPU, Xray, Telegram) are kept as-is.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 21:03:41 +02:00
MHSanaei 7fe082a7f1 fix(nodes): stop multi-attached client traffic inflating across node inbounds
Xray counts client traffic globally per email, so a client attached to
several of a node's inbounds has its single shared counter copied onto
every inbound by the node's enriched inbound list. When those copies
diverge (legacy per-inbound rows surviving a v3.2.x->v3.3.x upgrade, or
any drift) the per-inbound delta loop read the lower sibling as a
node-counter reset and re-added its full value, inflating the client far
past real usage (#5274).

Fold each email to its per-field node-wide max before the delta loop so
every occurrence is equal: the per-email baseline dedup then holds and
the reset clamp never misfires.
2026-06-15 19:31:57 +02:00
MHSanaei f7ffe89813 fix(outbound): preserve non-ASCII characters in imported subscription tags (#5354)
SlugRemark stripped every non-ASCII character, so tags generated from
remarks like Cyrillic names collapsed to just their digits, making
imported outbounds hard to identify. Keep Unicode letters and digits in
the slug regex while still collapsing punctuation into dashes.
2026-06-15 19:16:57 +02:00
MHSanaei c1fbfd0510 fix(outbound): parse xmux from imported share links (#5353)
The inbound link generator bundles xmux and downloadSettings as nested
objects inside the `extra=` JSON blob, but the outbound link parser only
pulled scalar fields and headers from it, silently dropping xmux on
import. Extract the nested objects too so they round-trip into the
outbound XMUX sub-form.
2026-06-15 19:12:47 +02:00
MHSanaei cbb21b7575 fix(nodes): propagate single-client deletion to remote nodes (#5352)
Deleting a client attached to a remote-node inbound could silently fail
to reach the node, so the node's next traffic snapshot resurrected the
client once the 90s delete tombstone expired.

Two paths in the single-client delete (Delete -> DelInboundClientByEmail):

- A disabled client was skipped entirely: the node-propagation and
  mark-dirty block sat behind the client's enable flag (needApiDel), so a
  disabled client on a node never detached and never marked the node
  dirty. The bulk and multi-client delete paths already handle the node
  case independently of enable state; mirror that structure here.

- Remote.DeleteUser returned nil when resolveRemoteID failed, hiding the
  failure from the caller so the node was never marked dirty. Surface the
  error like AddClient/UpdateUser do, so the caller marks the node dirty
  and the next reconcile converges.

Add a regression test asserting a disabled node client's deletion marks
the node dirty.
2026-06-15 17:56:12 +02:00
MHSanaei cf5f37e409 fix(iplimit): ban UDP as well as TCP in fail2ban action (#5350)
The generated 3x-ipl fail2ban action only matched -p tcp, so UDP-based
inbounds (Hysteria2, TUIC, WireGuard) from a banned IP kept working,
bypassing IP-limit enforcement. Drop the protocol qualifier from the
chain jump and ban both tcp and udp, keeping the SSH/panel port exemption.
2026-06-15 17:34:23 +02:00
MHSanaei 0d87bb8b4b fix(inbounds): flag conflicts with the reserved Xray API port (#5304)
The internal API inbound (tag "api", default port 62789 on 127.0.0.1) lives in
the Xray config template, not the inbounds table, so checkPortConflict never
caught a local user inbound reusing it — Xray then bound the port twice and
served requests unpredictably. Now reject a local TCP inbound whose listen
overlaps loopback on the reserved API port, read from the template (fallback
62789). Nodes are unaffected since they run their own Xray.
2026-06-15 17:21:06 +02:00
MHSanaei f00512d12e fix(frontend): TProxy schema, VLESS+XHTTP flow links, clearable Jalali date picker (#5339, #5322, #5313)
- #5339: accept transportless tunnel/TProxy streamSettings that carry no
  `security` key by adding a transportless branch to SecuritySettingsSchema,
  mirroring NetworkSettingsSchema. Fixes "streamSettings.security Invalid input".
- #5322: emit XTLS Vision `flow` in panel VLESS share links for XHTTP+vlessenc
  via the shared canEnableTlsFlow predicate, so panel links match the form and
  the subscription output.
- #5313: give the Jalali expiry date picker a working clear (X) button
  (remount on clear, since the library reads `value` only on mount) and a blank
  placeholder instead of the library's hardcoded Persian text.
2026-06-15 17:20:54 +02:00
nima1024m cdaf5f80db fix(inbound): strip XHTTP client-only fields from xray config, keep for subscriptions (#5349)
Inbound XMUX and other client-side xHTTP knobs were written into
bin/config.json even though xray-core's server listener ignores them.
Strip them in GenXrayInboundConfig while leaving the DB row intact so
buildXhttpExtra still pushes defaults to clients via share links.
2026-06-15 16:35:43 +02:00
n0ctal ac8cb505d1 fix(subscriptions): avoid shared mutable state during generation (#5270)
* fix(subscriptions): avoid shared mutable state during generation

* fix(subscriptions): serve external-link-only subs in JSON/Clash; load remark settings per request

The ForRequest refactor added an early `len(inbounds) == 0` return to
GetJson/GetClash that fired before external links were fetched, so a
subscription whose only entries are external links (or whose inbounds are
all disabled) rendered empty in the JSON and Clash formats. Drop the
premature check — the existing inbounds+externalLinks empty guard already
covers the truly-empty case.

Also load datepicker/emailInRemark in PrepareForRequest rather than only in
getSubs, so JSON and Clash remarks honor these settings instead of seeing
the zero values (emailInRemark previously depended on the shared-state leak
this PR fixes).

Add a regression test covering an external-link-only sub across both formats.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 16:23:47 +02:00
n0ctal 71616b7cf2 feat(web): cap request body size on state-changing routes (#5271)
* feat(web): cap request body size on state-changing routes

* fix(web): exempt importDB from request body size cap

The 10 MiB body cap was applied globally, which would break database
restore (/panel/api/server/importDB) on any panel whose SQLite backup
exceeds the limit. Make MaxBodyBytes accept exempt path suffixes and
pass importDB through uncapped; the cap still covers all other
state-changing routes. Add a test for the skip-suffix behavior.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 16:04:16 +02:00
Rouzbeh† 628406117e fix(nodes): sync "start after first connect" expiry so un-activated nodes do not reset it (#5319)
* fix(nodes): stop un-activated nodes from resetting "start after first connect" expiry

In a multi-node setup a client is attached to inbounds on several nodes, but
its `client_traffics` row is shared per-email (the column is `gorm:"unique"`).
With "start expiry after first connect", the expiry is stored as a negative
duration and each node converts it to an absolute deadline (now+duration) the
first time the client connects *there*.

The master's per-node traffic merge wrote `expiry_time = ?` unconditionally for
every node sync. So a node where the client never connected keeps reporting the
un-activated negative duration and clobbers the absolute deadline that the node
where the client *did* connect had already activated — last writer wins. The
shared row flip-flops and usually lands back on the negative value, so the main
panel shows the timer "not started" while the active node counts down, and the
subscription (which reads this row and recomputes negative as now+duration on
every fetch) reports a perpetually-resetting, wrong expiry and usage.

Guard the merge so an un-activated (<= 0) value reported by a node can never
reset an already-activated absolute deadline. A positive node value is still
adopted, so a node that legitimately moves the deadline forward (traffic reset /
auto-renew) still propagates. The rule lives in both the SQL CASE used by the
merge and a small `mergeActivationExpiry` helper (kept in lockstep) that the
structural-change check reuses so the guard does not trigger spurious config
re-pushes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(nodes): cast expiry merge params to BIGINT for Postgres

The "start after first connect" merge guard introduced the comparison
`? <= 0` in the client_traffics expiry_time CASE. There Postgres infers
the parameter type as int4 from the literal 0, so binding a real expiry
value — a negative start-after-connect duration or a positive absolute
deadline (~1.7e12 ms) — overflows int4 and the whole setRemoteTrafficLocked
transaction fails, breaking node traffic and expiry sync on Postgres.
SQLite (dynamic typing) was unaffected.

Wrap both params in CAST(? AS BIGINT) (portable across SQLite and
Postgres) so the parameter is typed bigint, matching the explicit casts
the sibling GreatestExpr/ClientTrafficEnableMergeExpr helpers already use.

Verified against Postgres 16: TestNodeFirstConnectExpiry_NotClobbered
failed before this change and passes after; SQLite suite unchanged.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 15:46:19 +02:00
Sanaei 7605902324 Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345)
* test(audit): add gremlins/rapid/coverage tooling + AUDIT.md scaffold

* test(audit): hygiene sweep (race-clean except logger global; Finding #2) + smell inventory

* test(audit): cover untested error/edge branches (TLS proxy+pin, migration tag cleanup=Finding #1)

* test(audit): strengthen internal/sub link tests (dedup key, TLS/Reality mapping, clash well-formedness)

* test(audit): property (rapid) + fuzz tests for joinHostPort/userinfo/pin/ParseLink

* test(audit): tighten frontend subSortIndex rejection assertions + wire coverage

* ci(audit): add shuffle gate + non-blocking race job (Finding #2) + fuzz-smoke; document mutation policy

* chore(audit): gitignore frontend coverage output

* test(audit): exhaustive whole-repo pass — strengthen 5 weak/fake tests (netproxy, CSP, modal per-protocol loops, schema coercions)

* docs(contributing): add Testing section (conventions, race/shuffle, fuzz, mutation policy); drop AUDIT.md ledger

* fix(logger,migration): guard logBuffer with mutex; execute legacy tag cleanup (tx.Exec); make CI race gate blocking

* ci(mutation): add nightly scoped gremlins workflow (informational artifacts)

* test(audit): strengthen runtime tests — baseURL scheme/port bounds, isNonEmptySlice, trafficReset

* test(audit): strengthen clash tests — reality field mapping + tcp-header validation

* test(audit): runtime — egress-proxy + content-type tests; drop redundant bp=='' branch

* test(audit): strengthen link parser/helper tests (defaultPort, splitComma, base64, canonicalQuery, tls/reality/transport mapping)

* test(audit): strengthen sub/xray/common/netsafe/mtproto/config/middleware tests (kill surviving mutants)

* test(audit): raise timeout on protocol-iteration modal tests (heavy re-renders, slow on CI)

* fix(logger): GetLogs returns at most c entries (off-by-one fix; addresses PR review)

* perf(logger): snapshot logBuffer under lock so GetLogs doesn't block logging; clarify fuzz-seed docs (addresses PR review)
2026-06-15 15:17:03 +02:00
tonymoses10 b5872af279 Frontend operation button size optimization (#5343)
* Update ClientsPage.tsx

* Update GroupsPage.tsx

* Update RowActions.tsx

* Update NodeList.tsx

* Update BalancersTab.tsx
2026-06-15 14:26:51 +02:00
Abdalrahman 53f6ed394f Add Enable/Disable Toggle for Xray Routing Rules (#5296)
* feat: add enable/disable toggle for xray routing rules

* fix(routing): never let the internal api rule be disabled

The Enable/Disable toggle could strip the stats api rule: its table
switch was locked, but the rule-form modal's Enable dropdown was not,
and stripDisabledRules had no api-rule guard (EnsureStatsRouting's
delete only runs when the api rule isn't already first). A disabled
api rule then dropped out of the generated config and broke traffic
accounting.

- stripDisabledRules now always keeps the api rule, even if marked
  disabled, and strips the panel-only enabled key from every rule
- extract isApiRule helper (backend + frontend) and reuse it across
  the table switch, card switch, and form modal
- disable the form-modal Enable dropdown for the api rule
- add stripDisabledRules tests covering the api-rule survival path

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 00:43:49 +02:00
Volov Vyacheslav 66a9a788fc fix(reality): load dest as target alias so existing inbounds aren't wiped (#5295)
xray-core accepts both `target` and `dest` for the REALITY destination
(infra/conf/transport_internet.go: REALITYConfig has json:"target" and
json:"dest"). The frontend schema only knows `target`, so an inbound whose
realitySettings use `dest` — older panel builds, external tools, or the
panel's own /panel/api/inbounds API — loads with an empty (required) Target
field even though xray is running fine. Re-saving then serializes the blank
`target` and drops the working `dest`, breaking REALITY on the next restart.

Normalize `dest` -> `target` on parse (z.preprocess) when `target` is
absent/empty, matching xray-core's alias behavior. Add unit tests covering
the schema directly and through the security discriminated union.

Co-authored-by: Volov <volovdata@google.com>
2026-06-15 00:25:10 +02:00
Rouzbeh† dab0add191 feat(finalmask): support Salamander packetSize (Gecko) and Realm tlsConfig for Hysteria2 (#5278)
* feat(finalmask): support salamander packetSize (Gecko) and realm tlsConfig

Hysteria v2.9.1/v2.9.2 added two finalmask features that the pinned
Xray-core (26.6.1, 94ffd50) already supports but the panel UI did not
expose: Salamander's packetSize range (Gecko, XTLS/Xray-core#6198) and
the Realm UDP hole-punching mask's optional tlsConfig (XTLS/Xray-core#6137).

Add typed schemas and form fields for both, keeping UdpMaskSchema.settings
permissive per the existing finalmask design note. packetSize reuses the
existing dash-range preprocess (like udpHop.ports) so it round-trips under
the fm= share-link param with no new URI key; realm tlsConfig emits xray's
flat TLSConfig shape (serverName/alpn/fingerprint/allowInsecure).

Verified against the bundled Xray 26.6.1: configs with packetSize and
realm tlsConfig validate (Configuration OK.), plain salamander stays
backward-compatible, and a malformed packetSize is correctly rejected by
the salamander mask builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(finalmask): add snapshots for salamander-gecko and realm-tls fixtures

vitest run does not auto-create missing snapshots in CI mode, so the two
new fixtures need committed snapshot entries. Verified under node:22 that
finalmask.test.ts passes (6/6) with these snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(finalmask): polished Gecko UX with core-grounded validation

Fold PR #5281's Gecko work into the Realm tlsConfig base:

- Replace the plain packetSize input with a Salamander/Gecko mode
  selector and validated Min/Max number inputs.
- parseGeckoPacketSize enforces xray-core's real bound
  (1 <= min <= max <= 2048, the gecko buffer size) so the panel
  rejects configs core would reject at runtime.
- Accurate Gecko description; add parser unit tests.
- Drop the unused Salamander/Realm settings schemas; settings stay
  permissive and are validated at the form level.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 00:21:31 +02:00
Nikan Zeyaei 7c737820d1 fix(links): bracket ipv6 hosts in share links and qr codes (#5310)
* fix(sub): bracket ipv6 hosts in share links

* fix(frontend): bracket ipv6 hosts in share links
2026-06-14 23:38:58 +02:00
MHSanaei 335470607f fix(ui): match node connection-outbound picker to panel-outbound selector
Group the tags into Outbounds/Balancers, hide blackhole outbounds, and show
the 'Direct connection' placeholder on empty via getValueProps so the field
never looks unset and an empty default can't read as a second 'direct'.
2026-06-14 23:25:37 +02:00
Nikan Zeyaei 05ad7f417c feat(node): per node outbound routing (#5275)
* feat: add per-node outbound routing for panel-to-node connections

* feat(ui): add outbound tag selector to node form with i18n

* fix(xray): avoid potential overflow warning in node egress rule allocation

* chore: run "npm run gen"

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 23:10:52 +02:00
n0ctal 2188830612 perf(db): index group_name and client_traffics hot columns (#5268)
* perf(db): index group_name and client_traffics hot columns

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 22:54:59 +02:00
n0ctal d14f341b21 refactor(web): centralize background job cadences (#5269) 2026-06-14 22:50:24 +02:00
Nikan Zeyaei f4bbaf40f0 feat(ui): show per-inbound live speed (#5261)
* feat(utils): add speedFormat utility and tests

* feat(inbounds): add InboundSpeedEntry type

* feat(inbounds): add speed column to inbound list

* feat(inbounds): show speed in inbound stats modal

* feat(inbounds): compute inbound speed from traffic deltas

* feat(inbounds): wire inbound speed through page

* feat(i18n): add speed translation for all locales

* refactor(inbounds): dedupe live-speed UI and harden formatting

Extract a shared InboundSpeedTag component and isActiveSpeed guard used by the speed column and stats modal, unify InboundSpeedEntry into a single type, and route speedFormat through sizeFormat.

Also guard sizeFormat against non-finite input (no more "NaN PB/s") and clear stale per-inbound speeds when a traffic poll returns no deltas.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 22:39:40 +02:00
MHSanaei 1c75034957 ci(smoke): retry transient GitHub download failures
The first-boot smoke test and install.sh fetched the released binary with
a single curl attempt, so a transient GitHub/CDN 504 failed the whole job.

- smoke-firstboot.sh: add --retry/--retry-all-errors with connect/max
  timeouts to the version API and tarball downloads, split the download
  into a guarded step, and assert the tarball is non-empty.
- install.sh: add --retry plus connect/max timeouts to the release-binary
  downloads and version lookups. Omit --retry-all-errors here for curl
  < 7.71 (Ubuntu 20.04 / Debian 10 / CentOS 7) compatibility; plain
  --retry already covers 504 and other transient errors.
2026-06-14 21:17:59 +02:00
Pavel 7f34c306d7 feat(docker): support XUI_PORT runtime override (#5240)
* feat(docker): support XUI_PORT runtime override

Allow deployments to select the panel listener port without mutating the persisted webPort setting. Invalid values fall back to the database-backed port and are covered by parser boundary tests.

* docs: describe XUI_PORT deployment usage

Add commented local and Compose examples, explain runtime precedence, and call out matching Docker bridge port mappings.
2026-06-14 21:15:08 +02:00
MHSanaei a133282fc3 ci(smoke): set least-privilege GITHUB_TOKEN permissions
Add a top-level `permissions: contents: read` block so the smoke-test
workflow no longer inherits the repository default token permissions.
Resolves CodeQL actions/missing-workflow-permissions.
2026-06-14 21:09:00 +02:00
MHSanaei dcb923b4a1 feat(sub): per-client external links and remote subscriptions
Add a Links tab to the client form for attaching third-party share
links and remote subscription URLs per client. They are merged into
the client's raw/JSON/Clash subscription output: links are emitted
verbatim and parsed for JSON/Clash; subscription URLs are fetched
(cached, with a short timeout) and their configs merged in.

i18n keys added across all 13 locales.
2026-06-14 20:57:14 +02:00
Sanaei 7c2598fae9 feat: release-driven golden-image & unattended-install deployment pipeline (#5323)
* feat(install): add non-interactive install path for cloud/golden-image use

Trigger non-interactive mode when XUI_NONINTERACTIVE=1 or stdin is not a
TTY (curl | bash, cloud-init). Every prompt is then replaced by an env var
or a sane default; interactive prompts stay byte-for-byte identical.

Honored env vars: XUI_USERNAME, XUI_PASSWORD, XUI_PANEL_PORT,
XUI_WEB_BASE_PATH (unset => random, as before), XUI_SSL_MODE=none|ip|domain
(default none), XUI_DOMAIN, XUI_ACME_EMAIL, XUI_DB_TYPE/XUI_DB_DSN, plus
additive XUI_ACME_HTTP_PORT, XUI_SSL_IPV6, XUI_SERVER_IP.

On success, write /etc/x-ui/install-result.env (mode 600) with the panel
creds + access URL + api token, in both interactive and non-interactive
modes, so cloud-init/MOTD can surface them. Postgres in non-interactive
mode requires XUI_DB_DSN or installs locally; never silently downgrades.

* feat(deploy): add first-boot per-instance credential generation

Golden images ship with no x-ui.db. x-ui-firstboot.sh runs once (guarded by
/etc/x-ui/.firstboot-done), before x-ui.service, and replaces the seeded
admin/admin with fresh random username/password on a random high port,
regenerates the session secret/panel GUID via 'x-ui setting -reset', mints an
API token, and writes the creds to /etc/x-ui/credentials.txt (600) + /etc/motd.

Idempotent: skips regeneration if a non-default admin already exists. The
oneshot unit is ordered After=network-online/cloud-init and Before=x-ui.service
so the panel never serves default credentials.

* chore(deploy): force LF for cloud-image deploy assets (.service/.hcl/.yaml)

* feat(deploy): add Packer config + provisioning scripts for golden image

One build, two sources: amazon-ebs (AWS AMI, Canonical Ubuntu 24.04 base via
source_ami_filter) and qemu (qcow2 + raw, NoCloud-seeded for build-time SSH).
Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.

- provision.sh: downloads the released x-ui tarball (no Go build), installs the
  panel + firstboot unit, enables but does NOT start services, creates NO DB.
- harden.sh: key-only SSH, no root password login, locks default account
  passwords, enables unattended-upgrades (scanner-compliant).
- cleanup.sh: wipes any DB/creds, SSH host keys, authorized_keys, machine-id,
  cloud-init state, logs and history; fails the build if any secret survives.

packer fmt -check clean; packer validate passes for both sources.

* feat(deploy): add generic cloud-init user-data for unattended install

cloud-init.yaml installs the latest 3x-ui non-interactively (XUI_NONINTERACTIVE=1)
on any cloud-init platform, generating unique per-instance credentials and
surfacing them via /etc/x-ui/install-result.env, serial console and MOTD.
README documents per-provider usage (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
and all XUI_* knobs.

* ci: add image.yml to build cloud images on release

On release: published (or workflow_dispatch with a tag), waits for the
x-ui-linux-amd64.tar.gz asset (handles the release-matrix upload race), then:
- qemu-image (always): builds the qcow2 with Packer and attaches a compressed
  .qcow2.xz + sha256 to the GitHub release. Uses KVM when /dev/kvm exists,
  else TCG.
- ami-image (gated): builds the AWS AMI only when AWS creds exist (OIDC role
  preferred, else access keys), so forks skip cleanly. Prints the AMI ID to the
  job summary. No secrets or AMI IDs are committed.

* test(deploy): add container smoke tests for install + firstboot

smoke-noninteractive.sh: runs install.sh piped (no TTY) with
XUI_NONINTERACTIVE=1 in an Ubuntu container; asserts install-result.env (600)
holds random non-default creds, hasDefaultCredential is false, and the panel
serves HTTP.

smoke-firstboot.sh: installs the released binary with no DB, runs
x-ui-firstboot.sh; asserts per-instance creds + credentials.txt (600) + MOTD,
no admin/admin, and that a second run is a no-op (sentinel honored).

smoke.yml runs both as gated jobs on PRs/pushes touching install.sh or deploy/**.
Both pass locally against the v3.3.1 release binary.

* docs(deploy): add Packer/marketplace docs and link from README

- deploy/README.md: index of the cloud-deploy tooling and the two models
- deploy/packer/README.md: how to build locally, variables, first-boot behavior
- deploy/marketplace/aws/README.md: seller registration -> AMI scan ->
  limited-visibility preview -> go-public checklist
- deploy/marketplace/hetzner/README.md: cloud-init-first guidance + snapshot
  caveat (delete x-ui.db first) + hetznercloud/apps reference
- README.md: link the unattended-install / cloud-image docs from Quick Start

* feat(deploy): build golden images for arm64 as well as amd64

The install path was already multi-arch (install.sh auto-detects arch); this
extends the golden image + CI to arm64:

- packer: xui_arch (amd64|arm64, validated) now derives the base AMI filter and
  the Ubuntu cloud image; the qemu source switches to qemu-system-aarch64 + virt
  machine + AAVMF UEFI firmware for arm64. amd64 path unchanged.
- image.yml: arch matrix. AMIs for amd64 (t3.small) + arm64 (t4g.small/Graviton)
  from one runner; qcow2 for amd64 on a standard runner and arm64 on a native
  ubuntu-24.04-arm runner. Waits for both release tarballs.
- smoke.yml: run install + firstboot smoke tests on amd64 and arm64 runners;
  smoke-firstboot.sh now resolves the arch tarball via dpkg.
- docs updated for both arches.

packer fmt/validate pass for amd64 and arm64; actionlint + shellcheck clean.
Verified locally: non-interactive install AND firstboot run on the real arm64
release binary under emulation (ELF aarch64, no admin/admin).

* chore(deploy): default AWS region to eu-central-1 (Frankfurt)

Replace the us-east-1 fallback in image.yml (4 sites) and the Packer 'region'
default + doc examples. Still overridable via the AWS_REGION repo variable / the
-var 'region=...' flag.

* feat(deploy): add Amazon Lightsail support (launch script + snapshot builder)

Lightsail can't launch from an EC2 AMI and its blueprint list isn't
self-publishable, so add the two self-service paths instead:

- launch-script.sh: paste into Lightsail 'Add launch script' (or --user-data) to
  install 3x-ui non-interactively with unique per-instance credentials.
- snapshot-userdata.sh + build-snapshot.sh: AWS CLI pipeline that provisions a
  build instance (panel installed, NO DB, firstboot enabled), runs the shared
  cleanup.sh, then snapshots it. Instances launched from the snapshot mint their
  own credentials on first boot. Optional --panel-port pins a known port for the
  Lightsail firewall.
- README documents both paths, the firewall caveat, and the blueprint reality.

EC2 AMI / Marketplace path kept untouched alongside. All scripts shellcheck-clean.

* fix(deploy): address Copilot PR review findings

- install.sh + firstboot: write install-result.env / credentials.txt values with
  printf %q so the files stay safe to source even if creds are pinned with shell
  metacharacters (no-op for the alphanumeric random defaults).
- firstboot: fail closed if 'x-ui setting -show' can't be parsed to true/false —
  exit without writing the sentinel so the next boot retries, instead of silently
  skipping regeneration and risking admin/admin.
- firstboot + cloud-init + lightsail launch-script: keep secrets out of the
  world-readable /etc/motd (show URL + username only; full creds via the mode-600
  file / serial console).
- lightsail build-snapshot: handle download-default-key-pair returning either a
  PEM or base64, and assert a valid PEM before using it for SSH.
- image.yml: pin hashicorp/setup-packer@v3 (was @main).
- deploy/README: document XUI_ACME_HTTP_PORT / XUI_SSL_IPV6 / XUI_SERVER_IP.

Both container smoke tests still pass; shellcheck + actionlint clean.
2026-06-14 18:08:35 +02:00
MHSanaei 1c0fdb4527 fix(outbounds): test subscriptions in Test All, skip direct/dns
Test All only iterated the editable template outbounds, so subscription
outbounds (the read-only "from subscriptions" table) were never probed in
bulk. They are now queued too, keyed by tag in subscriptionTestStates so
their rows light up live; the template and subscription HTTP lanes run
serially to respect the backend's single-batch lock (TCP runs alongside).

Also stop testing freedom ("direct") and dns outbounds: they aren't
proxies, so an HTTP probe through them only measures the host's own
reachability, not a tunnel. They are now untestable in every mode -- the
per-row button is disabled and Test All skips them -- with a matching
backend guard so a direct API caller can't HTTP-test them either.
2026-06-13 11:48:02 +02:00
MHSanaei 2d6dea4bf6 fix(settings): rename remark model 'Other' to 'External Proxy' (#5265)
The 'o' remark block is sourced from an external proxy's remark, but the
label 'Other' gave no hint where to set it. Rename the display label to
'External Proxy' to match the inbound form section; the stored 'o' key is
unchanged so existing remarkModel values stay compatible.
2026-06-13 11:14:22 +02:00
MHSanaei 4c8d3cb625 fix(nodes): honor TLS verify mode skip/pin for remote node operations (#5264)
The node probe honored the per-node TlsVerifyMode (skip/pin) but
runtime.Remote used a shared client with no TLSClientConfig, so traffic
sync and every other remote op fell back to system-CA verification and
failed against self-signed nodes even after the operator set skip/pin.

Move the TLS client builder into the runtime layer (HTTPClientForNode /
DecodeCertPin) as the single source of truth, have Remote build and cache
its per-node client through it, and delegate the service probe to the same
builder so the two paths can no longer diverge.
2026-06-13 11:11:02 +02:00
MHSanaei 9a8247fa78 fix(tgbot): clear legacy panelProxy/tgBotProxy settings on upgrade
v3.3.1 removed the Panel Proxy URL field from the UI but left the stored
panelProxy/tgBotProxy values in the DB. The Telegram bot still reads
tgBotProxy directly, so a stale value masked the panelOutbound egress
fallback. Add a one-off seeder to drop both rows.

Closes #5266
2026-06-13 10:56:02 +02:00
MHSanaei 355262e632 fix(clients): keep the client list live with a background poll (#5262)
The paged client list is sorted/paginated server-side but fetched with staleTime: Infinity, so the WS client_stats patch only refreshed traffic on already-visible rows — newly connected clients never appeared and the sort order went stale until a manual refresh.

Add a 5s refetchInterval so the current page tracks reality, and drive the table overlay off isPlaceholderData so the background poll does not flash it.
2026-06-13 10:42:24 +02:00
MHSanaei 8f556fe2db fix(clients): centre the online dot inside the Online tag (#5238)
The .online-dot uses vertical-align: middle, which in inline layout
aligns to baseline + half x-height — visibly off-centre inside the Ant
Tag's line box. Add a .dot-tag utility (inline-flex, align-items:
center) and apply it to the Online tag so the dot and label share one
centred axis. Other dot usages (Nodes page Space, card heads, stat
rows) already sit in flex containers and are unaffected.
2026-06-13 01:19:19 +02:00
MHSanaei b770287995 fix(sub): stop appending the node name to subscription remarks (#5231)
The #5035 change tagged node-hosted entries with the node name to
disambiguate multi-node subscriptions, but the node name is
panel-internal and leaked into the profile names end users see in
their client apps. Drop the suffix entirely — remarks are the
admin-set inbound remark again.
2026-06-12 22:35:41 +02:00
MHSanaei 3c68b039f6 fix(sub): deliver vision flow for VLESS+XHTTP+REALITY in share links and Clash (#5232)
The vlessenc fix (#5185) enabled flow on XHTTP only in the security=none
branch of genVlessLink, and the Clash builder still gated flow on
network==tcp. With XHTTP+REALITY+vlessenc the panel accepts and stores
the flow (inboundCanEnableTlsFlow passes), but subscriptions dropped it,
so clients received configs without xtls-rprx-vision.

Add vlessFlowAllowed mirroring inboundCanEnableTlsFlow — tcp with
tls/reality, or xhttp with vlessenc regardless of security layer — and
use it in both the vless:// link generator and the Clash proxy builder.
2026-06-12 22:25:04 +02:00
MHSanaei c200e248f7 fix(script): report per-file geo update status and skip restart when nothing changed
Updating geo files printed raw curl progress meters showing 0 bytes when
files were already current (curl -z conditional download), claimed success
unconditionally, and restarted xray even when nothing was downloaded —
confusing enough to be reported as a bug (#5230).

Now each file reports updated / already up to date / download failed,
failures no longer print a success message, and the restart (which drops
live connections) only happens when a file actually changed. Same for the
non-interactive 'x-ui update-all-geofiles' command.
2026-06-12 22:18:42 +02:00
307 changed files with 24526 additions and 2327 deletions
+1
View File
@@ -3,3 +3,4 @@ XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080
+7 -1
View File
@@ -3,4 +3,10 @@ DockerInit.sh text eol=lf
DockerEntrypoint.sh text eol=lf
frontend/src/generated/** text eol=lf
frontend/public/openapi.json text eol=lf
frontend/src/test/__snapshots__/** text eol=lf
frontend/src/test/__snapshots__/** text eol=lf
# Cloud-image deploy assets are consumed on Linux — force LF regardless of host.
*.service text eol=lf
deploy/**/*.service text eol=lf
deploy/**/*.hcl text eol=lf
deploy/**/*.yaml text eol=lf
+34 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
codegen:
runs-on: ubuntu-latest
@@ -69,6 +69,39 @@ jobs:
- name: Run govulncheck
run: govulncheck ./...
# Race + shuffle hygiene gate: data races and order-dependent tests fail the build.
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Race + shuffle
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Fuzz critical parsers (smoke)
run: |
go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
frontend:
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
if: matrix.language == 'go'
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
+260
View File
@@ -0,0 +1,260 @@
name: Build Cloud Images
# Build golden cloud images from a published release, for amd64 and arm64:
# * qemu -> qcow2 attached to the GitHub release (always)
# * amazon-ebs -> AWS AMI (only when AWS credentials are configured)
#
# Images contain NO database and NO baked credentials; first boot generates
# unique per-instance credentials (see deploy/firstboot + deploy/packer).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to build images for (e.g. v3.3.1)"
required: true
type: string
permissions:
contents: write
concurrency:
group: image-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
# Resolve the tag and wait until BOTH arch tarballs are actually published
# (the release matrix uploads assets one by one, so 'published' can fire
# before the tarballs exist).
setup:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.resolve.outputs.tag }}
steps:
- name: Resolve tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
else
TAG="${{ inputs.tag }}"
fi
[ -n "$TAG" ] || { echo "::error::no tag resolved"; exit 1; }
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Wait for released binary assets (amd64 + arm64)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
want="x-ui-linux-amd64.tar.gz x-ui-linux-arm64.tar.gz"
for i in $(seq 1 30); do
names=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')
missing=""
for w in $want; do
echo "$names" | grep -qx "$w" || missing="$missing $w"
done
if [ -z "$missing" ]; then
echo "All assets present on $TAG"
exit 0
fi
echo "Waiting for$missing on $TAG ($i/30)..."
sleep 20
done
echo "::error::missing release assets on $TAG after 10 minutes:$missing"
exit 1
# Gate the AWS AMI build so forks without secrets skip it cleanly
# (secrets cannot be referenced directly in job-level `if`).
check-aws:
runs-on: ubuntu-latest
outputs:
enabled: ${{ steps.c.outputs.enabled }}
use_oidc: ${{ steps.c.outputs.use_oidc }}
steps:
- id: c
env:
ROLE: ${{ secrets.AWS_ROLE_ARN }}
KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: |
if [ -n "$ROLE" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "use_oidc=true" >> "$GITHUB_OUTPUT"
elif [ -n "$KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "use_oidc=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "use_oidc=false" >> "$GITHUB_OUTPUT"
echo "::notice::No AWS credentials configured; skipping the AMI build."
fi
qemu-image:
needs: setup
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
qemu_pkgs: qemu-system-x86 qemu-utils
- arch: arm64
runner: ubuntu-24.04-arm
qemu_pkgs: qemu-system-arm qemu-efi-aarch64 qemu-utils
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install QEMU
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ${{ matrix.qemu_pkgs }}
- name: Setup Packer
uses: hashicorp/setup-packer@v3
with:
version: latest
- name: Verify released binary asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
mkdir -p _asset
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
ls -la _asset
- name: Select accelerator
id: accel
run: |
if [ -e /dev/kvm ]; then echo "value=kvm" >> "$GITHUB_OUTPUT"; else echo "value=tcg" >> "$GITHUB_OUTPUT"; fi
- name: Packer init
run: packer init deploy/packer/
- name: Build qcow2 image
env:
TAG: ${{ needs.setup.outputs.tag }}
ACCEL: ${{ steps.accel.outputs.value }}
run: |
packer build -only='qemu.x-ui' \
-var "xui_version=${TAG}" \
-var "xui_arch=${{ matrix.arch }}" \
-var "qemu_accelerator=${ACCEL}" \
deploy/packer/
- name: Compress qcow2
id: pack
env:
TAG: ${{ needs.setup.outputs.tag }}
run: |
cd deploy/packer/output-qemu
src="3x-ui-ubuntu-24.04-${{ matrix.arch }}.qcow2"
out="3x-ui-ubuntu-24.04-${TAG}-${{ matrix.arch }}.qcow2.xz"
xz -T0 -6 -c "$src" > "$out"
sha256sum "$out" > "${out}.sha256"
echo "file=deploy/packer/output-qemu/${out}" >> "$GITHUB_OUTPUT"
echo "sha=deploy/packer/output-qemu/${out}.sha256" >> "$GITHUB_OUTPUT"
ls -la
- name: Attach qcow2 to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \
"${{ steps.pack.outputs.file }}" "${{ steps.pack.outputs.sha }}"
- name: Summary
env:
TAG: ${{ needs.setup.outputs.tag }}
ACCEL: ${{ steps.accel.outputs.value }}
run: |
{
echo "## QEMU image (${{ matrix.arch }})"
echo "- Tag: \`${TAG}\`"
echo "- Accelerator: \`${ACCEL}\`"
echo "- Attached: \`$(basename "${{ steps.pack.outputs.file }}")\`"
} >> "$GITHUB_STEP_SUMMARY"
ami-image:
needs: [setup, check-aws]
if: needs.check-aws.outputs.enabled == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
instance_type: t3.small
- arch: arm64
instance_type: t4g.small
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Packer
uses: hashicorp/setup-packer@v3
with:
version: latest
- name: Configure AWS credentials (OIDC)
if: needs.check-aws.outputs.use_oidc == 'true'
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
- name: Configure AWS credentials (access keys)
if: needs.check-aws.outputs.use_oidc != 'true'
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
- name: Verify released binary asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
mkdir -p _asset
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
ls -la _asset
- name: Packer init
run: packer init deploy/packer/
- name: Build AMI
env:
TAG: ${{ needs.setup.outputs.tag }}
REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
run: |
packer build -only='amazon-ebs.x-ui' \
-var "xui_version=${TAG}" \
-var "xui_arch=${{ matrix.arch }}" \
-var "instance_type=${{ matrix.instance_type }}" \
-var "region=${REGION}" \
deploy/packer/
- name: Publish AMI id to summary
env:
REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
run: |
AMI_ID=$(jq -r '.builds[] | select(.builder_type=="amazon-ebs") | .artifact_id' packer-manifest.json | tail -1 | cut -d: -f2)
{
echo "## AWS AMI (${{ matrix.arch }})"
echo "- Region: \`${REGION}\`"
echo "- Instance type: \`${{ matrix.instance_type }}\`"
echo "- AMI ID: \`${AMI_ID}\`"
} >> "$GITHUB_STEP_SUMMARY"
+62
View File
@@ -0,0 +1,62 @@
name: Mutation testing
# Mutation testing (gremlins) is the objective check for "fake" tests: it mutates the
# source and a surviving (LIVED) mutant means no test caught the change. It is SLOW, so it
# runs nightly / on demand and scoped per package — never per-commit. It is informational:
# no thresholds are set, so it reports survivors as artifacts without failing the build.
on:
schedule:
- cron: "0 3 * * *" # 03:00 UTC daily
workflow_dispatch:
permissions:
contents: read
jobs:
gremlins:
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: sub
path: ./internal/sub/
exclude: ""
- name: runtime
path: ./internal/web/runtime/
exclude: ""
- name: link
path: ./internal/util/link/
exclude: ""
- name: database
path: ./internal/database/
exclude: 'dump_sqlite\.go'
- name: service
path: ./internal/web/service/
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Install gremlins
run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0
- name: Run gremlins on ${{ matrix.name }}
run: |
OUT="mutation-${{ matrix.name }}.json"
if [ -n "${{ matrix.exclude }}" ]; then
gremlins unleash -E '${{ matrix.exclude }}' -o "$OUT" ${{ matrix.path }}
else
gremlins unleash -o "$OUT" ${{ matrix.path }}
fi
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-${{ matrix.name }}
path: mutation-${{ matrix.name }}.json
if-no-files-found: ignore
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
@@ -210,7 +210,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
+44
View File
@@ -0,0 +1,44 @@
name: Deploy Smoke Tests
# Container smoke tests for the unattended install path and first-boot
# credential generation. Runs only when the install/deploy assets change.
on:
push:
paths:
- "install.sh"
- "deploy/**"
- ".github/workflows/smoke.yml"
pull_request:
paths:
- "install.sh"
- "deploy/**"
- ".github/workflows/smoke.yml"
permissions:
contents: read
jobs:
noninteractive-install:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Non-interactive install smoke test
run: bash deploy/test/smoke-noninteractive.sh
first-boot:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: First-boot credential smoke test
run: bash deploy/test/smoke-firstboot.sh
+51
View File
@@ -73,6 +73,7 @@ XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080
```
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
@@ -235,6 +236,49 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `internal/config/` | Environment-variable helpers, paths, defaults |
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
## Testing
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
### Go conventions
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
### Running
| Goal | Command |
|------|---------|
| Standard run | `go test ./...` |
| Hygiene — data races + order-dependence | `go test -race -shuffle=on -count=1 ./...` (`-race` needs the C compiler from Prerequisites) |
| Coverage gaps | `go test -coverprofile=cov.out ./<pkg>/... && go tool cover -func=cov.out` |
| Fuzz a parser briefly | `go test -run '^$' -fuzz 'FuzzName$' -fuzztime=30s ./<pkg>/...` |
Frontend: `cd frontend && npm run test` (vitest), or `npm run test -- --coverage`.
### Property and fuzz tests
Input-heavy or pure logic (link builders, parsers, decoders) is also covered by **property tests** (`pgregory.net/rapid`) and **native fuzz targets** (`go test -fuzz`). A fuzz target's **seed corpus** (its inline `f.Add` cases plus any `testdata/fuzz` entries) runs as ordinary subtests under a plain `go test` — no `-fuzz` flag needed — so CI's normal test job exercises the seeds; the time-boxed *fuzzing* exploration (`-fuzz=...`) runs separately as the `fuzz-smoke` job.
### Mutation testing (optional, manual)
[gremlins](https://github.com/go-gremlins/gremlins) checks whether tests actually fail when the code is mutated — a surviving (`LIVED`) mutant means a weak test. It is **slow**, so run it **scoped per package**, never repo-wide or per-commit:
```bash
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
gremlins unleash ./internal/sub/
gremlins unleash -E 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' ./internal/web/service/
```
Treat each survivor as one of: a weak test (strengthen it), dead code (remove it), or an equivalent mutant (unkillable — leave it). Don't write a test purely to kill a mutant if it doesn't reflect real behavior.
CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml` — scoped per package, results uploaded as artifacts. It is **informational**, not a gate (no thresholds), so check the reports when hardening a suite rather than waiting for a red build.
### CI
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
## Sending a pull request
1. Branch off `main` (e.g. `feat/short-description`).
@@ -256,9 +300,16 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
| `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
| `XUI_PORT` | persisted `webPort` | Runtime-only web panel listener port override (`1` through `65535`) |
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
A valid `XUI_PORT` takes precedence over the database-backed `webPort` for the
current process without changing the stored setting. Unset, empty, whitespace-only,
malformed, or out-of-range values fall back to `webPort`; invalid configured values
also produce a warning. With Docker bridge networking, the published container port
must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080"]`.
## Issues
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
+6 -5
View File
@@ -44,23 +44,24 @@ before = iptables-allports.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -j f2b-<name>
<iptables> -I <chain> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actionstop = <iptables> -D <chain> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -I f2b-<name> 1 -s <ip> -p udp -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
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -D f2b-<name> -s <ip> -p udp -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
[Init]
name = default
protocol = tcp
chain = INPUT
exemptports = $EXEMPT_PORTS
EOF
+12
View File
@@ -77,6 +77,18 @@ During installation a random username, password, and access path are generated.
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
### Unattended install & cloud images
The installer also runs **non-interactively** for cloud-init and golden images.
Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with
zero prompts, generating random credentials and writing them to
`/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for:
- [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [Packer golden image](deploy/packer/) — build an AWS EC2 AMI + qcow2 (amd64/arm64) with per-instance credentials generated on first boot
- [Amazon Lightsail](deploy/lightsail/) — launch script + reusable snapshot builder
- [AWS Marketplace checklist](deploy/marketplace/aws/)
## Supported Platforms
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
+38
View File
@@ -0,0 +1,38 @@
# Cloud deployment & golden images
Tooling to ship the 3x-ui panel as a cloud image or via unattended install,
with **per-instance credentials generated on first boot** (never `admin/admin`,
never a shared session secret). Everything here supports **amd64 and arm64**.
| Path | What it is | Use when |
| --- | --- | --- |
| [`cloud-init/`](cloud-init/) | Generic cloud-init user-data (unattended `install.sh`) | Any cloud, no image build |
| [`packer/`](packer/) | Packer build → AWS AMI + qcow2/raw | Reusable / Marketplace images |
| [`lightsail/`](lightsail/) | Launch script + snapshot builder | Amazon Lightsail |
| [`firstboot/`](firstboot/) | First-boot unit + script that mints per-instance creds | Used by the Packer/Lightsail images |
| [`marketplace/aws/`](marketplace/aws/) | AWS Marketplace submission checklist | Publishing an EC2 AMI |
| [`marketplace/hetzner/`](marketplace/hetzner/) | Hetzner Cloud notes | Hetzner deployments |
| [`test/`](test/) | Container smoke tests | Verifying the install/firstboot paths |
## Two models
- **Non-interactive install (cloud-init):** `install.sh` runs unattended when
`XUI_NONINTERACTIVE=1` or stdin is not a TTY. Each instance installs and
configures itself with random credentials. See [`cloud-init/README.md`](cloud-init/README.md).
- **Golden image (Packer):** the image contains the panel but **no DB and no
secrets**; `firstboot` generates unique credentials on first boot. See
[`packer/README.md`](packer/README.md).
## Unattended install knobs
`install.sh` reads these env vars in non-interactive mode (all optional; unset ⇒
secure random / default):
`XUI_USERNAME`, `XUI_PASSWORD`, `XUI_PANEL_PORT`, `XUI_WEB_BASE_PATH`,
`XUI_SSL_MODE` (`none`|`ip`|`domain`, default `none`), `XUI_DOMAIN`,
`XUI_ACME_EMAIL`, `XUI_ACME_HTTP_PORT` (ACME HTTP-01 listener port, default `80`),
`XUI_SSL_IPV6` (optional IPv6 address to add to an `ip`-mode cert),
`XUI_SERVER_IP` (fallback IP for the displayed access URL when auto-detection fails),
`XUI_DB_TYPE` (`sqlite`|`postgres`), `XUI_DB_DSN`.
The resulting credentials are written to `/etc/x-ui/install-result.env` (mode 600).
+71
View File
@@ -0,0 +1,71 @@
# 3x-ui via cloud-init (generic, no golden image)
This is the **secondary** deployment path: a single [`cloud-init.yaml`](cloud-init.yaml)
user-data file that installs 3x-ui non-interactively on a fresh Ubuntu/Debian
VM and generates **unique random credentials per instance**. Use it when you do
not want to build a golden image — it works on any cloud-init platform.
> For AWS Marketplace / reusable images, use the Packer build in
> [`../packer/`](../packer/) instead.
## How it works
1. The VM boots a stock Ubuntu/Debian cloud image.
2. cloud-init writes and runs `/opt/xui-bootstrap.sh`, which exports
`XUI_NONINTERACTIVE=1` and pipes the project's `install.sh` into `bash`.
3. `install.sh` runs end-to-end with **zero prompts**, picking secure random
values for any credential you didn't pin.
4. The generated credentials are written to `/etc/x-ui/install-result.env`
(mode 600), echoed to the **serial console**, and appended to `/etc/motd`.
Retrieve them after boot with either:
```bash
sudo cat /etc/x-ui/install-result.env # over SSH
```
…or read the provider's **serial console** output (handy before you have SSH).
## Customising
Edit the `export XUI_*` lines inside the `write_files` block of
[`cloud-init.yaml`](cloud-init.yaml). All knobs are optional; unset ⇒ random/secure default.
| Env var | Default | Meaning |
| --- | --- | --- |
| `XUI_SSL_MODE` | `none` | `none` (plain HTTP), `ip` (Let's Encrypt IP cert), `domain` |
| `XUI_USERNAME` | random | Admin username |
| `XUI_PASSWORD` | random | Admin password |
| `XUI_PANEL_PORT` | random high port | Panel listen port |
| `XUI_WEB_BASE_PATH` | random | Panel base path (obscures the URL) |
| `XUI_DOMAIN` | — | Required when `XUI_SSL_MODE=domain` |
| `XUI_ACME_EMAIL` | — | Let's Encrypt account email (domain mode) |
| `XUI_DB_TYPE` / `XUI_DB_DSN` | `sqlite` | Set `postgres` + DSN to use PostgreSQL |
> **TLS note:** `none` serves the panel over plain HTTP on a random high port —
> fine behind a reverse proxy or an SSH tunnel, but put TLS in front of it before
> exposing the panel publicly. `domain` mode needs a public DNS A record pointing
> at the box and port 80 reachable at install time.
## Per-provider usage
- **Hetzner Cloud** — *Create Server → Cloud config*: paste the file. Or CLI:
`hcloud server create --image ubuntu-24.04 --user-data-from-file cloud-init.yaml ...`
- **AWS EC2** — *Advanced details → User data*: paste the file. Or
`aws ec2 run-instances --user-data file://cloud-init.yaml ...`
(For a reusable Marketplace image use the Packer AMI build instead.)
- **DigitalOcean** — *Create Droplet → Advanced options → Add Initialization
scripts (user data)*: paste the file. Or `doctl compute droplet create --user-data-file cloud-init.yaml ...`
- **Vultr** — *Deploy → Additional Features → Cloud-Init User-Data*: paste the file.
- **Google Cloud (GCE)** — `gcloud compute instances create xui \
--image-family ubuntu-2404-lts-amd64 --image-project ubuntu-os-cloud \
--metadata-from-file user-data=cloud-init.yaml`
- **Azure** — `az vm create --image Ubuntu2404 --custom-data cloud-init.yaml ...`
- **Oracle Cloud (OCI)** — *Create Instance → Show advanced options →
Management → Cloud-init script*: paste (or base64-upload) the file.
## Validate before you deploy
```bash
cloud-init schema --config-file deploy/cloud-init/cloud-init.yaml
```
+78
View File
@@ -0,0 +1,78 @@
#cloud-config
# ---------------------------------------------------------------------------
# Generic 3x-ui unattended install via cloud-init user-data.
#
# Works on any cloud-init platform: Hetzner, AWS, DigitalOcean, Vultr, GCP,
# Azure, Oracle. Paste the whole file as the instance "user data".
#
# It installs the latest 3x-ui release NON-INTERACTIVELY, generating unique
# random credentials per instance. Full credentials are surfaced ONLY on the
# serial console (owner-only); /etc/motd (world-readable) shows just the access
# URL + username. Nothing is baked in advance — every instance is unique.
#
# Requires the non-interactive install.sh (3x-ui with XUI_NONINTERACTIVE support).
# Edit the exported XUI_* knobs in /opt/xui-bootstrap.sh below to customise.
# ---------------------------------------------------------------------------
package_update: true
package_upgrade: false
write_files:
- path: /opt/xui-bootstrap.sh
permissions: '0700'
owner: root:root
content: |
#!/usr/bin/env bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# --- Non-interactive install knobs --------------------------------------
export XUI_NONINTERACTIVE=1
# SSL mode: none (plain HTTP, default) | ip | domain
export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
# Pin credentials instead of random (leave unset for secure random values):
# export XUI_USERNAME="admin2"
# export XUI_PASSWORD="change-me-please"
# export XUI_PANEL_PORT="2053"
# export XUI_WEB_BASE_PATH="panel"
# Let's Encrypt domain certificate instead of plain HTTP:
# export XUI_SSL_MODE="domain"
# export XUI_DOMAIN="panel.example.com"
# export XUI_ACME_EMAIL="you@example.com"
# PostgreSQL instead of SQLite:
# export XUI_DB_TYPE="postgres"
# export XUI_DB_DSN="postgres://user:pass@host:5432/db?sslmode=disable"
# ------------------------------------------------------------------------
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
# Surface the generated credentials. Full creds (incl. password + API token)
# go ONLY to the serial console (/dev/console, owner-only). /etc/motd is
# world-readable, so it gets just the access URL + username and a pointer
# to the root-only env file.
if [ -r /etc/x-ui/install-result.env ]; then
{
echo
echo "=== 3x-ui panel credentials (generated on first boot) ==="
cat /etc/x-ui/install-result.env
echo "========================================================"
echo "Change the password after first login."
} > /dev/console 2>/dev/null || true
# shellcheck disable=SC1091
. /etc/x-ui/install-result.env
{
echo
echo "=== 3x-ui panel (generated on first boot) ==="
echo "URL: ${XUI_ACCESS_URL:-unknown}"
echo "Username: ${XUI_USERNAME:-unknown}"
echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
echo "============================================="
echo "Change the password after first login."
} >> /etc/motd 2>/dev/null || true
fi
runcmd:
- [bash, /opt/xui-bootstrap.sh]
final_message: "3x-ui installed — full credentials in /etc/x-ui/install-result.env (sudo); /etc/motd shows the URL + username only."
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=3x-ui first-boot per-instance credential generation
Documentation=https://github.com/MHSanaei/3x-ui
# Run after the network and cloud-init are up, but BEFORE the panel starts, so
# the panel never serves the default admin/admin account.
After=network-online.target cloud-init.service
Wants=network-online.target
Before=x-ui.service
# Skip entirely once the sentinel exists (cheap guard; the script re-checks too).
ConditionPathExists=!/etc/x-ui/.firstboot-done
[Service]
Type=oneshot
RemainAfterExit=yes
# Inherit the same DB configuration the panel uses (sqlite default / postgres).
EnvironmentFile=-/etc/default/x-ui
EnvironmentFile=-/etc/conf.d/x-ui
EnvironmentFile=-/etc/sysconfig/x-ui
ExecStart=/usr/local/x-ui/x-ui-firstboot.sh
[Install]
WantedBy=multi-user.target
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
#
# x-ui-firstboot.sh — generate per-instance 3x-ui panel credentials on first boot.
#
# A golden image (AMI / qcow2) MUST ship without an initialized x-ui.db: the
# panel seeds a hardcoded admin/admin user and generates its session secret +
# panel GUID on first start, so a baked DB would make every clone share the same
# credentials and secret. This script runs ONCE, before x-ui.service starts, and
# replaces the default admin with fresh random credentials on a random high port.
#
# Idempotent: a sentinel file guards against re-running. If a non-default admin
# already exists (operator pre-configured the box), regeneration is skipped.
#
# Wired up by deploy/packer/scripts/provision.sh; ordered Before=x-ui.service.
set -u
SENTINEL="/etc/x-ui/.firstboot-done"
CRED_FILE="/etc/x-ui/credentials.txt"
MOTD_FILE="/etc/motd"
XUI_DIR="${XUI_MAIN_FOLDER:-/usr/local/x-ui}"
XUI_BIN="${XUI_DIR}/x-ui"
log() { echo "[x-ui-firstboot] $*"; }
# Already provisioned — nothing to do (idempotent on re-run / re-image).
if [ -f "$SENTINEL" ]; then
log "sentinel $SENTINEL present; skipping."
exit 0
fi
if [ ! -x "$XUI_BIN" ]; then
log "ERROR: x-ui binary not found at $XUI_BIN"
exit 1
fi
# Inherit DB configuration (sqlite default; postgres via XUI_DB_TYPE/XUI_DB_DSN)
# from the same env files the systemd unit loads, so the binary talks to the
# same database the panel will use.
for ef in /etc/default/x-ui /etc/conf.d/x-ui /etc/sysconfig/x-ui; do
if [ -r "$ef" ]; then
set -a
# shellcheck disable=SC1090
. "$ef"
set +a
fi
done
install -d -m 755 /etc/x-ui 2> /dev/null || true
# Defense-in-depth: make sure the panel is not running while we mutate the DB.
if command -v systemctl > /dev/null 2>&1; then
systemctl stop x-ui > /dev/null 2>&1 || true
fi
gen_random_string() {
local length="$1"
openssl rand -base64 $((length * 2)) | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
# Best-effort public IPv4 for the displayed access URL (cosmetic only — the
# panel binds 0.0.0.0). Falls back to the primary local IP, then a placeholder.
detect_ip() {
local ip=""
local url
for url in https://api4.ipify.org https://ipv4.icanhazip.com https://4.ident.me; do
ip=$(curl -fsS4 --max-time 3 "$url" 2> /dev/null | tr -d '[:space:]')
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "$ip"
return 0
fi
done
ip=$(hostname -I 2> /dev/null | awk '{print $1}')
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
echo "<server-ip>"
}
# Detect whether the seeded admin/admin default is still in place.
default_creds=$("$XUI_BIN" setting -show true 2> /dev/null | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
# The parse MUST yield exactly "true" or "false". If the command failed or its
# output format changed, refuse to proceed: do NOT write the sentinel, so the
# next boot retries instead of silently leaving admin/admin in place.
if [ "$default_creds" != "true" ] && [ "$default_creds" != "false" ]; then
log "ERROR: could not determine credential state (hasDefaultCredential='${default_creds}'); not writing sentinel, will retry next boot."
exit 1
fi
if [ "$default_creds" = "false" ]; then
log "non-default admin already configured; skipping credential regeneration."
{
echo "3x-ui first-boot: a non-default admin account already exists on this"
echo "instance, so credentials were left unchanged."
} > "$MOTD_FILE" 2> /dev/null || true
: > "$SENTINEL" 2> /dev/null || true
chmod 600 "$SENTINEL" 2> /dev/null || true
exit 0
fi
log "generating per-instance credentials..."
NEW_USER="${XUI_USERNAME:-$(gen_random_string 10)}"
NEW_PASS="${XUI_PASSWORD:-$(gen_random_string 16)}"
NEW_PATH="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}"
NEW_PORT="${XUI_PANEL_PORT:-$(shuf -i 1024-62000 -n 1)}"
# Clean settings slate: drops any baked port/webBasePath and forces the panel
# to regenerate its session secret + panel GUID on next start (per-instance).
"$XUI_BIN" setting -reset > /dev/null 2>&1 || true
# Apply fresh random identity. UpdateFirstUser renames the seeded admin row and
# rehashes the password, so admin/admin no longer exists after this call.
if ! "$XUI_BIN" setting -username "$NEW_USER" -password "$NEW_PASS" -port "$NEW_PORT" -webBasePath "$NEW_PATH" > /dev/null 2>&1; then
log "ERROR: failed to apply new panel settings."
exit 1
fi
API_TOKEN=$("$XUI_BIN" setting -getApiToken true 2> /dev/null | grep -Eo 'apiToken: .+' | awk '{print $2}')
SERVER_IP=$(detect_ip)
ACCESS_URL="http://${SERVER_IP}:${NEW_PORT}/${NEW_PATH}"
# Persist credentials for the operator (root-only). Values are shell-escaped
# with %q so the file stays safe to `source` even if a value contains shell
# metacharacters (the smoke test and operators source this file).
umask 077
{
echo "# 3x-ui per-instance credentials (generated on first boot)"
printf 'XUI_USERNAME=%q\n' "$NEW_USER"
printf 'XUI_PASSWORD=%q\n' "$NEW_PASS"
printf 'XUI_PANEL_PORT=%q\n' "$NEW_PORT"
printf 'XUI_WEB_BASE_PATH=%q\n' "$NEW_PATH"
printf 'XUI_ACCESS_URL=%q\n' "$ACCESS_URL"
printf 'XUI_API_TOKEN=%q\n' "$API_TOKEN"
} > "$CRED_FILE"
chmod 600 "$CRED_FILE" 2> /dev/null || true
# Friendly login banner shown on SSH / console before the panel is reachable.
# /etc/motd is world-readable, so it MUST NOT contain the password or API token;
# those secrets live only in ${CRED_FILE} (mode 600). Show non-secret info only.
cat > "$MOTD_FILE" 2> /dev/null << EOF
========================================================================
3x-ui panel — per-instance credentials (generated on first boot)
========================================================================
Access URL : ${ACCESS_URL}
Username : ${NEW_USER}
The password and API token are NOT shown here (this banner is
world-readable). Read them as root with:
sudo cat ${CRED_FILE}
Change the password after login. If no public IP is shown above,
replace <server-ip> with the address you reach this server on.
========================================================================
EOF
# Mark complete so we never regenerate on subsequent boots.
: > "$SENTINEL" 2> /dev/null || true
chmod 600 "$SENTINEL" 2> /dev/null || true
log "done. Panel will start on port ${NEW_PORT} with a unique admin account."
exit 0
+94
View File
@@ -0,0 +1,94 @@
# 3x-ui on Amazon Lightsail
Two self-service ways to run 3x-ui on Lightsail, both producing **unique
per-instance credentials** (never `admin/admin`, never a shared secret).
> **Reality check.** The Lightsail *blueprint* list (WordPress, LAMP, GitLab…)
> is curated by AWS — you **cannot** self-publish your panel there, and Lightsail
> **cannot** launch from an arbitrary EC2 AMI. What you *can* do yourself is the
> two paths below. (For a public AWS listing you'd use the EC2 **AMI** +
> Marketplace path in [`../marketplace/aws/`](../marketplace/aws/), which is a
> different product from Lightsail.)
---
## Path A — launch script (simplest, self-service)
Install on a fresh instance at creation time. No image to build.
1. **Create instance** → platform **Linux/Unix** → blueprint **OS Only → Ubuntu 24.04**.
2. **Add launch script** → paste [`launch-script.sh`](launch-script.sh).
3. Create the instance.
4. After it boots, read the credentials:
```bash
ssh ubuntu@<public-ip> 'sudo cat /etc/x-ui/install-result.env'
```
5. **Open the panel port** (see the firewall note below) and log in.
CLI equivalent:
```bash
aws lightsail create-instances \
--instance-names my-3xui \
--availability-zone eu-central-1a \
--blueprint-id ubuntu_24_04 \
--bundle-id small_3_0 \
--user-data file://deploy/lightsail/launch-script.sh \
--region eu-central-1
```
By default the panel uses a **random** high port (in `install-result.env`). To
pin a known port so you can pre-open it, set `export XUI_PANEL_PORT=54321` inside
`launch-script.sh`.
---
## Path B — reusable snapshot (your own "ready image")
Build a Lightsail **snapshot** once; launch as many instances from it as you
like, each generating its own credentials on first boot (the golden-image model).
```bash
deploy/lightsail/build-snapshot.sh --region eu-central-1 --panel-port 54321
```
What it does: launches a temporary Ubuntu instance with
[`snapshot-userdata.sh`](snapshot-userdata.sh) (installs the panel, **no DB**,
enables the first-boot unit), strips all state via the shared
[`cleanup.sh`](../packer/scripts/cleanup.sh), then snapshots and deletes the
build instance. Requires `awscli`, `jq`, `ssh` and Lightsail permissions.
Launch instances from the snapshot:
```bash
aws lightsail create-instances-from-snapshot \
--instance-snapshot-name 3x-ui-ubuntu-24.04-<stamp> \
--instance-names my-3xui-1 --bundle-id small_3_0 \
--availability-zone eu-central-1a --region eu-central-1
```
Each launched instance runs `x-ui-firstboot` and writes its unique credentials to
`/etc/x-ui/credentials.txt` + `/etc/motd`. With `--panel-port` the port is the
same across instances (only the credentials differ), so you can pre-open it.
> Lightsail snapshots are **private to your AWS account** (and region). To use one
> elsewhere you can export it to EC2 (`aws lightsail export-snapshot`) and share
> the resulting AMI.
---
## Lightsail firewall note (important)
Lightsail's per-instance firewall only opens **22 / 80 / 443** by default. The
panel runs on a different port, so you must open it:
- Console: instance → **Networking → IPv4 Firewall → Add rule** (TCP, the panel port).
- CLI:
```bash
aws lightsail open-instance-public-ports --region eu-central-1 \
--instance-name my-3xui \
--port-info fromPort=54321,toPort=54321,protocol=TCP
```
The panel port is in `/etc/x-ui/install-result.env` (Path A) or
`/etc/x-ui/credentials.txt` (Path B), or fixed via `--panel-port` / `XUI_PANEL_PORT`.
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
#
# build-snapshot.sh — build a reusable Amazon Lightsail snapshot of 3x-ui.
#
# Flow (mirrors the Packer golden-image model, via the Lightsail API):
# 1. create an Ubuntu Lightsail instance with snapshot-userdata.sh
# (installs the panel, NO database, enables the first-boot unit)
# 2. wait for provisioning, then (optionally) pin a known panel port and run
# the shared cleanup.sh (wipes any DB/creds/keys/host-keys/cloud-init state)
# 3. stop the instance and create an instance snapshot
# 4. delete the build instance (unless --keep-instance)
#
# Every instance you later launch from the snapshot generates its OWN unique
# credentials on first boot (see deploy/firstboot/). The snapshot is private to
# your AWS account.
#
# Requirements: awscli v2, jq, ssh. AWS credentials with Lightsail permissions.
# Usage:
# deploy/lightsail/build-snapshot.sh --region eu-central-1 [options]
# Options:
# --region <r> AWS region (default: $AWS_REGION or eu-central-1)
# --blueprint-id <id> Lightsail blueprint (default: ubuntu_24_04)
# --bundle-id <id> Lightsail bundle/size (default: small_3_0)
# --availability-zone <z> AZ (default: <region>a)
# --panel-port <p> Pin the panel port in the snapshot so you can pre-open
# it in the Lightsail firewall (default: random per instance)
# --snapshot-name <n> Snapshot name (default: 3x-ui-ubuntu-24.04-<timestamp>)
# --keep-instance Do not delete the build instance afterwards
set -euo pipefail
REGION="${AWS_REGION:-eu-central-1}"
BLUEPRINT="ubuntu_24_04"
BUNDLE="small_3_0"
AZ=""
PANEL_PORT=""
SNAPSHOT_NAME=""
KEEP_INSTANCE=0
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
STAMP="$(date +%Y%m%d-%H%M%S)"
INSTANCE_NAME="3xui-build-${STAMP}"
KEY_FILE=""
log() { echo "[build-snapshot] $*"; }
die() {
echo "[build-snapshot] ERROR: $*" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--region) REGION="$2"; shift 2 ;;
--blueprint-id) BLUEPRINT="$2"; shift 2 ;;
--bundle-id) BUNDLE="$2"; shift 2 ;;
--availability-zone) AZ="$2"; shift 2 ;;
--panel-port) PANEL_PORT="$2"; shift 2 ;;
--snapshot-name) SNAPSHOT_NAME="$2"; shift 2 ;;
--keep-instance) KEEP_INSTANCE=1; shift ;;
-h | --help) sed -n '2,40p' "$0"; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
[ -n "$AZ" ] || AZ="${REGION}a"
[ -n "$SNAPSHOT_NAME" ] || SNAPSHOT_NAME="3x-ui-ubuntu-24.04-${STAMP}"
for cmd in aws jq ssh; do
command -v "$cmd" > /dev/null 2>&1 || die "'$cmd' is required"
done
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR)
cleanup() {
[ -n "$KEY_FILE" ] && rm -f "$KEY_FILE"
if [ "$KEEP_INSTANCE" -eq 0 ]; then
aws lightsail delete-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null 2>&1 || true
fi
}
trap cleanup EXIT
wait_state() {
local want="$1" tries="${2:-60}" st
for _ in $(seq 1 "$tries"); do
st=$(aws lightsail get-instance-state --instance-name "$INSTANCE_NAME" --region "$REGION" \
--query 'state.name' --output text 2> /dev/null || echo "")
[ "$st" = "$want" ] && return 0
sleep 5
done
return 1
}
log "creating build instance ${INSTANCE_NAME} (${BLUEPRINT}/${BUNDLE}) in ${REGION}..."
aws lightsail create-instances \
--instance-names "$INSTANCE_NAME" \
--availability-zone "$AZ" \
--blueprint-id "$BLUEPRINT" \
--bundle-id "$BUNDLE" \
--user-data "file://${SCRIPT_DIR}/snapshot-userdata.sh" \
--region "$REGION" > /dev/null
log "waiting for instance to run..."
wait_state running 60 || die "instance did not reach 'running'"
IP=$(aws lightsail get-instance --instance-name "$INSTANCE_NAME" --region "$REGION" \
--query 'instance.publicIpAddress' --output text)
if [ -z "$IP" ] || [ "$IP" = "None" ]; then die "no public IP"; fi
log "instance IP: ${IP}"
KEY_FILE="$(mktemp)"
# download-default-key-pair returns the key in 'privateKeyBase64'. Despite the
# name, the CLI historically emits the plaintext PEM (-----BEGIN...); the API
# docs describe it as base64. Handle both: write PEM as-is, else base64-decode.
KEY_RAW="$(aws lightsail download-default-key-pair --region "$REGION" \
--query 'privateKeyBase64' --output text)"
[ -n "$KEY_RAW" ] && [ "$KEY_RAW" != "None" ] || die "failed to download default key pair"
case "$KEY_RAW" in
*-----BEGIN*) printf '%s\n' "$KEY_RAW" > "$KEY_FILE" ;;
*) printf '%s' "$KEY_RAW" | base64 -d > "$KEY_FILE" 2> /dev/null \
|| die "private key is neither PEM nor valid base64" ;;
esac
grep -q -- "-----BEGIN" "$KEY_FILE" || die "downloaded key is not a valid PEM private key"
chmod 600 "$KEY_FILE"
log "waiting for provisioning to finish (this installs the panel)..."
ok=0
for _ in $(seq 1 72); do # ~12 min
if ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
'test -f /var/lib/3xui-provision-done' 2> /dev/null; then
ok=1
break
fi
sleep 10
done
[ "$ok" -eq 1 ] || die "provisioning did not complete in time"
log "provisioning complete."
if [ -n "$PANEL_PORT" ]; then
log "pinning panel port ${PANEL_PORT} (username/password stay random)..."
ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
"echo 'XUI_PANEL_PORT=${PANEL_PORT}' | sudo tee -a /etc/default/x-ui >/dev/null"
fi
log "stripping instance state (shared cleanup.sh)..."
ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
'curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/deploy/packer/scripts/cleanup.sh | sudo bash'
log "stopping instance..."
aws lightsail stop-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null
wait_state stopped 60 || die "instance did not stop"
log "creating snapshot ${SNAPSHOT_NAME}..."
aws lightsail create-instance-snapshot \
--instance-name "$INSTANCE_NAME" \
--instance-snapshot-name "$SNAPSHOT_NAME" \
--region "$REGION" > /dev/null
log "waiting for snapshot to become available..."
snap_ok=0
for _ in $(seq 1 120); do # ~20 min
state=$(aws lightsail get-instance-snapshot --instance-snapshot-name "$SNAPSHOT_NAME" \
--region "$REGION" --query 'instanceSnapshot.state' --output text 2> /dev/null || echo "")
[ "$state" = "available" ] && {
snap_ok=1
break
}
sleep 10
done
[ "$snap_ok" -eq 1 ] || die "snapshot did not become available"
log "DONE."
echo
echo "================================================================"
echo " Lightsail snapshot ready: ${SNAPSHOT_NAME} (region ${REGION})"
echo "================================================================"
echo " Launch an instance from it:"
echo " aws lightsail create-instances-from-snapshot \\"
echo " --instance-snapshot-name ${SNAPSHOT_NAME} \\"
echo " --instance-names my-3xui-1 --bundle-id ${BUNDLE} \\"
echo " --availability-zone ${AZ} --region ${REGION}"
if [ -n "$PANEL_PORT" ]; then
echo
echo " Then open the panel port (pinned to ${PANEL_PORT}):"
echo " aws lightsail open-instance-public-ports --region ${REGION} \\"
echo " --instance-name my-3xui-1 \\"
echo " --port-info fromPort=${PANEL_PORT},toPort=${PANEL_PORT},protocol=TCP"
else
echo
echo " Each instance picks a RANDOM panel port. After it boots, read it from"
echo " sudo cat /etc/x-ui/credentials.txt"
echo " and open that TCP port in the instance's Lightsail IPv4 firewall."
fi
echo "================================================================"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
#
# Amazon Lightsail launch script for 3x-ui (self-service, per-instance creds).
#
# Use it one of two ways when creating an Ubuntu 24.04 Lightsail instance:
# * Console: "Add launch script" -> paste this file.
# * CLI: aws lightsail create-instances --user-data file://launch-script.sh ...
#
# It installs the latest 3x-ui release non-interactively and generates unique
# random credentials for THIS instance. The full credentials land in
# /etc/x-ui/install-result.env (mode 600); /etc/motd shows only the URL + username.
#
# IMPORTANT (Lightsail firewall): Lightsail only opens 22/80/443 by default. The
# panel listens on a random high port, so after boot read the port from
# /etc/x-ui/install-result.env and open it under the instance's Networking tab
# (IPv4 Firewall), or pin a known port below and pre-open it.
set -e
export DEBIAN_FRONTEND=noninteractive
# --- Non-interactive install knobs ------------------------------------------
export XUI_NONINTERACTIVE=1
export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
# Pin a known panel port so you can pre-open it in the Lightsail firewall
# (otherwise a random high port is chosen). Username/password stay random:
# export XUI_PANEL_PORT="54321"
# Other optional pins (unset => secure random):
# export XUI_USERNAME="admin2"
# export XUI_PASSWORD="change-me"
# export XUI_WEB_BASE_PATH="panel"
# Domain TLS instead of plain HTTP:
# export XUI_SSL_MODE="domain" XUI_DOMAIN="panel.example.com" XUI_ACME_EMAIL="you@example.com"
# ----------------------------------------------------------------------------
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
# /etc/motd is world-readable, so it gets ONLY non-secret info (URL + username);
# the full credentials stay in the root-only /etc/x-ui/install-result.env
# (mode 600) — read them with `sudo cat` over SSH.
if [ -r /etc/x-ui/install-result.env ]; then
# shellcheck disable=SC1091
. /etc/x-ui/install-result.env
{
echo
echo "=== 3x-ui panel (generated on first boot) ==="
echo "URL: ${XUI_ACCESS_URL:-unknown}"
echo "Username: ${XUI_USERNAME:-unknown}"
echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
echo "Open the panel port in the Lightsail IPv4 firewall, then log in."
echo "============================================="
} >> /etc/motd 2>/dev/null || true
fi
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
#
# Lightsail snapshot provisioning user-data (used by build-snapshot.sh).
#
# Installs the 3x-ui panel into a build instance but creates NO database and
# NO credentials, and enables the first-boot unit. The instance is then snapshot
# so that every instance launched from the snapshot generates its own unique
# credentials on first boot (see deploy/firstboot/).
#
# This is the Lightsail equivalent of deploy/packer/scripts/provision.sh. It is
# NOT for end users — use deploy/lightsail/launch-script.sh for a direct install.
set -e
export DEBIAN_FRONTEND=noninteractive
REPO=MHSanaei/3x-ui
XUI_DIR=/usr/local/x-ui
RAW="https://raw.githubusercontent.com/${REPO}/main"
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl tar tzdata socat openssl cron jq
ARCH=$(dpkg --print-architecture) # amd64 | arm64
VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
if [ -z "$VER" ] || [ "$VER" = "null" ]; then
echo "failed to resolve 3x-ui version" >&2
exit 1
fi
tmp=$(mktemp -d)
curl -fL4 --retry 3 -o "${tmp}/x.tar.gz" \
"https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"
systemctl stop x-ui > /dev/null 2>&1 || true
rm -rf "$XUI_DIR"
tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
chmod +x /usr/bin/x-ui
mkdir -p /var/log/x-ui
# Panel + first-boot systemd units.
install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
curl -fL4 -o "${XUI_DIR}/x-ui-firstboot.sh" "${RAW}/deploy/firstboot/x-ui-firstboot.sh"
curl -fL4 -o /etc/systemd/system/x-ui-firstboot.service "${RAW}/deploy/firstboot/x-ui-firstboot.service"
chmod 755 "${XUI_DIR}/x-ui-firstboot.sh"
chmod 644 /etc/systemd/system/x-ui-firstboot.service
systemctl daemon-reload
systemctl enable x-ui-firstboot.service
systemctl enable x-ui.service
# No DB, no creds in the image — first boot generates them per-instance.
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
# Marker that build-snapshot.sh polls for over SSH.
touch /var/lib/3xui-provision-done
echo "[snapshot-userdata] provisioned 3x-ui ${VER} (${ARCH}); no DB created."
+92
View File
@@ -0,0 +1,92 @@
# Publishing 3x-ui to the AWS Marketplace (AMI)
This is the checklist for turning the Packer-built AMI into an AWS Marketplace
listing. It assumes you have already built an AMI with
[`../../packer/`](../../packer/) (locally or via `.github/workflows/image.yml`).
> Do **not** commit AMI IDs, AWS account numbers, or credentials. The AMI ID is
> printed to the workflow job summary at build time.
## 1. Seller registration (one-time)
1. Sign in to the [AWS Marketplace Management Portal](https://aws.amazon.com/marketplace/management/)
with the AWS account that will own the listing.
2. Complete **seller registration** (legal entity, bank, tax interview). Required
before any product can be submitted.
## 2. Build a compliant AMI
Build in the seller account (or share the AMI into it):
```bash
cd deploy/packer
packer init .
# amd64
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=vX.Y.Z' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
# arm64 (Graviton)
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=vX.Y.Z' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
```
You can list both AMIs (amd64 + arm64) as architectures of a single Marketplace
product, or as separate products.
The image already satisfies the Marketplace AMI policies enforced by `harden.sh`
+ `cleanup.sh`:
- ✅ `PasswordAuthentication no`, `PermitRootLogin prohibit-password`
- ✅ no default OS account passwords (all locked)
- ✅ no baked `authorized_keys`, no SSH host keys (regenerated on boot)
- ✅ base OS = current Ubuntu 24.04 LTS, patched at build time
- ✅ no application default credentials — the panel admin is generated on first
boot on a random high port (no `admin/admin`, no shipped `x-ui.db`)
## 3. Run the self-service AMI scan
1. In the Management Portal: **Server products → AMIs → Upload/scan an AMI**.
2. Share the AMI with the AWS Marketplace scanning account when prompted
(the portal gives you the exact account id and the `modify-image-attribute`
command, or share it from the EC2 console).
3. Start the scan. It checks SSH config, default credentials, open ports, and
for malware. Fix any finding and re-scan.
Common scan findings and where they're handled:
| Finding | Fix (already in the build) |
| --- | --- |
| Password authentication enabled | `harden.sh` sshd drop-in |
| Root login with password | `harden.sh` `PermitRootLogin prohibit-password` |
| Default user password set | `harden.sh` `passwd -l` on all accounts |
| Authorized keys present | `cleanup.sh` removes them |
| Out-of-date packages | base image is the latest LTS; `provision.sh` runs `apt-get update` |
## 4. Create the product (limited / private first)
1. **Server products → Create new product → AMI** (or AMI + CloudFormation).
2. Add title, description, categories, pricing (free or paid), regions, the AMI
id, recommended instance types, and the **usage instructions** (tell buyers
to read `/etc/x-ui/credentials.txt` / MOTD after first boot for the generated
admin login, then change the password).
3. Submit as a **Limited** (private) listing first. AWS publishes it with
restricted visibility so only your account / allow-listed accounts see it.
## 5. Preview & launch test
1. From the limited listing, **subscribe and launch** a test instance.
2. SSH in, `sudo cat /etc/x-ui/credentials.txt`, open the panel URL, log in,
confirm the panel works and the credentials are unique to that instance.
3. Launch a second instance and confirm its credentials differ (no shared
secrets).
## 6. Go public
1. Once the scan passes and the preview looks correct, request **public
visibility** (move from Limited to Public) in the listing.
2. AWS does a final review before the listing goes live.
## References
- AWS Marketplace seller guide: <https://docs.aws.amazon.com/marketplace/latest/userguide/>
- AMI-based product requirements: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html>
- Self-service AMI scanning: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-submission.html>
+58
View File
@@ -0,0 +1,58 @@
# 3x-ui on Hetzner Cloud
Hetzner Cloud does **not** have a third-party image marketplace the way AWS does.
There are two practical ways to ship 3x-ui on Hetzner.
## Option A — cloud-init (recommended, no image build)
Use the generic user-data from [`../../cloud-init/`](../../cloud-init/). It installs
3x-ui non-interactively and generates unique per-instance credentials.
Web console: **Create Server → Cloud config** → paste
[`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml).
CLI:
```bash
hcloud server create \
--name xui-1 \
--type cx22 \
--image ubuntu-24.04 \
--user-data-from-file deploy/cloud-init/cloud-init.yaml
```
After boot, fetch the generated credentials:
```bash
ssh root@<server-ip> 'cat /etc/x-ui/install-result.env'
```
## Option B — snapshot from the qcow2 / a configured server
Hetzner lets you create a **snapshot** of a running server and launch new
servers from it. Two ways to get there:
1. **From the Packer qcow2:** Hetzner does not allow direct qcow2 upload via the
normal API, but you can boot a server, write the image to its disk in rescue
mode, then take a snapshot — or simply use Option A, which needs no image.
2. **From a configured server:** spin up a server, install via cloud-init
(Option A), verify, then **delete `/etc/x-ui/x-ui.db` and the first-boot
sentinel** before snapshotting so clones regenerate their own credentials:
```bash
systemctl stop x-ui
rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done /etc/x-ui/credentials.txt
# re-enable first-boot regeneration if you installed via Packer:
systemctl enable x-ui-firstboot 2>/dev/null || true
```
> ⚠️ If you snapshot a server **with** its `x-ui.db`, every clone shares the
> same admin credentials and session secret. Always remove the DB first.
## "App"-style listing
Hetzner's curated apps live in the community repo
[`github.com/hetznercloud/apps`](https://github.com/hetznercloud/apps): each app
is essentially a documented cloud-init config plus metadata. To propose 3x-ui as
a Hetzner app, follow that repo's contribution pattern and base the app's
cloud-config on [`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml).
+7
View File
@@ -0,0 +1,7 @@
# Packer build artifacts (never commit images or manifests)
output-qemu/
*.qcow2
*.raw
packer-manifest.json
packer_cache/
crash.log
+116
View File
@@ -0,0 +1,116 @@
# 3x-ui golden image (Packer)
Builds a cloud image with the 3x-ui panel pre-installed but **not configured**:
the image ships with **no database and no credentials**, and generates a unique
admin account on first boot. This is the **primary** path for AWS Marketplace
and any reusable image.
Two sources, one build:
| Source | Output | For |
| --- | --- | --- |
| `amazon-ebs` | AWS AMI | AWS / Marketplace |
| `qemu` | `qcow2` (+ `raw`) | Hetzner, DigitalOcean, Vultr, GCP, Azure, Oracle, bare metal |
Both sources build for **`amd64` and `arm64`** (select with `-var xui_arch=...`).
## Why no baked DB
3x-ui seeds a hardcoded `admin/admin` user and generates its session secret +
panel GUID the first time it starts. If an image shipped an initialized
`x-ui.db`, **every clone would share the same credentials and secret**. So the
build deliberately:
- installs the panel binary + systemd unit but **never starts it** and **never
creates a DB** (`scripts/provision.sh`);
- wipes any stray DB/credentials/host-keys at the end (`scripts/cleanup.sh`);
- enables `x-ui-firstboot.service`, which on first boot resets settings, sets a
random username/password on a random high port, regenerates the secret/GUID,
and writes the credentials to `/etc/x-ui/credentials.txt` + `/etc/motd`
(`deploy/firstboot/`).
## Prerequisites
- [Packer](https://developer.hashicorp.com/packer) ≥ 1.9
- For `qemu` amd64: `qemu-system-x86`, `qemu-utils` (and `/dev/kvm` for acceptable speed)
- For `qemu` arm64: `qemu-system-arm`, `qemu-efi-aarch64`, `qemu-utils` — best built on an
arm64 host (native KVM); cross-building from x86 works but uses slow TCG emulation
- For `amazon-ebs`: AWS credentials with EC2 build permissions (arm64 builds on a Graviton
instance such as `t4g.small`)
```bash
cd deploy/packer
packer init .
packer fmt -check . # formatting
packer validate . # both sources
```
## Build
Build a specific release (recommended) or `latest`:
```bash
# amd64 qcow2 (no cloud account needed)
packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' .
# arm64 qcow2 (run on an arm64 host for native KVM)
packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' .
# amd64 AWS AMI
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=v3.3.1' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
# arm64 AWS AMI (Graviton)
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=v3.3.1' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
```
Outputs (per arch):
- `output-qemu/3x-ui-ubuntu-24.04-<arch>.qcow2` and `.raw`
- the AMI id (also recorded in `packer-manifest.json`)
If `/dev/kvm` is unavailable, add `-var 'qemu_accelerator=tcg'` (much slower).
## Key variables
See [`variables.pkr.hcl`](variables.pkr.hcl) for the full list.
| Variable | Default | Notes |
| --- | --- | --- |
| `xui_version` | `latest` | Release tag to install, e.g. `v3.3.1` |
| `xui_arch` | `amd64` | `amd64` or `arm64` (derives the base AMI / cloud image) |
| `region` | `eu-central-1` | AWS region (amazon-ebs) |
| `instance_type` | `t3.small` | EC2 build instance — must match the arch (`t4g.small` for arm64) |
| `qemu_accelerator` | `kvm` | `kvm` or `tcg` |
| `qemu_cpu` | `host` | arm64 `-cpu` model (`host` with KVM, `max` for TCG) |
| `ubuntu_version` | `24.04` | Base Ubuntu LTS (naming/tags) |
The CI workflow builds both arches automatically: amd64 qcow2 on a standard runner,
arm64 qcow2 on a native `ubuntu-24.04-arm` runner, and both AMIs from a single runner
(the build instance runs in AWS).
## First boot
On the first boot of any instance launched from the image:
1. `x-ui-firstboot.service` runs **before** `x-ui.service`.
2. It generates a unique admin username/password, a random panel port, a random
base path, and an API token.
3. Credentials are written to `/etc/x-ui/credentials.txt` (root-only) and shown
in `/etc/motd`. Retrieve them with `sudo cat /etc/x-ui/credentials.txt`.
4. The panel then starts on the random port. `admin/admin` never exists.
## CI
`.github/workflows/image.yml` runs this build on `release: published` (and via
`workflow_dispatch`), attaching the compressed `qcow2` to the release and
building the AMI when AWS credentials are configured.
## A note on host firewalls
`scripts/harden.sh` intentionally does **not** enable a restrictive host
firewall. 3x-ui opens Xray inbound ports on admin-chosen ports at runtime, which
a host firewall would block. Use your cloud provider's security groups/firewall
instead, and open the panel port + your inbound ports there. If you still want a
host firewall, add `ufw` rules in `harden.sh` allowing SSH, the panel port and
your inbound ports.
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# cleanup.sh — strip all instance-specific state and secrets from the image.
#
# Runs LAST. The output image must contain no panel database, no credentials,
# no SSH host keys, and no baked authorized_keys. Fails the build if any of
# those survive.
set -euo pipefail
echo "[cleanup] removing panel database, credentials and first-boot sentinel..."
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* 2> /dev/null || true
rm -f /etc/x-ui/install-result.env /etc/x-ui/credentials.txt 2> /dev/null || true
rm -f /etc/x-ui/.firstboot-done 2> /dev/null || true
echo "[cleanup] removing SSH host keys (regenerated on first boot)..."
rm -f /etc/ssh/ssh_host_* 2> /dev/null || true
echo "[cleanup] removing any baked authorized_keys..."
rm -f /root/.ssh/authorized_keys 2> /dev/null || true
find /home -maxdepth 3 -name authorized_keys -type f -delete 2> /dev/null || true
echo "[cleanup] resetting machine-id..."
truncate -s 0 /etc/machine-id 2> /dev/null || true
rm -f /var/lib/dbus/machine-id 2> /dev/null || true
ln -sf /etc/machine-id /var/lib/dbus/machine-id 2> /dev/null || true
echo "[cleanup] resetting cloud-init so it re-runs on the real first boot..."
cloud-init clean --logs --seed > /dev/null 2>&1 || rm -rf /var/lib/cloud/* 2> /dev/null || true
echo "[cleanup] truncating logs, history and package caches..."
find /var/log -type f -exec truncate -s 0 {} + 2> /dev/null || true
rm -rf /var/lib/x-ui /var/log/x-ui/* 2> /dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* 2> /dev/null || true
rm -f /root/.bash_history 2> /dev/null || true
find /home -maxdepth 3 -name .bash_history -type f -delete 2> /dev/null || true
rm -rf /tmp/firstboot 2> /dev/null || true
echo "[cleanup] verifying the image is clean..."
fail=0
for f in /etc/x-ui/x-ui.db /etc/x-ui/credentials.txt /etc/x-ui/install-result.env /etc/x-ui/.firstboot-done; do
if [ -e "$f" ]; then
echo "[cleanup] FATAL: $f is present in the image" >&2
fail=1
fi
done
if ls /etc/ssh/ssh_host_* > /dev/null 2>&1; then
echo "[cleanup] FATAL: SSH host keys present in the image" >&2
fail=1
fi
if [ -e /root/.ssh/authorized_keys ]; then
echo "[cleanup] FATAL: /root/.ssh/authorized_keys present in the image" >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "[cleanup] OK — no DB, no credentials, no host keys, no authorized_keys."
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# harden.sh — baseline OS hardening for AWS Marketplace AMI scanner compliance.
#
# Focus: the controls the scanner actually checks — key-only SSH, no root
# password login, and no default OS account passwords. A restrictive host
# firewall is intentionally NOT enforced by default because 3x-ui opens Xray
# inbound ports on admin-chosen ports at runtime (see README for the rationale
# and how to add ufw rules if you want them).
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
echo "[harden] applying SSH hardening..."
install -d -m 755 /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-3xui-hardening.conf << 'EOF'
# 3x-ui golden image hardening (AWS Marketplace scanner compliance)
PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
EOF
chmod 644 /etc/ssh/sshd_config.d/99-3xui-hardening.conf
echo "[harden] locking passwords on default OS accounts..."
# No account may ship with a usable password. Keys are provisioned per-instance
# by the cloud platform (EC2 metadata / cloud-init) on first boot.
# passwd -l locks the PASSWORD only; key-based login keeps working.
for u in root ubuntu admin; do
if id "$u" > /dev/null 2>&1; then
passwd -l "$u" > /dev/null 2>&1 || true
fi
done
echo "[harden] enabling automatic security updates..."
apt-get update
apt-get install -y --no-install-recommends unattended-upgrades
systemctl enable unattended-upgrades > /dev/null 2>&1 || true
echo "[harden] done."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# provision.sh — install the 3x-ui panel into a golden image (Packer).
#
# Self-contained: mirrors install.sh's download/extract logic but DELIBERATELY
# does NOT run config_after_install and does NOT create a database. The image
# must ship without /etc/x-ui/x-ui.db so that deploy/firstboot generates unique
# per-instance credentials on first boot. Both x-ui.service and
# x-ui-firstboot.service are enabled but NOT started here.
#
# Inputs (from Packer environment_vars):
# XUI_VERSION release tag (e.g. v3.3.1) or 'latest'
# XUI_ARCH amd64 (default) or arm64
set -euo pipefail
XUI_VERSION="${XUI_VERSION:-latest}"
XUI_ARCH="${XUI_ARCH:-amd64}"
XUI_DIR="/usr/local/x-ui"
REPO="MHSanaei/3x-ui"
export DEBIAN_FRONTEND=noninteractive
echo "[provision] installing base packages..."
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl tar tzdata socat openssl cron jq
echo "[provision] resolving 3x-ui version..."
if [ "$XUI_VERSION" = "latest" ]; then
XUI_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name')
fi
if [ -z "$XUI_VERSION" ] || [ "$XUI_VERSION" = "null" ]; then
echo "[provision] ERROR: could not resolve 3x-ui release tag" >&2
exit 1
fi
echo "[provision] installing 3x-ui ${XUI_VERSION} (${XUI_ARCH})"
tarball="x-ui-linux-${XUI_ARCH}.tar.gz"
url="https://github.com/${REPO}/releases/download/${XUI_VERSION}/${tarball}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# Download the RELEASED binary tarball (no Go build inside the image).
curl -fL4 --retry 3 -o "${tmp}/${tarball}" "$url"
# Extract into /usr/local/ (the tarball contains an x-ui/ directory).
systemctl stop x-ui > /dev/null 2>&1 || true
rm -rf "$XUI_DIR"
tar -xzf "${tmp}/${tarball}" -C /usr/local/
chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
# Install the x-ui management CLI.
if [ -f "${XUI_DIR}/x-ui.sh" ]; then
cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
else
curl -fL4 -o /usr/bin/x-ui "https://raw.githubusercontent.com/${REPO}/main/x-ui.sh"
fi
chmod +x /usr/bin/x-ui
mkdir -p /var/log/x-ui
# Panel systemd unit (Ubuntu base => debian variant).
install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
# First-boot per-instance credential unit + script (uploaded to /tmp/firstboot).
install -m 755 /tmp/firstboot/x-ui-firstboot.sh "${XUI_DIR}/x-ui-firstboot.sh"
install -m 644 /tmp/firstboot/x-ui-firstboot.service /etc/systemd/system/x-ui-firstboot.service
systemctl daemon-reload
# Enable (start on next boot) but do NOT start now — there is no DB yet.
systemctl enable x-ui-firstboot.service
systemctl enable x-ui.service
# Belt-and-braces: ensure no DB / sentinel was created during provisioning.
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
echo "[provision] done — panel installed, services enabled, NO database initialized."
+109
View File
@@ -0,0 +1,109 @@
// Input variables for the 3x-ui golden-image build.
// See README.md for usage. Override with -var / -var-file or env (PKR_VAR_*).
variable "xui_version" {
type = string
description = "3x-ui release tag to install, e.g. v3.3.1. 'latest' resolves the newest GitHub release at build time."
default = "latest"
}
variable "xui_arch" {
type = string
description = "CPU architecture to build for: amd64 or arm64."
default = "amd64"
validation {
condition = contains(["amd64", "arm64"], var.xui_arch)
error_message = "The xui_arch value must be 'amd64' or 'arm64'."
}
}
variable "ubuntu_version" {
type = string
description = "Ubuntu LTS version label, used only for image naming/tags."
default = "24.04"
}
// --- amazon-ebs (AMI) ---------------------------------------------------------
variable "region" {
type = string
description = "AWS region the AMI is built in."
default = "eu-central-1"
}
variable "instance_type" {
type = string
description = "EC2 instance type used to build the AMI. Must match xui_arch (e.g. t3.small for amd64, t4g.small for arm64/Graviton)."
default = "t3.small"
}
variable "ami_name_prefix" {
type = string
description = "Prefix for the produced AMI name."
default = "3x-ui"
}
variable "source_ami_filter_name" {
type = string
description = "Override for the Canonical Ubuntu base AMI name filter. Empty ⇒ derived from xui_arch (latest patched 24.04 LTS for that arch)."
default = ""
}
variable "ssh_username" {
type = string
description = "Default SSH user on the base Ubuntu cloud image."
default = "ubuntu"
}
// --- qemu (qcow2 / raw) -------------------------------------------------------
variable "qemu_iso_url" {
type = string
description = "Override for the Ubuntu cloud image used as the qemu base disk. Empty ⇒ derived from xui_arch (amd64/arm64 cloud image)."
default = ""
}
variable "qemu_iso_checksum" {
type = string
description = "Checksum for the qemu base disk. 'file:<SHA256SUMS url>' auto-fetches; 'none' skips verification."
default = "file:https://cloud-images.ubuntu.com/releases/24.04/release/SHA256SUMS"
}
variable "qemu_accelerator" {
type = string
description = "QEMU accelerator: 'kvm' when /dev/kvm is available, else 'tcg' (slow software emulation)."
default = "kvm"
}
variable "qemu_headless" {
type = bool
description = "Run QEMU without a display (required on CI runners)."
default = true
}
variable "qemu_build_password" {
type = string
description = "Temporary password injected via cloud-init for Packer's build-time SSH. Locked/removed before the image is finalized."
default = "packer-build-temp-pw"
sensitive = true
}
# --- qemu arm64-only knobs (ignored for amd64) -------------------------------
variable "qemu_cpu" {
type = string
description = "QEMU -cpu model for arm64 builds: 'host' with KVM on an arm64 host, 'max' for TCG emulation."
default = "host"
}
variable "qemu_efi_code" {
type = string
description = "Path to the arm64 UEFI code firmware (AAVMF). Only used when xui_arch=arm64."
default = "/usr/share/AAVMF/AAVMF_CODE.fd"
}
variable "qemu_efi_vars" {
type = string
description = "Path to the arm64 UEFI vars firmware template (AAVMF). Only used when xui_arch=arm64."
default = "/usr/share/AAVMF/AAVMF_VARS.fd"
}
+160
View File
@@ -0,0 +1,160 @@
// 3x-ui golden image — one build, two sources:
// * amazon-ebs : produces an AWS AMI (Marketplace-scannable)
// * qemu : produces a qcow2 (+ raw) for Hetzner/DO/Vultr/GCP/Azure/Oracle
//
// The image ships WITHOUT an initialized x-ui.db and WITHOUT any baked
// credentials. deploy/firstboot/x-ui-firstboot.{sh,service} generates unique
// per-instance credentials on first boot, before x-ui.service starts.
//
// Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.
packer {
required_plugins {
amazon = {
version = ">= 1.3.0"
source = "github.com/hashicorp/amazon"
}
qemu = {
version = ">= 1.1.0"
source = "github.com/hashicorp/qemu"
}
}
}
locals {
build_stamp = formatdate("YYYYMMDD-hhmmss", timestamp())
image_name = "${var.ami_name_prefix}-ubuntu-${var.ubuntu_version}-${var.xui_arch}"
is_arm = var.xui_arch == "arm64"
# Base images are derived from xui_arch unless explicitly overridden.
source_ami_name = var.source_ami_filter_name != "" ? var.source_ami_filter_name : "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-${var.xui_arch}-server-*"
qemu_iso_url = var.qemu_iso_url != "" ? var.qemu_iso_url : "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-${var.xui_arch}.img"
}
source "amazon-ebs" "x-ui" {
region = var.region
instance_type = var.instance_type
ssh_username = var.ssh_username
ami_name = "${local.image_name}-${var.xui_version}-${local.build_stamp}"
ami_description = "3x-ui panel on Ubuntu ${var.ubuntu_version}. Per-instance credentials are generated on first boot."
source_ami_filter {
filters = {
name = local.source_ami_name
root-device-type = "ebs"
virtualization-type = "hvm"
}
owners = ["099720109477"] // Canonical
most_recent = true
}
launch_block_device_mappings {
device_name = "/dev/sda1"
volume_size = 8
volume_type = "gp3"
delete_on_termination = true
}
tags = {
Name = local.image_name
Project = "3x-ui"
XuiVersion = var.xui_version
BuildTool = "packer"
BaseOS = "ubuntu-${var.ubuntu_version}"
}
}
source "qemu" "x-ui" {
iso_url = local.qemu_iso_url
iso_checksum = var.qemu_iso_checksum
disk_image = true
disk_size = "10G"
format = "qcow2"
accelerator = var.qemu_accelerator
headless = var.qemu_headless
cpus = 2
memory = 2048
net_device = "virtio-net"
disk_interface = "virtio"
// Arch-specific QEMU machine. amd64 uses Packer defaults (BIOS boot, x86_64);
// arm64 needs the aarch64 binary, the 'virt' machine and UEFI (AAVMF) firmware.
qemu_binary = local.is_arm ? "qemu-system-aarch64" : null
machine_type = local.is_arm ? "virt" : null
efi_boot = local.is_arm
efi_firmware_code = local.is_arm ? var.qemu_efi_code : null
efi_firmware_vars = local.is_arm ? var.qemu_efi_vars : null
qemuargs = local.is_arm ? [["-cpu", var.qemu_cpu]] : []
output_directory = "output-qemu"
vm_name = "${local.image_name}.qcow2"
// Build-time access: a NoCloud seed sets a temporary password for the default
// user so Packer can SSH in. The seed is a separate CD-ROM (not part of the
// output disk); the password is locked by harden.sh and state wiped by cleanup.sh.
cd_label = "cidata"
cd_content = {
"meta-data" = ""
"user-data" = <<-EOT
#cloud-config
password: ${var.qemu_build_password}
chpasswd: { expire: false }
ssh_pwauth: true
EOT
}
ssh_username = var.ssh_username
ssh_password = var.qemu_build_password
ssh_timeout = "20m"
boot_wait = "45s"
shutdown_command = "sudo shutdown -P now"
}
build {
name = "3x-ui"
sources = ["source.amazon-ebs.x-ui", "source.qemu.x-ui"]
// Upload the first-boot unit + script so provision.sh can install them.
provisioner "shell" {
inline = ["mkdir -p /tmp/firstboot"]
}
provisioner "file" {
source = "${path.root}/../firstboot/x-ui-firstboot.sh"
destination = "/tmp/firstboot/x-ui-firstboot.sh"
}
provisioner "file" {
source = "${path.root}/../firstboot/x-ui-firstboot.service"
destination = "/tmp/firstboot/x-ui-firstboot.service"
}
provisioner "shell" {
environment_vars = [
"XUI_VERSION=${var.xui_version}",
"XUI_ARCH=${var.xui_arch}",
"DEBIAN_FRONTEND=noninteractive",
]
execute_command = "chmod +x {{ .Path }}; sudo -E bash {{ .Path }}"
scripts = [
"${path.root}/scripts/provision.sh",
"${path.root}/scripts/harden.sh",
"${path.root}/scripts/cleanup.sh",
]
// give cloud-init time to release apt locks on the very first boot
pause_before = "10s"
}
// Convert the qcow2 to raw for clouds that need it (qemu source only).
post-processor "shell-local" {
only = ["qemu.x-ui"]
inline = ["qemu-img convert -p -O raw output-qemu/${local.image_name}.qcow2 output-qemu/${local.image_name}.raw"]
}
// Record the AMI id / artifacts for CI to surface.
post-processor "manifest" {
output = "packer-manifest.json"
strip_path = true
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# smoke-firstboot.sh — verify the first-boot per-instance credential script.
#
# Installs the released x-ui binary into a container WITHOUT a database, runs
# x-ui-firstboot.sh, and asserts:
# * fresh random credentials are generated (no admin/admin)
# * /etc/x-ui/credentials.txt (600) and /etc/motd are written
# * the sentinel is created and a second run is a no-op (creds unchanged)
#
# Requires Docker and network access. Usage: bash deploy/test/smoke-firstboot.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
if ! command -v docker > /dev/null 2>&1; then
echo "ERROR: docker is required for this smoke test." >&2
exit 1
fi
echo "== first-boot credential smoke test (image: $IMAGE) =="
docker run --rm \
-v "${REPO_ROOT}/deploy/firstboot/x-ui-firstboot.sh:/root/x-ui-firstboot.sh:ro" \
-e DEBIAN_FRONTEND=noninteractive \
"$IMAGE" bash -euo pipefail -c '
apt-get update -qq
apt-get install -y -qq curl tar openssl ca-certificates jq > /dev/null
echo "--- installing released x-ui binary (no DB, no systemd) ---"
REPO=MHSanaei/3x-ui
ARCH=$(dpkg --print-architecture) # amd64 | arm64
echo "container arch: $ARCH"
VER=$(curl --fail --location --silent --show-error \
--retry 5 --retry-all-errors --retry-delay 3 \
--connect-timeout 15 --max-time 60 \
"https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
[ -n "$VER" ] && [ "$VER" != "null" ] || { echo "FAIL: cannot resolve version"; exit 1; }
tmp=$(mktemp -d)
# 504s and other transient GitHub/CDN hiccups are retried; a real HTTP
# failure (e.g. missing arch asset) still aborts after the retries.
if ! curl -4 --fail --location --silent --show-error \
--retry 5 --retry-all-errors --retry-delay 3 \
--connect-timeout 15 --max-time 300 \
-o "${tmp}/x.tar.gz" \
"https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"; then
echo "FAIL: cannot download x-ui-linux-${ARCH}.tar.gz (${VER})" >&2; exit 1
fi
test -s "${tmp}/x.tar.gz" || { echo "FAIL: downloaded tarball is empty"; exit 1; }
tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
chmod +x /usr/local/x-ui/x-ui
install -m 755 /root/x-ui-firstboot.sh /usr/local/x-ui/x-ui-firstboot.sh
# Guarantee a clean slate (the image must never ship a DB).
rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done
echo "--- run 1: generate per-instance credentials ---"
/usr/local/x-ui/x-ui-firstboot.sh
test -f /etc/x-ui/.firstboot-done || { echo "FAIL: sentinel not created"; exit 1; }
test -f /etc/x-ui/credentials.txt || { echo "FAIL: credentials.txt missing"; exit 1; }
perms=$(stat -c %a /etc/x-ui/credentials.txt)
[ "$perms" = "600" ] || { echo "FAIL: credentials.txt perms=$perms (want 600)"; exit 1; }
grep -q "3x-ui" /etc/motd || { echo "FAIL: motd not written"; exit 1; }
# shellcheck disable=SC1090
. /etc/x-ui/credentials.txt
[ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
|| { echo "FAIL: username missing or still admin"; exit 1; }
first_user="$XUI_USERNAME"
/usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
|| { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
echo "--- run 2: must be a no-op (sentinel honored) ---"
/usr/local/x-ui/x-ui-firstboot.sh
# shellcheck disable=SC1090
. /etc/x-ui/credentials.txt
[ "$XUI_USERNAME" = "$first_user" ] \
|| { echo "FAIL: credentials changed on re-run"; exit 1; }
echo "SMOKE_PASS: firstboot user=$first_user (stable across re-run)"
'
echo "== first-boot smoke test PASSED =="
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# smoke-noninteractive.sh — verify the non-interactive install path.
#
# Runs install.sh inside an Ubuntu container with NO TTY (piped) and
# XUI_NONINTERACTIVE=1, then asserts:
# * /etc/x-ui/install-result.env exists (mode 600) with random, non-default creds
# * the panel reports hasDefaultCredential: false (no admin/admin remains)
# * the panel HTTP server actually serves on the generated port/base path
#
# Requires Docker and network access (install.sh downloads the released binary).
# Usage: bash deploy/test/smoke-noninteractive.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
if ! command -v docker > /dev/null 2>&1; then
echo "ERROR: docker is required for this smoke test." >&2
exit 1
fi
echo "== non-interactive install smoke test (image: $IMAGE) =="
docker run --rm \
-v "${REPO_ROOT}/install.sh:/root/install.sh:ro" \
-e XUI_NONINTERACTIVE=1 \
-e XUI_SSL_MODE=none \
-e DEBIAN_FRONTEND=noninteractive \
"$IMAGE" bash -euo pipefail -c '
apt-get update -qq
apt-get install -y -qq curl tar openssl ca-certificates > /dev/null
echo "--- running install.sh piped (no TTY) ---"
# Piping guarantees stdin is not a TTY, exercising the auto non-interactive path.
cat /root/install.sh | bash
echo "--- assertions ---"
RESULT=/etc/x-ui/install-result.env
test -f "$RESULT" || { echo "FAIL: $RESULT missing"; exit 1; }
perms=$(stat -c %a "$RESULT")
[ "$perms" = "600" ] || { echo "FAIL: $RESULT perms=$perms (want 600)"; exit 1; }
# shellcheck disable=SC1090
. "$RESULT"
[ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
|| { echo "FAIL: username missing or still admin"; exit 1; }
[ -n "${XUI_PASSWORD:-}" ] && [ "$XUI_PASSWORD" != "admin" ] \
|| { echo "FAIL: password missing or still admin"; exit 1; }
[ -n "${XUI_PANEL_PORT:-}" ] || { echo "FAIL: port missing"; exit 1; }
# No default admin in the DB.
/usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
|| { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
echo "--- verifying the panel serves HTTP ---"
cd /usr/local/x-ui
./x-ui > /tmp/xui.log 2>&1 &
xpid=$!
for _ in $(seq 1 15); do
code=$(curl -s -o /dev/null -w "%{http_code}" \
"http://127.0.0.1:${XUI_PANEL_PORT}/${XUI_WEB_BASE_PATH}/" 2>/dev/null || true)
case "$code" in 200|301|302|307|308) break ;; esac
sleep 1
done
kill "$xpid" 2>/dev/null || true
echo "panel HTTP status: ${code:-none}"
case "${code:-}" in
200|301|302|307|308) : ;;
*) echo "FAIL: panel did not serve (status ${code:-none})"; tail -n 30 /tmp/xui.log; exit 1 ;;
esac
echo "SMOKE_PASS: user=$XUI_USERNAME port=$XUI_PANEL_PORT path=$XUI_WEB_BASE_PATH"
'
echo "== non-interactive smoke test PASSED =="
+3 -1
View File
@@ -19,6 +19,7 @@ services:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
# XUI_INIT_WEB_BASE_PATH: "/"
# XUI_PORT: "8080"
# To use PostgreSQL instead of the default SQLite, run:
# docker compose --profile postgres up -d
# and uncomment the two lines below.
@@ -26,6 +27,7 @@ services:
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
tty: true
ports:
# When XUI_PORT is set, publish the same container port (for example "8080:8080").
- "2053:2053"
restart: unless-stopped
@@ -39,4 +41,4 @@ services:
POSTGRES_DB: xui
volumes:
- $PWD/pgdata/:/var/lib/postgresql/data
restart: unless-stopped
restart: unless-stopped
+102
View File
@@ -0,0 +1,102 @@
# Capturing the Real Client IP
When an Xray inbound sits behind an intermediary — a CDN like Cloudflare, an L4 tunnel/relay,
or another panel — the IP that Xray sees is the **intermediary's** address, not the visitor's.
That intermediary IP is what shows up in the panel's online/IP view and what the per-client
**IP limit** counts against, which makes both useless behind a proxy.
Xray-core can recover the real visitor IP. 3x-ui exposes the two mechanisms in the inbound form
and feeds the recovered IP into the same pipeline that drives IP-limit enforcement, the online
list, and multi-node sync — so once it is set, everything downstream just works.
## Where to set it
Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the
**Real client IP** preset selector:
| Preset | What it does | Use for |
|---|---|---|
| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset
selector for manual / advanced tuning — the presets just fill them in for you.
## Scenario 1 — Cloudflare CDN
Cloudflare's CDN (the orange cloud) forwards the visitor's IP in the `CF-Connecting-IP` request
header. Xray reads it when the transport is **WebSocket**, **HTTPUpgrade**, or **XHTTP** and
the header name is listed in `sockopt.trustedXForwardedFor`.
```json
"streamSettings": {
"network": "ws",
"sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] }
}
```
Pick the **Cloudflare CDN** preset. You can add `X-Real-IP`, `True-Client-IP`, or `X-Client-IP`
to the list if a different upstream uses those.
> This is **not** the same as Cloudflare Spectrum. The free/CDN tier forwards HTTP headers — use
> this scenario. Spectrum (a TCP/L4 product) can send the PROXY protocol — use Scenario 2.
## Scenario 2 — L4 tunnel / relay or Cloudflare Spectrum (PROXY protocol)
For a TCP-level front (HAProxy, gost, nginx `stream`, an Xray dokodemo-door relay, or Cloudflare
Spectrum), the real IP is carried in the **PROXY protocol** header. Enable
`acceptProxyProtocol` and make sure the **upstream emits PROXY protocol** — otherwise the
connection will fail.
```json
"streamSettings": {
"network": "tcp",
"sockopt": { "acceptProxyProtocol": true }
}
```
Pick the **L4 relay / Spectrum (PROXY)** preset. Works on TCP/RAW, WebSocket, HTTPUpgrade, gRPC
and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g.:
- **HAProxy**: `server backend 127.0.0.1:443 send-proxy` (or `send-proxy-v2`).
- **nginx** (`stream {}` block): `proxy_protocol on;` on the `server`, and on the upstream side
`proxy_protocol on;` in the `server` that connects to Xray.
## Transport support matrix
| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
|---|:--:|:--:|:--:|:--:|:--:|:--:|
| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ |
The form shows a warning when you select a preset that the current transport cannot honor.
> **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the
> first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On
> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale
> `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are
> mutually exclusive and clear the other field for you; only mix them by hand if you know your
> upstream chain needs it.
## Multi-node
No extra configuration is needed. The inbound's `streamSettings` (including these sockopt
fields) is pushed to child nodes verbatim, so the node's Xray records the real IP, and the
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
shows up on the parent automatically.
## Security note
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
stripped from subscription output, so they never reach clients. Only enable
`trustedXForwardedFor` when the inbound is genuinely behind a trusted proxy that sets the
header; otherwise a client could spoof the header and forge its own source IP.
## Verifying
1. Set the preset and save the inbound.
2. Inspect the generated Xray config and confirm `streamSettings.sockopt` carries the expected
field (`trustedXForwardedFor` or `acceptProxyProtocol`).
3. Connect through the intermediary, then open the client's IPs / online view in the panel — it
should show the real visitor IP rather than the CDN/relay address.
+1
View File
@@ -2,3 +2,4 @@ node_modules/
.vite/
*.log
*.tsbuildinfo
coverage/
+315 -134
View File
@@ -13,8 +13,8 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"antd": "^6.4.3",
"axios": "^1.17.0",
"antd": "^6.4.4",
"axios": "^1.18.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.1",
@@ -24,7 +24,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.16.0",
"react-router-dom": "^7.17.0",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
@@ -33,18 +33,19 @@
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.16",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.61.1",
"vite": "8.0.16",
"vitest": "^4.1.8"
"vitest": "^4.1.9"
},
"engines": {
"node": ">=22.0.0",
@@ -456,6 +457,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -617,9 +628,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz",
"integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==",
"dev": true,
"funding": [
{
@@ -1236,9 +1247,9 @@
}
},
"node_modules/@rc-component/form": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
"integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.4.tgz",
"integrity": "sha512-I2FHNMWoiGQNjC+hQFhAj/rQeScAIBc+AkZqvu4Zyaxe4I3WOVpQte2E5lyZhruswyT8aULYHu1clPaPwE9L2A==",
"license": "MIT",
"dependencies": {
"@rc-component/async-validator": "^6.0.0",
@@ -3096,17 +3107,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/type-utils": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -3119,7 +3130,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.61.0",
"@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -3135,16 +3146,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3160,14 +3171,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3182,14 +3193,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0"
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3200,9 +3211,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3217,15 +3228,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -3242,9 +3253,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3256,16 +3267,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -3297,16 +3308,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0"
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3321,13 +3332,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3364,17 +3375,48 @@
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3383,13 +3425,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.8",
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3410,9 +3452,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3423,13 +3465,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.8",
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
@@ -3437,14 +3479,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3453,9 +3495,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3463,13 +3505,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3647,6 +3689,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3678,9 +3739,9 @@
}
},
"node_modules/axios": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -4941,6 +5002,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -5067,6 +5138,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -5320,6 +5398,45 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-file-download": {
"version": "0.4.12",
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
@@ -5333,9 +5450,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -5832,6 +5959,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7033,6 +7201,19 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/swagger-client": {
"version": "3.37.4",
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz",
@@ -7175,22 +7356,22 @@
}
},
"node_modules/tldts": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.2"
"tldts-core": "^7.4.3"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
"dev": true,
"license": "MIT"
},
@@ -7366,16 +7547,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0"
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7390,9 +7571,9 @@
}
},
"node_modules/undici": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7566,19 +7747,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -7606,12 +7787,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+12 -8
View File
@@ -26,8 +26,8 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"antd": "^6.4.3",
"axios": "^1.17.0",
"antd": "^6.4.4",
"axios": "^1.18.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.1",
@@ -37,7 +37,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.16.0",
"react-router-dom": "^7.17.0",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
@@ -46,24 +46,28 @@
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.16",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.61.1",
"vite": "8.0.16",
"vitest": "^4.1.8"
"vitest": "^4.1.9"
},
"overrides": {
"react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0",
"react-debounce-input": {
"react": "^19.0.0"
},
"swagger-ui-react": {
"js-yaml": "^4.2.0"
}
},
"allowScripts": {
@@ -73,4 +77,4 @@
"core-js-pure": false,
"tree-sitter-json": false
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import type { HostFormValues } from '@/schemas/api/host';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
export function useHostMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
const createMut = useMutation({
mutationFn: (payload: Partial<HostFormValues>) => HttpUtil.post('/panel/api/hosts/add', payload),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<HostFormValues> }) =>
HttpUtil.post(`/panel/api/hosts/update/${id}`, payload),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const removeMut = useMutation({
mutationFn: (id: number) => HttpUtil.post(`/panel/api/hosts/del/${id}`),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const setEnableMut = useMutation({
mutationFn: ({ id, enable }: { id: number; enable: boolean }) =>
HttpUtil.post(`/panel/api/hosts/setEnable/${id}`, { enable }),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const reorderMut = useMutation({
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const bulkEnableMut = useMutation({
mutationFn: ({ ids, enable }: { ids: number[]; enable: boolean }) =>
HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids, enable }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const bulkDelMut = useMutation({
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
return {
create: (payload: Partial<HostFormValues>) => createMut.mutateAsync(payload),
update: (id: number, payload: Partial<HostFormValues>) => updateMut.mutateAsync({ id, payload }),
remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
reorder: (ids: number[]) => reorderMut.mutateAsync(ids),
bulkSetEnable: (ids: number[], enable: boolean) => bulkEnableMut.mutateAsync({ ids, enable }),
bulkDel: (ids: number[]) => bulkDelMut.mutateAsync(ids),
};
}
+33
View File
@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { HostListSchema, type HostRecord } from '@/schemas/api/host';
import { keys } from '@/api/queryKeys';
export type { HostRecord };
async function fetchHosts(): Promise<HostRecord[]> {
const msg = await HttpUtil.get('/panel/api/hosts/list', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
const validated = parseMsg(msg, HostListSchema, 'hosts/list');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useHostsQuery() {
const query = useQuery({
queryKey: keys.hosts.list(),
queryFn: fetchHosts,
});
const hosts = useMemo(() => query.data ?? [], [query.data]);
return {
hosts,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}
+41 -3
View File
@@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting';
// inbound's Telegram traffic to. Shares the cached xray config query so opening
// the inbound form costs no extra request when the Xray page was already
// visited; `select` derives just the tag list without disturbing other readers.
export function useOutboundTags() {
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
@@ -15,8 +16,10 @@ export function useOutboundTags() {
select: (data): string[] => {
const tags = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const tag = (o as { tag?: string } | null)?.tag;
if (tag) tags.add(tag);
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
tags.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) tags.add(t);
@@ -31,3 +34,38 @@ export function useOutboundTags() {
},
});
}
export interface OutboundTagGroups {
outbounds: string[];
balancers: string[];
}
// Same data as useOutboundTags, but keeps outbound and balancer tags apart so a
// picker can render them in labeled groups (like the panel-outbound selector)
// instead of one flat list.
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
select: (data): OutboundTagGroups => {
const outbounds = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
outbounds.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) outbounds.add(t);
}
const balancers: string[] = [];
const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
for (const b of bal ?? []) {
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
}
return { outbounds: [...outbounds], balancers };
},
});
}
+6
View File
@@ -6,6 +6,12 @@ export const keys = {
root: () => ['nodes'] as const,
list: () => ['nodes', 'list'] as const,
},
hosts: {
root: () => ['hosts'] as const,
list: () => ['hosts', 'list'] as const,
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const,
},
settings: {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
@@ -1,5 +1,6 @@
.jdp-wrap {
width: 100%;
position: relative;
}
.jdp-wrap > * {
@@ -33,3 +34,38 @@
pointer-events: none;
opacity: 0.6;
}
/* persian-calendar-suite has no allowClear; overlay our own clear button so
the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */
.jdp-wrap .jdp-clear {
position: absolute;
top: 50%;
right: 11px;
transform: translateY(-50%);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
line-height: 1;
color: rgba(0, 0, 0, 0.25);
transition: color 0.2s;
}
.jdp-wrap .jdp-clear:hover {
color: rgba(0, 0, 0, 0.45);
}
.jdp-dark .jdp-clear {
color: rgba(255, 255, 255, 0.30);
}
.jdp-dark .jdp-clear:hover,
.jdp-ultra .jdp-clear:hover {
color: rgba(255, 255, 255, 0.45);
}
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
@@ -54,6 +55,10 @@ export default function DateTimePicker({
}: DateTimePickerProps) {
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null);
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
// remounting via key is the only way to reflect an externally cleared value.
const [clearNonce, setClearNonce] = useState(0);
const persianTheme = useMemo(() => {
if (isUltra) return ULTRA_DARK_THEME;
@@ -61,10 +66,21 @@ export default function DateTimePicker({
return LIGHT_THEME;
}, [isDark, isUltra]);
// The library hardcodes a Persian placeholder and exposes no working prop to
// override it, so clear it (or apply the caller's) on the input directly so
// the empty field shows no leftover Persian text. No dep array: re-apply
// after every render (incl. clear-remounts).
useEffect(() => {
if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder;
});
if (datepicker === 'jalalian') {
return (
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<PersianDateTimePicker
key={clearNonce}
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
if (next == null || next === '') {
@@ -80,6 +96,21 @@ export default function DateTimePicker({
rtlCalendar
theme={persianTheme}
/>
{value && !disabled && (
<button
type="button"
className="jdp-clear"
aria-label="clear"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onChange(null);
setClearNonce((n) => n + 1);
}}
>
<CloseCircleFilled />
</button>
)}
</div>
);
}
@@ -0,0 +1,71 @@
import { useRef } from 'react';
import { Button, Input, Popover, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import { CodeOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
interface RemarkTemplateFieldProps {
// Injected by antd Form.Item:
value?: string;
onChange?: (value: string) => void;
maxLength?: number;
placeholder?: string;
}
/**
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
* (insert-at-caret) and a live, sample-based preview of the expanded result.
* Used for the global subscription Remark Template.
*/
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
const { t } = useTranslation();
const inputRef = useRef<InputRef>(null);
function insertToken(token: string) {
const el = inputRef.current?.input;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
const insert = wrapToken(token);
const next = value.slice(0, start) + insert + value.slice(end);
onChange?.(maxLength ? next.slice(0, maxLength) : next);
const caret = start + insert.length;
// The controlled value updates next render; restore the caret after it.
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(caret, caret);
});
}
return (
<div>
<Input
ref={inputRef}
value={value}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
addonAfter={
<Popover
content={<RemarkVarPicker onPick={insertToken} />}
trigger="click"
placement="bottomRight"
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
</Tooltip>
</Popover>
}
/>
{hasRemarkTokens(value) && (
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
{t('pages.hosts.remarkVars.preview')}:{' '}
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
interface RemarkVarPickerProps {
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
onPick: (token: string) => void;
}
/**
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
* the global remark-template field.
*/
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
const { t } = useTranslation();
return (
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
{t('pages.hosts.remarkVars.intro')}
</Typography.Paragraph>
{REMARK_VAR_GROUPS.map((group) => (
<div key={group} style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
{t(`pages.hosts.remarkVars.groups.${group}`)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
<Tag
onClick={() => onPick(v.token)}
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
>
{wrapToken(v.token)}
</Tag>
</Tooltip>
))}
</div>
</div>
))}
</div>
);
}
@@ -1,27 +1,23 @@
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
interface Option {
value: number;
}
interface SelectAllClearButtonsProps {
options: Option[];
value: number[];
onChange: (value: number[]) => void;
interface SelectAllClearButtonsProps<T extends string | number = number> {
options: Array<{ value: T }>;
value: T[];
onChange: (value: T[]) => void;
/** Override the default "Select all" label (defaults to the inbound copy). */
selectAllLabel?: string;
/** Override the default "Clear all" label (defaults to the inbound copy). */
clearLabel?: string;
}
export default function SelectAllClearButtons({
export default function SelectAllClearButtons<T extends string | number = number>({
options,
value,
onChange,
selectAllLabel,
clearLabel,
}: SelectAllClearButtonsProps) {
}: SelectAllClearButtonsProps<T>) {
const { t } = useTranslation();
const optionValues = options.map((o) => o.value);
+3
View File
@@ -2,3 +2,6 @@ export { default as DateTimePicker } from './DateTimePicker';
export { default as JsonEditor } from './JsonEditor';
export { default as HeaderMapEditor } from './HeaderMapEditor';
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
export { default as RemarkTemplateField } from './RemarkTemplateField';
export { default as RemarkVarPicker } from './RemarkVarPicker';
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
@@ -0,0 +1,147 @@
import { Checkbox, Collapse, InputNumber, Space } from 'antd';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
interface EventGroup {
key: string;
labelKey: string;
events: { value: string; labelKey: string }[];
}
const EVENT_GROUPS: EventGroup[] = [
{
key: 'outbound',
labelKey: 'pages.settings.eventGroupOutbound',
events: [
{ value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' },
{ value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' },
],
},
{
key: 'xray',
labelKey: 'pages.settings.eventGroupXray',
events: [
{ value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' },
],
},
{
key: 'node',
labelKey: 'pages.settings.eventGroupNode',
events: [
{ value: 'node.down', labelKey: 'pages.settings.eventNodeDown' },
{ value: 'node.up', labelKey: 'pages.settings.eventNodeUp' },
],
},
{
key: 'system',
labelKey: 'pages.settings.eventGroupSystem',
events: [
{ value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' },
],
},
{
key: 'security',
labelKey: 'pages.settings.eventGroupSecurity',
events: [
{ value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' },
],
},
];
interface EventBusCheckboxesProps {
value: string;
onChange: (v: string) => void;
/** Maps event value → { key: setting field name, value: current value } for inline inputs */
extra?: Record<string, { key: string; value: number }>;
/** Callback when extra input changes: (settingKey, newValue) => void */
onExtraChange?: (key: string, v: number | null) => void;
}
export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) {
const { t } = useTranslation();
const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
function toggle(eventType: string) {
const next = selected.includes(eventType)
? selected.filter((e) => e !== eventType)
: [...selected, eventType];
onChange(next.join(','));
}
function toggleGroup(group: EventGroup) {
const groupValues = group.events.map((e) => e.value);
const allSelected = groupValues.every((v) => selected.includes(v));
let next: string[];
if (allSelected) {
next = selected.filter((v) => !groupValues.includes(v));
} else {
next = [...new Set([...selected, ...groupValues])];
}
onChange(next.join(','));
}
const items = EVENT_GROUPS.map((group) => {
const count = group.events.filter((e) => selected.includes(e.value)).length;
const total = group.events.length;
const allSelected = count === total;
return {
key: group.key,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
<span style={{ color: '#999', fontSize: 12 }}>
{count}/{total}
</span>
<Checkbox
checked={allSelected}
indeterminate={count > 0 && count < total}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleGroup(group)}
/>
</div>
),
children: (
<Checkbox.Group value={selected} style={{ width: '100%' }}>
<Space wrap size={[16, 4]}>
{group.events.map((et) => {
const checked = selected.includes(et.value);
const extraConf = extra?.[et.value];
return (
<span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Checkbox value={et.value} onChange={() => toggle(et.value)}>
{t(et.labelKey)}
</Checkbox>
{extraConf && onExtraChange && (
<InputNumber
size="small"
min={0}
max={100}
value={extraConf.value}
disabled={!checked}
onChange={(v) => onExtraChange(extraConf.key, v)}
style={{ width: 60 }}
/>
)}
</span>
);
})}
</Space>
</Checkbox.Group>
),
};
});
const defaultActiveKeys = EVENT_GROUPS
.filter((g) => g.events.some((e) => selected.includes(e.value)))
.map((g) => g.key);
return (
<Collapse
items={items}
defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
size="small"
/>
);
}
+1
View File
@@ -1,3 +1,4 @@
export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem';
export { EventBusCheckboxes } from './EventBusCheckboxes';
+71 -8
View File
@@ -27,9 +27,18 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapVlessField": "",
"pageSize": 0,
"panelOutbound": "",
"remarkModel": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpHost": "",
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
@@ -38,7 +47,6 @@ export const EXAMPLES: Record<string, unknown> = {
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
@@ -54,7 +62,6 @@ export const EXAMPLES: Record<string, unknown> = {
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
@@ -64,10 +71,10 @@ export const EXAMPLES: Record<string, unknown> = {
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgEnabledEvents": "",
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
@@ -91,6 +98,7 @@ export const EXAMPLES: Record<string, unknown> = {
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasSmtpPassword": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
@@ -116,9 +124,18 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapVlessField": "",
"pageSize": 0,
"panelOutbound": "",
"remarkModel": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpHost": "",
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
@@ -127,7 +144,6 @@ export const EXAMPLES: Record<string, unknown> = {
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
@@ -143,7 +159,6 @@ export const EXAMPLES: Record<string, unknown> = {
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
@@ -153,10 +168,10 @@ export const EXAMPLES: Record<string, unknown> = {
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgEnabledEvents": "",
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
@@ -258,6 +273,51 @@ export const EXAMPLES: Record<string, unknown> = {
"id": 0,
"seederName": ""
},
"Host": {
"address": "cdn.example.com",
"allowInsecure": false,
"alpn": [
""
],
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"hostHeader": "",
"id": 1,
"inboundId": 1,
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": null,
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 8443,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sockoptParams": null,
"sortOrder": 0,
"tags": [
""
],
"updatedAt": 0,
"verifyPeerCertByName": false,
"vlessRoute": ""
},
"Inbound": {
"clientStats": [
{
@@ -353,7 +413,10 @@ export const EXAMPLES: Record<string, unknown> = {
"latencyMs": 42,
"memPct": 45.1,
"name": "de-fra-1",
"netDown": 2097152,
"netUp": 1048576,
"onlineCount": 3,
"outboundTag": "",
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
+309 -37
View File
@@ -98,8 +98,8 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
"type": "string"
},
"remarkModel": {
"description": "Remark model pattern for inbounds",
"remarkTemplate": {
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
"type": "string"
},
"restartXrayOnClientDisable": {
@@ -112,6 +112,46 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean"
},
"smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string"
},
"smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string"
},
"smtpHost": {
"description": "SMTP server host",
"type": "string"
},
"smtpPassword": {
"description": "SMTP password",
"type": "string"
},
"smtpPort": {
"description": "SMTP server port",
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string"
},
"smtpUsername": {
"description": "SMTP username",
"type": "string"
},
"subAnnounce": {
"description": "Subscription announce",
"type": "string"
@@ -144,10 +184,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Domain for subscription server validation",
"type": "string"
},
"subEmailInRemark": {
"description": "Include email in subscription remark/name",
"type": "boolean"
},
"subEnable": {
"description": "Subscription server settings\nEnable subscription server",
"type": "boolean"
@@ -209,10 +245,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Subscription global routing rules (Only for Happ)",
"type": "string"
},
"subShowInfo": {
"description": "Show client information in subscriptions",
"type": "boolean"
},
"subSupportUrl": {
"description": "Subscription support URL",
"type": "string"
@@ -251,10 +283,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean"
},
"tgBotLoginNotify": {
"description": "Send login notifications",
"type": "boolean"
},
"tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string"
@@ -269,6 +297,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string"
},
"tgLang": {
"description": "Telegram bot language",
"type": "string"
@@ -358,9 +390,18 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapVlessField",
"pageSize",
"panelOutbound",
"remarkModel",
"remarkTemplate",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpHost",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashEnable",
@@ -369,7 +410,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashRules",
"subClashURI",
"subDomain",
"subEmailInRemark",
"subEnable",
"subEnableRouting",
"subEncrypt",
@@ -385,7 +425,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowInfo",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -395,10 +434,10 @@ export const SCHEMAS: Record<string, unknown> = {
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotLoginNotify",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgRunTime",
"timeLocation",
@@ -445,6 +484,9 @@ export const SCHEMAS: Record<string, unknown> = {
"hasNordSecret": {
"type": "boolean"
},
"hasSmtpPassword": {
"type": "boolean"
},
"hasTgBotToken": {
"type": "boolean"
},
@@ -532,8 +574,8 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
"type": "string"
},
"remarkModel": {
"description": "Remark model pattern for inbounds",
"remarkTemplate": {
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
"type": "string"
},
"restartXrayOnClientDisable": {
@@ -546,6 +588,46 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean"
},
"smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string"
},
"smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string"
},
"smtpHost": {
"description": "SMTP server host",
"type": "string"
},
"smtpPassword": {
"description": "SMTP password",
"type": "string"
},
"smtpPort": {
"description": "SMTP server port",
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string"
},
"smtpUsername": {
"description": "SMTP username",
"type": "string"
},
"subAnnounce": {
"description": "Subscription announce",
"type": "string"
@@ -578,10 +660,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Domain for subscription server validation",
"type": "string"
},
"subEmailInRemark": {
"description": "Include email in subscription remark/name",
"type": "boolean"
},
"subEnable": {
"description": "Subscription server settings\nEnable subscription server",
"type": "boolean"
@@ -643,10 +721,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Subscription global routing rules (Only for Happ)",
"type": "string"
},
"subShowInfo": {
"description": "Show client information in subscriptions",
"type": "boolean"
},
"subSupportUrl": {
"description": "Subscription support URL",
"type": "string"
@@ -685,10 +759,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean"
},
"tgBotLoginNotify": {
"description": "Send login notifications",
"type": "boolean"
},
"tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string"
@@ -703,6 +773,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string"
},
"tgLang": {
"description": "Telegram bot language",
"type": "string"
@@ -773,6 +847,7 @@ export const SCHEMAS: Record<string, unknown> = {
"hasApiToken",
"hasLdapPassword",
"hasNordSecret",
"hasSmtpPassword",
"hasTgBotToken",
"hasTwoFactorToken",
"hasWarpSecret",
@@ -798,9 +873,18 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapVlessField",
"pageSize",
"panelOutbound",
"remarkModel",
"remarkTemplate",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpHost",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashEnable",
@@ -809,7 +893,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashRules",
"subClashURI",
"subDomain",
"subEmailInRemark",
"subEnable",
"subEnableRouting",
"subEncrypt",
@@ -825,7 +908,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subPort",
"subProfileUrl",
"subRoutingRules",
"subShowInfo",
"subSupportUrl",
"subThemeDir",
"subTitle",
@@ -835,10 +917,10 @@ export const SCHEMAS: Record<string, unknown> = {
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotLoginNotify",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgRunTime",
"timeLocation",
@@ -1224,6 +1306,181 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"Host": {
"description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.",
"properties": {
"address": {
"example": "cdn.example.com",
"type": "string"
},
"allowInsecure": {
"type": "boolean"
},
"alpn": {
"items": {
"type": "string"
},
"type": "array"
},
"createdAt": {
"type": "integer"
},
"echConfigList": {
"type": "string"
},
"excludeFromSubTypes": {
"items": {
"type": "string"
},
"type": "array"
},
"finalMask": {
"description": "FinalMask is a JSON object of xray finalmask masks (tcp/udp/quicParams),\nmerged into this host's JSON-subscription stream. Empty = no override.",
"type": "string"
},
"fingerprint": {
"type": "string"
},
"hostHeader": {
"type": "string"
},
"id": {
"example": 1,
"type": "integer"
},
"inboundId": {
"example": 1,
"type": "integer"
},
"isDisabled": {
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
"keepSniBlank": {
"type": "boolean"
},
"mihomoIpVersion": {
"enum": [
"dual",
"ipv4",
"ipv6",
"ipv4-prefer",
"ipv6-prefer"
],
"type": "string"
},
"mihomoX25519": {
"type": "boolean"
},
"muxParams": {},
"nodeGuids": {
"items": {
"type": "string"
},
"type": "array"
},
"overrideSniFromAddress": {
"type": "boolean"
},
"path": {
"type": "string"
},
"pinnedPeerCertSha256": {
"items": {
"type": "string"
},
"type": "array"
},
"port": {
"example": 8443,
"maximum": 65535,
"minimum": 0,
"type": "integer"
},
"remark": {
"example": "cdn-front",
"maxLength": 256,
"type": "string"
},
"security": {
"enum": [
"same",
"tls",
"none",
"reality"
],
"example": "same",
"type": "string"
},
"serverDescription": {
"maxLength": 64,
"type": "string"
},
"shuffleHost": {
"type": "boolean"
},
"sni": {
"type": "string"
},
"sockoptParams": {},
"sortOrder": {
"type": "integer"
},
"tags": {
"items": {
"type": "string"
},
"type": "array"
},
"updatedAt": {
"type": "integer"
},
"verifyPeerCertByName": {
"type": "boolean"
},
"vlessRoute": {
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
"type": "string"
}
},
"required": [
"address",
"allowInsecure",
"alpn",
"createdAt",
"echConfigList",
"excludeFromSubTypes",
"finalMask",
"fingerprint",
"hostHeader",
"id",
"inboundId",
"isDisabled",
"isHidden",
"keepSniBlank",
"mihomoIpVersion",
"mihomoX25519",
"muxParams",
"overrideSniFromAddress",
"path",
"pinnedPeerCertSha256",
"port",
"remark",
"security",
"serverDescription",
"shuffleHost",
"sni",
"sockoptParams",
"sortOrder",
"tags",
"updatedAt",
"verifyPeerCertByName",
"vlessRoute"
],
"type": "object"
},
"Inbound": {
"description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
"properties": {
@@ -1591,10 +1848,21 @@ export const SCHEMAS: Record<string, unknown> = {
"example": "de-fra-1",
"type": "string"
},
"netDown": {
"example": 2097152,
"type": "integer"
},
"netUp": {
"example": 1048576,
"type": "integer"
},
"onlineCount": {
"example": 3,
"type": "integer"
},
"outboundTag": {
"type": "string"
},
"panelVersion": {
"example": "v3.x.x",
"type": "string"
@@ -1632,7 +1900,8 @@ export const SCHEMAS: Record<string, unknown> = {
"enum": [
"verify",
"skip",
"pin"
"pin",
"mtls"
],
"type": "string"
},
@@ -1681,7 +1950,10 @@ export const SCHEMAS: Record<string, unknown> = {
"latencyMs",
"memPct",
"name",
"netDown",
"netUp",
"onlineCount",
"outboundTag",
"panelVersion",
"pinnedCertSha256",
"port",
+63 -8
View File
@@ -3,6 +3,7 @@ export type OnlineAPISupport = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type staticEgressResolver = string;
export type transportBits = number;
export interface AllSetting {
@@ -32,9 +33,18 @@ export interface AllSetting {
ldapVlessField: string;
pageSize: number;
panelOutbound: string;
remarkModel: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpHost: string;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
@@ -43,7 +53,6 @@ export interface AllSetting {
subClashRules: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
@@ -59,7 +68,6 @@ export interface AllSetting {
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
@@ -69,10 +77,10 @@ export interface AllSetting {
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgEnabledEvents: string;
tgLang: string;
tgRunTime: string;
timeLocation: string;
@@ -97,6 +105,7 @@ export interface AllSettingView {
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasSmtpPassword: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
@@ -122,9 +131,18 @@ export interface AllSettingView {
ldapVlessField: string;
pageSize: number;
panelOutbound: string;
remarkModel: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpHost: string;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
@@ -133,7 +151,6 @@ export interface AllSettingView {
subClashRules: string;
subClashURI: string;
subDomain: string;
subEmailInRemark: boolean;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
@@ -149,7 +166,6 @@ export interface AllSettingView {
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowInfo: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
@@ -159,10 +175,10 @@ export interface AllSettingView {
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgEnabledEvents: string;
tgLang: string;
tgRunTime: string;
timeLocation: string;
@@ -274,6 +290,42 @@ export interface HistoryOfSeeders {
seederName: string;
}
export interface Host {
address: string;
allowInsecure: boolean;
alpn: string[];
createdAt: number;
echConfigList: string;
excludeFromSubTypes: string[];
finalMask: string;
fingerprint: string;
hostHeader: string;
id: number;
inboundId: number;
isDisabled: boolean;
isHidden: boolean;
keepSniBlank: boolean;
mihomoIpVersion: string;
mihomoX25519: boolean;
muxParams: unknown;
nodeGuids?: string[];
overrideSniFromAddress: boolean;
path: string;
pinnedPeerCertSha256: string[];
port: number;
remark: string;
security: string;
serverDescription: string;
shuffleHost: boolean;
sni: string;
sockoptParams: unknown;
sortOrder: number;
tags: string[];
updatedAt: number;
verifyPeerCertByName: boolean;
vlessRoute: string;
}
export interface Inbound {
clientStats: ClientTraffic[];
down: number;
@@ -357,7 +409,10 @@ export interface Node {
latencyMs: number;
memPct: number;
name: string;
netDown: number;
netUp: number;
onlineCount: number;
outboundTag: string;
panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string;
+67 -9
View File
@@ -12,6 +12,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
export const transportBitsSchema = z.number().int();
export type transportBits = z.infer<typeof transportBitsSchema>;
@@ -42,9 +45,18 @@ export const AllSettingSchema = z.object({
ldapVlessField: z.string(),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkModel: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpHost: z.string(),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
@@ -53,7 +65,6 @@ export const AllSettingSchema = z.object({
subClashRules: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
@@ -69,7 +80,6 @@ export const AllSettingSchema = z.object({
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
@@ -79,10 +89,10 @@ export const AllSettingSchema = z.object({
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgEnabledEvents: z.string(),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
@@ -108,6 +118,7 @@ export const AllSettingViewSchema = z.object({
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasSmtpPassword: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
@@ -133,9 +144,18 @@ export const AllSettingViewSchema = z.object({
ldapVlessField: z.string(),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkModel: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpHost: z.string(),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
@@ -144,7 +164,6 @@ export const AllSettingViewSchema = z.object({
subClashRules: z.string(),
subClashURI: z.string(),
subDomain: z.string(),
subEmailInRemark: z.boolean(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
@@ -160,7 +179,6 @@ export const AllSettingViewSchema = z.object({
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowInfo: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
@@ -170,10 +188,10 @@ export const AllSettingViewSchema = z.object({
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgEnabledEvents: z.string(),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
@@ -295,6 +313,43 @@ export const HistoryOfSeedersSchema = z.object({
});
export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
export const HostSchema = z.object({
address: z.string(),
allowInsecure: z.boolean(),
alpn: z.array(z.string()),
createdAt: z.number().int(),
echConfigList: z.string(),
excludeFromSubTypes: z.array(z.string()),
finalMask: z.string(),
fingerprint: z.string(),
hostHeader: z.string(),
id: z.number().int(),
inboundId: z.number().int(),
isDisabled: z.boolean(),
isHidden: z.boolean(),
keepSniBlank: z.boolean(),
mihomoIpVersion: z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']),
mihomoX25519: z.boolean(),
muxParams: z.unknown(),
nodeGuids: z.array(z.string()).optional(),
overrideSniFromAddress: z.boolean(),
path: z.string(),
pinnedPeerCertSha256: z.array(z.string()),
port: z.number().int().min(0).max(65535),
remark: z.string().max(256),
security: z.enum(['same', 'tls', 'none', 'reality']),
serverDescription: z.string().max(64),
shuffleHost: z.boolean(),
sni: z.string(),
sockoptParams: z.unknown(),
sortOrder: z.number().int(),
tags: z.array(z.string()),
updatedAt: z.number().int(),
verifyPeerCertByName: z.boolean(),
vlessRoute: z.string(),
});
export type Host = z.infer<typeof HostSchema>;
export const InboundSchema = z.object({
clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
down: z.number().int(),
@@ -383,7 +438,10 @@ export const NodeSchema = z.object({
latencyMs: z.number().int(),
memPct: z.number(),
name: z.string(),
netDown: z.number().int(),
netUp: z.number().int(),
onlineCount: z.number().int(),
outboundTag: z.string(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
pinnedCertSha256: z.string(),
@@ -391,7 +449,7 @@ export const NodeSchema = z.object({
remark: z.string(),
scheme: z.enum(['http', 'https']),
status: z.string(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
transitive: z.boolean().optional(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
+24 -1
View File
@@ -22,6 +22,7 @@ import {
type ClientsSummary,
type ClientPageResponse,
type InboundOption,
type ExternalLink,
type BulkAdjustResult,
type BulkAttachResult,
type BulkCreateResult,
@@ -30,7 +31,10 @@ import {
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
// One row sent to POST /clients/:email/externalLinks.
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
@@ -183,6 +187,9 @@ export function useClients() {
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
staleTime: Infinity,
// List is sorted/paged server-side, so the WS patch can't add new or
// re-sort rows; poll the current page to keep it live (pauses when hidden).
refetchInterval: 5000,
placeholderData: keepPreviousData,
});
@@ -216,6 +223,9 @@ export function useClients() {
const fetched = listQuery.data !== undefined || listQuery.isError;
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
const loading = listQuery.isFetching;
// Showing kept-previous data for a new key (filter/sort/page) — drives the
// table overlay so the 5s background poll doesn't flash it.
const transitioning = listQuery.isPlaceholderData;
const inbounds = inboundOptionsQuery.data ?? [];
const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
@@ -344,6 +354,12 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const setExternalLinksMut = useMutation({
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAttachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
@@ -358,6 +374,7 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkDetachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
@@ -418,6 +435,10 @@ export function useClients() {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return setExternalLinksMut.mutateAsync({ email, externalLinks });
}, [setExternalLinksMut]);
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
@@ -528,6 +549,7 @@ export function useClients() {
inbounds,
onlines,
loading,
transitioning,
fetched,
fetchError,
subSettings,
@@ -546,6 +568,7 @@ export function useClients() {
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
detach,
bulkDetach,
+67 -18
View File
@@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult {
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
// Seed local editor state from the config query. Runs on first fetch and
// every time the query refetches (e.g. after a successful save).
@@ -316,41 +318,61 @@ export function useXraySetting(): UseXraySettingResult {
);
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
const list = templateSettingsRef.current?.outbounds || [];
if (list.length === 0 || testingAll) return;
// Template outbounds key their results by index (outboundTestStates);
// subscription outbounds aren't in the template, so they key by tag
// (subscriptionTestStates). Both go through the same probe endpoint.
const templateList = templateSettingsRef.current?.outbounds || [];
const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
setTestingAll(true);
try {
const tcpQueue: { index: number; outbound: unknown }[] = [];
const httpQueue: { index: number; outbound: unknown }[] = [];
list.forEach((ob, i) => {
const tag = ob?.tag;
type TcpEntry =
| { kind: 'tpl'; index: number; outbound: unknown }
| { kind: 'sub'; tag: string; outbound: unknown };
const tcpQueue: TcpEntry[] = [];
// HTTP batches stay homogeneous (all template or all subscription) so a
// tag shared between a template and a subscription outbound can't collide
// inside one batch, and each batch's results route to one state map.
const httpTplQueue: { index: number; outbound: unknown }[] = [];
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
const proto = ob?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
if (mode === 'http' || isUdpOutbound(ob)) {
httpQueue.push({ index: i, outbound: ob });
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
// freedom ("direct") and dns aren't proxies — skip them in every mode.
if (proto === 'freedom' || proto === 'dns') return;
if (kind === 'sub' && !tag) return;
const toHttp = mode === 'http' || isUdpOutbound(ob);
if (kind === 'tpl') {
if (toHttp) httpTplQueue.push({ index, outbound: ob });
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
} else if (toHttp) {
httpSubQueue.push({ tag, outbound: ob });
} else {
tcpQueue.push({ index: i, outbound: ob });
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
}
});
};
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
// TCP probes are dial-only and cheap server-side; per-item requests
// keep results landing one by one.
// keep results landing one by one, each routed to its own state map.
const runTcpLane = async () => {
const queue = [...tcpQueue];
const worker = async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
else await testOutbound(item.index, item.outbound, mode);
}
};
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
};
// HTTP probes go out as chunked batches — one temp xray spawn per
// chunk instead of one per outbound, with results landing per chunk.
const runHttpLane = async () => {
for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
const runTplHttpLane = async () => {
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
setOutboundTestStates((prev) => {
const next = { ...prev };
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
@@ -366,11 +388,38 @@ export function useXraySetting(): UseXraySettingResult {
});
}
};
const runSubHttpLane = async () => {
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' };
return next;
});
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
setSubscriptionTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.tag] = { testing: false, result: results[i] };
});
return next;
});
}
};
// HTTP batches must not overlap: the backend serialises them with a
// non-blocking lock and rejects a second concurrent batch ("Another
// outbound test is already running"). Run the template and subscription
// HTTP lanes one after the other; TCP probes don't take that lock, so
// they still run alongside.
const runHttpLane = async () => {
await runTplHttpLane();
await runSubHttpLane();
};
await Promise.all([runTcpLane(), runHttpLane()]);
} finally {
setTestingAll(false);
}
}, [testingAll, testOutbound, postOutboundTestBatch]);
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
useEffect(() => {
const timer = window.setInterval(() => {
+8 -3
View File
@@ -12,10 +12,13 @@ import {
CodeOutlined,
DashboardOutlined,
DatabaseOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
@@ -27,7 +30,6 @@ import {
TagsOutlined,
TeamOutlined,
ToolOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
@@ -40,7 +42,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
@@ -50,9 +52,10 @@ const iconByName: Record<IconName, ComponentType> = {
setting: SettingOutlined,
tool: ToolOutlined,
cluster: ClusterOutlined,
hosts: GlobalOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
outbound: UploadOutlined,
outbound: ExportOutlined,
};
function readCollapsed(): boolean {
@@ -138,6 +141,7 @@ export default function AppSidebar() {
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
@@ -153,6 +157,7 @@ export default function AppSidebar() {
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
];
if (showSubFormats) {
+33
View File
@@ -0,0 +1,33 @@
// Shape of one entry in a client's IP log, as returned by
// POST /panel/api/clients/ips/:email. `node` is the name of the node the IP is
// connecting through, or '' when it is on this local panel (or unattributed).
export type ClientIpInfo = {
ip: string;
time: string;
node: string;
};
// normalizeClientIps accepts the API payload and returns typed entries. It also
// tolerates the legacy shape (a plain array of "ip (time)" strings) so the UI
// keeps working against older panels.
export function normalizeClientIps(obj: unknown): ClientIpInfo[] {
if (!Array.isArray(obj)) return [];
const out: ClientIpInfo[] = [];
for (const x of obj) {
if (typeof x === 'string') {
if (x.length > 0) out.push({ ip: x, time: '', node: '' });
continue;
}
if (x && typeof x === 'object') {
const o = x as Record<string, unknown>;
const ip = typeof o.ip === 'string' ? o.ip : '';
if (!ip) continue;
out.push({
ip,
time: typeof o.time === 'string' ? o.time : '',
node: typeof o.node === 'string' ? o.node : '',
});
}
}
return out;
}
+50
View File
@@ -0,0 +1,50 @@
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
import type { HostFormValues } from '@/schemas/api/host';
// The subset of a host that affects its share link. Mirrors the fields the
// backend's hostToExternalProxyMap reads.
export type HostLinkInput = Pick<
HostFormValues,
| 'security'
| 'address'
| 'port'
| 'remark'
| 'sni'
| 'alpn'
| 'fingerprint'
| 'pinnedPeerCertSha256'
| 'echConfigList'
| 'overrideSniFromAddress'
| 'keepSniBlank'
>;
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
// share-link preview generators already understand — the frontend mirror of the
// backend's hostToExternalProxyMap. security "reality"/"same" keep the inbound's
// base TLS (forceTls "same"); the preview falls back to port 443 when the host
// inherits the inbound port (port 0).
export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntry {
const forceTls = host.security === 'tls' || host.security === 'none' ? host.security : 'same';
let sni: string | undefined;
if (host.keepSniBlank) {
sni = undefined;
} else if (host.overrideSniFromAddress) {
sni = host.address || undefined;
} else {
sni = host.sni || undefined;
}
return {
forceTls,
dest: host.address || '',
port: host.port && host.port > 0 ? host.port : 443,
remark: host.remark || '',
sni,
fingerprint: host.fingerprint,
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
pinnedPeerCertSha256:
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
echConfigList: host.echConfigList || undefined,
};
}
@@ -0,0 +1,70 @@
// Template variables an operator can embed in a Host's Remark. At subscription
// time the backend (internal/sub/remark_vars.go) substitutes each {{TOKEN}}
// per client. This file is the single frontend source of truth for the picker
// UI and the live preview — keep the token list in sync with remark_vars.go.
export type RemarkVarGroup = 'client' | 'traffic' | 'time';
export interface RemarkVar {
/** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
token: string;
group: RemarkVarGroup;
/** Example value used only for the form's live preview. */
sample: string;
}
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
export const REMARK_VARIABLES: RemarkVar[] = [
// Client identity
{ token: 'EMAIL', group: 'client', sample: 'john' },
{ token: 'INBOUND', group: 'client', sample: 'Germany' },
{ token: 'HOST', group: 'client', sample: 'CDN' },
{ token: 'ID', group: 'client', sample: '3f2a9c1b-aaaa-bbbb-cccc-1234567890ab' },
{ token: 'SHORT_ID', group: 'client', sample: '3f2a9c1b' },
{ token: 'TELEGRAM_ID', group: 'client', sample: '123456789' },
{ token: 'SUB_ID', group: 'client', sample: 'subABC' },
{ token: 'COMMENT', group: 'client', sample: 'vip' },
// Traffic
{ token: 'TRAFFIC_USED', group: 'traffic', sample: '8.40GB' },
{ token: 'TRAFFIC_LEFT', group: 'traffic', sample: '41.60GB' },
{ token: 'TRAFFIC_TOTAL', group: 'traffic', sample: '50.00GB' },
{ token: 'TRAFFIC_USED_BYTES', group: 'traffic', sample: '9019431321' },
{ token: 'TRAFFIC_LEFT_BYTES', group: 'traffic', sample: '44667656679' },
{ token: 'TRAFFIC_TOTAL_BYTES', group: 'traffic', sample: '53687091200' },
{ token: 'UP', group: 'traffic', sample: '5.20GB' },
{ token: 'DOWN', group: 'traffic', sample: '3.20GB' },
// Time / status
{ token: 'STATUS', group: 'time', sample: 'active' },
{ token: 'DAYS_LEFT', group: 'time', sample: '12' },
{ token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
{ token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
{ token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
{ token: 'RESET_DAYS', group: 'time', sample: '30' },
];
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
);
const TOKEN_RE = /\{\{([A-Z_]+)\}\}/g;
/** wrapToken("EMAIL") → "{{EMAIL}}". */
export function wrapToken(token: string): string {
return `{{${token}}}`;
}
/** Whether a remark string uses any {{VAR}} token at all. */
export function hasRemarkTokens(template: string): boolean {
return template.includes('{{');
}
/**
* previewRemark renders a template against the sample values, mirroring the
* backend substitution closely enough for an at-a-glance preview. Unknown
* tokens collapse to empty, just like the server.
*/
export function previewRemark(template: string): string {
if (!hasRemarkTokens(template)) return template;
return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
}
@@ -0,0 +1,76 @@
import { Button, Divider, Form, Input, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { NamePath } from 'antd/es/form/interface';
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
// entry is rendered as a titled group of labeled fields (system / level / opt /
// type / value) instead of one cramped inline row, so it reads like the rest of
// the sockopt form. Shared by the inbound and outbound (and host) sockopt forms.
// Ref: https://xtls.github.io/config/transports/sockopt.html#sockoptobject
const SYSTEM_OPTIONS = [
{ value: 'linux', label: 'linux' },
{ value: 'windows', label: 'windows' },
{ value: 'darwin', label: 'darwin' },
];
const TYPE_OPTIONS = [
{ value: 'int', label: 'int' },
{ value: 'str', label: 'str' },
];
interface CustomSockoptListProps {
name?: NamePath;
}
export default function CustomSockoptList({
name = ['streamSettings', 'sockopt', 'customSockopt'],
}: CustomSockoptListProps) {
const { t } = useTranslation();
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
>
{t('pages.inbounds.form.addCustomOption')}
</Button>
</Form.Item>
{fields.map((field, idx) => (
<div key={field.key}>
<Divider plain style={{ margin: '4px 0 8px' }}>
{t('pages.inbounds.form.customSockopt')} {idx + 1}
<DeleteOutlined
className="danger-icon"
style={{ marginInlineStart: 8 }}
onClick={() => remove(field.name)}
/>
</Divider>
<Form.Item label="System" name={[field.name, 'system']}>
<Select placeholder="all" allowClear options={SYSTEM_OPTIONS} />
</Form.Item>
<Form.Item label="Level" name={[field.name, 'level']}>
<Input placeholder="6 (SOL_TCP)" />
</Form.Item>
<Form.Item label="Opt" name={[field.name, 'opt']}>
<Input placeholder="decimal, e.g. 19" />
</Form.Item>
<Form.Item label="Type" name={[field.name, 'type']}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="Value" name={[field.name, 'value']}>
<Input placeholder="value" />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
);
}
@@ -4,7 +4,9 @@ import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
export interface FinalMaskFormProps {
name: NamePath;
@@ -18,6 +20,46 @@ export interface FinalMaskFormProps {
}
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 };
// Xray-core caps the Gecko output packet size at its internal buffer (2048)
// and needs 1 <= min <= max; mirror those bounds so the panel rejects what
// core would reject at runtime (salamander/conn.go).
const GECKO_MIN_PACKET_SIZE = 1;
const GECKO_MAX_PACKET_SIZE = 2048;
export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
const match = /^(\d+)-(\d+)$/.exec(str);
if (!match) return null;
const min = Number(match[1]);
const max = Number(match[2]);
if (
!Number.isSafeInteger(min) || !Number.isSafeInteger(max)
|| min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE
) {
return null;
}
return { min, max };
}
function formatGeckoPacketSize(min: number, max: number): string {
return `${min}-${max}`;
}
function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
const [minRaw = '', maxRaw = ''] = str.split('-', 2);
const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null;
const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null;
return { min, max };
}
function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
if (parseGeckoPacketSize(value)) return Promise.resolve();
return Promise.reject(new Error(
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
));
}
function asPath(name: NamePath): (string | number)[] {
return Array.isArray(name) ? [...name] : [name];
@@ -29,7 +71,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
case 'sudoku':
return {
password: '', ascii: '', customTable: '', customTables: [''],
password: '', ascii: '', customTable: '', customTables: [],
paddingMin: 0, paddingMax: 0,
};
case 'header-custom':
@@ -470,22 +512,7 @@ function UdpMaskItem({
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'salamander') {
return (
<Form.Item label="Password">
<Space.Compact block>
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
);
return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
}
if (type === 'mkcp-legacy') {
return (
@@ -537,6 +564,35 @@ function UdpMaskItem({
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
</Form.Item>
<Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
<Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
<Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
</Form.Item>
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
<Select
mode="multiple"
style={{ width: '100%' }}
options={[
{ value: 'h3', label: 'h3' },
{ value: 'h2', label: 'h2' },
{ value: 'http/1.1', label: 'http/1.1' },
]}
/>
</Form.Item>
<Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
<Select
allowClear
style={{ width: '100%' }}
options={UTLS_FINGERPRINT_OPTIONS}
/>
</Form.Item>
<Form.Item
label="Allow Insecure"
name={[fieldName, 'settings', 'tlsConfig', 'allowInsecure']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
);
}
@@ -565,6 +621,111 @@ function UdpMaskItem({
);
}
function SalamanderUdpMaskSettings({
fieldName, form, absolutePath,
}: {
fieldName: number;
form: FormInstance;
absolutePath: (string | number)[];
}) {
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
return (
<>
<Form.Item
label="Mode"
extra={mode === 'gecko'
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
: 'Scrambles each packet into random-looking bytes.'}
>
<Select
value={mode}
onChange={(next) => {
if (next === 'gecko') {
const current = form.getFieldValue(packetSizePath);
form.setFieldValue(
packetSizePath,
parseGeckoPacketSize(current)
? current
: formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
);
} else {
form.setFieldValue(packetSizePath, undefined);
}
}}
options={[
{ value: 'salamander', label: 'Salamander' },
{ value: 'gecko', label: 'Gecko experimental' },
]}
/>
</Form.Item>
<Form.Item label="Password">
<Space.Compact block>
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
{mode === 'gecko' && (
<Form.Item
label="Packet size"
name={[fieldName, 'settings', 'packetSize']}
rules={[{ validator: validateGeckoPacketSize }]}
extra="Serialized as a string range, for example 512-1200."
>
<GeckoPacketSizeInput />
</Form.Item>
)}
</>
);
}
function GeckoPacketSizeInput({
value,
onChange,
}: {
value?: string;
onChange?: (value: string) => void;
}) {
const { min, max } = splitGeckoPacketSize(value);
return (
<Space.Compact block>
<InputNumber
addonBefore="Min"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
precision={0}
value={min}
placeholder={String(DEFAULT_GECKO_PACKET_SIZE.min)}
onChange={(next) => onChange?.(`${next ?? ''}-${max ?? ''}`)}
style={{ width: '50%' }}
/>
<InputNumber
addonBefore="Max"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
precision={0}
value={max}
placeholder={String(DEFAULT_GECKO_PACKET_SIZE.max)}
onChange={(next) => onChange?.(`${min ?? ''}-${next ?? ''}`)}
style={{ width: '50%' }}
/>
</Space.Compact>
);
}
function UdpHeaderCustom({
udpFieldName, form, absoluteSettingsPath,
}: {
+51 -35
View File
@@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -23,6 +24,14 @@ import { getHeaderValue } from './headers';
type ForceTls = 'same' | 'tls' | 'none';
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
// Format a host for interpolation into a URL authority. IPv6 literals are
// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
// Any brackets already present are first stripped so the helper is idempotent.
function formatUrlHost(address: string): string {
const bare = address.replace(/^\[|\]$/g, '');
return bare.includes(':') ? `[${bare}]` : bare;
}
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
@@ -178,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
const stream = inbound.streamSettings;
if (!stream) return '';
const tls = forceTls === 'same' ? stream.security : forceTls;
const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
const obj: Record<string, unknown> = {
v: '2',
ps: remark,
@@ -374,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
@@ -394,13 +402,24 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
} else {
params.set('security', 'none');
}
const url = new URL(`vless://${clientId}@${address}:${port}`);
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
// vlessFlowAllowed and the form's flow-field gating so panel link, share
// link and subscription agree.
if (flow.length > 0 && canEnableTlsFlow({
protocol: inbound.protocol,
settings: inbound.settings,
streamSettings: stream,
})) {
params.set('flow', flow);
}
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -524,7 +543,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
params.set('security', 'none');
}
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -576,6 +595,18 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
applyExternalProxyTLSParams(externalProxy, params, security);
}
// SIP002 clients (v2rayN) ignore type/headerType/host/path and only read
// `plugin`. Re-encode a TCP http header as obfs-local so they build a
// matching tcp/http outbound (v2rayN forces request path "/").
if ((stream.network ?? 'tcp') === 'tcp' && params.get('headerType') === 'http') {
const host = params.get('host') ?? '';
params.delete('type');
params.delete('headerType');
params.delete('host');
params.delete('path');
params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`);
}
const isSS2022 = settings.method.substring(0, 4) === '2022';
const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305';
const passwords: string[] = [];
@@ -583,7 +614,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
if (isSSMultiUser) passwords.push(clientPassword);
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
const url = new URL(`ss://${userinfo}@${address}:${port}`);
const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -681,7 +712,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
params.set('mport', hopPorts);
}
const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -724,7 +755,7 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
const peer = settings.peers[peerIndex];
if (!peer) return '';
const url = new URL(`wireguard://${address}:${port}`);
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
url.username = peer.privateKey ?? '';
const pubKey = settings.secretKey.length > 0
@@ -964,23 +995,19 @@ export interface GenAllLinksEntry {
export interface GenAllLinksInput {
inbound: Inbound;
remark?: string;
remarkModel?: string;
client: ClientShape;
hostOverride?: string;
fallbackHostname: string;
}
// Fans out a single client's link per externalProxy entry, or just one
// link when there are no external proxies. remarkModel is a 4-char
// string: first char is the separator, remaining chars pick which
// pieces to compose into the per-link remark — 'i' = inbound remark,
// 'e' = client email, 'o' = externalProxy remark. Defaults to '-io'
// (dash-separated, inbound + email + proxy).
// Fans out a single client's link per externalProxy entry, or just one link
// when there are no external proxies. The panel copy/QR remark is the inbound
// remark plus the externalProxy remark, dash-joined (the configurable
// subscription remark model was removed; subscription output uses the template).
export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
const {
inbound,
remark = '',
remarkModel = '-io',
client,
hostOverride = '',
fallbackHostname,
@@ -988,17 +1015,9 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const port = inbound.port;
const separationChar = remarkModel.charAt(0);
const orderChars = remarkModel.slice(1);
const email = client.email ?? '';
const composeRemark = (proxyRemark: string): string => {
const orders: Record<string, string> = { i: remark, e: email, o: proxyRemark };
return orderChars.split('')
.map((c) => orders[c] ?? '')
.filter((x) => x.length > 0)
.join(separationChar);
};
const composeRemark = (proxyRemark: string): string =>
[remark, proxyRemark].filter((x) => x.length > 0).join('-');
const externals = inbound.streamSettings?.externalProxy;
if (!externals || externals.length === 0) {
@@ -1025,7 +1044,6 @@ export function genAllLinks(input: GenAllLinksInput): GenAllLinksEntry[] {
export interface GenInboundLinksInput {
inbound: Inbound;
remark?: string;
remarkModel?: string;
hostOverride?: string;
fallbackHostname: string;
}
@@ -1039,7 +1057,6 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
const {
inbound,
remark = '',
remarkModel = '-io',
hostOverride = '',
fallbackHostname,
} = input;
@@ -1048,7 +1065,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
if (clients) {
const links: string[] = [];
for (const client of clients) {
const entries = genAllLinks({ inbound, remark, remarkModel, client, hostOverride, fallbackHostname });
const entries = genAllLinks({ inbound, remark, client, hostOverride, fallbackHostname });
for (const e of entries) links.push(e.link);
}
return links.join('\r\n');
@@ -1057,7 +1074,7 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
return genShadowsocksLink({ inbound, address: addr, port: inbound.port, forceTls: 'same', remark });
}
if (inbound.protocol === 'wireguard') {
return genWireguardConfigs({ inbound, remark, remarkModel, hostOverride, fallbackHostname });
return genWireguardConfigs({ inbound, remark, hostOverride, fallbackHostname });
}
return '';
}
@@ -1068,16 +1085,15 @@ export function genInboundLinks(input: GenInboundLinksInput): string {
export interface GenWireguardFanoutInput {
inbound: Inbound;
remark?: string;
remarkModel?: string;
hostOverride?: string;
fallbackHostname: string;
}
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'wireguard') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = remarkModel.charAt(0);
const sep = '-';
return inbound.settings.peers
.map((p, i) => genWireguardLink({
settings: inbound.settings as WireguardInboundSettings,
@@ -1090,10 +1106,10 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
}
export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
const { inbound, remark = '', remarkModel = '-io', hostOverride = '', fallbackHostname } = input;
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'wireguard') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = remarkModel.charAt(0);
const sep = '-';
return inbound.settings.peers
.map((p, i) => genWireguardConfig({
settings: inbound.settings as WireguardInboundSettings,
+10 -23
View File
@@ -47,29 +47,16 @@ const TRANSPORT_COLOR = 'gold';
const TAG_STYLE = { marginInlineEnd: 0, fontWeight: 600, letterSpacing: '0.3px' };
/* Strip the client email and the optional traffic/expiry decorations the
panel appends to a remark (e.g. "5.23GB📊", "30D⏳", "⛔️N/A") together
with any separator chars left dangling, so the label shows just the
inbound remark. The email is known from the client record, so it can be
removed even though its position in the composed remark depends on the
panel's remark-model settings. */
function cleanRemark(remark: string, email: string): string {
let r = remark
.replace(/⛔️?N\/A/gu, '')
.replace(/[0-9][0-9A-Za-z.,]*[📊⏳]/gu, '');
if (email) {
const esc = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
r = r.replace(new RegExp(`[\\s\\-_.|,@]*${esc}`, 'g'), '');
}
return r.replace(/^[\s\-_.|,@]+|[\s\-_.|,@]+$/gu, '').trim();
}
/* Pull protocol, transport, security plus the remark and port out of a share
link. vless/trojan carry network+security as `type`/`security` query params
and the remark in the URL hash; vmess packs them into the base64 JSON as
`net`/`tls`/`ps`/`port`. Returns null when the scheme is unknown or the
payload can't be parsed, so callers fall back to "Link N".
/* Pull protocol, transport, security plus the inbound remark and port out
of a share link. vless/trojan carry network+security as `type`/`security`
query params and the remark in the URL hash; vmess packs them into the
base64 JSON as `net`/`tls`/`ps`/`port`. Returns null when the scheme is
unknown or the payload can't be parsed, so callers fall back to "Link N". */
export function parseLinkParts(link: string, email = ''): LinkParts | null {
The remark is shown verbatim: the panel displays the subscription's clean
(name-only) remarks — the per-client traffic/expiry info is rendered only
into the body a client app imports, so there is nothing to strip here. */
export function parseLinkParts(link: string): LinkParts | null {
const trimmed = link.trim();
const scheme = /^([a-z0-9]+):\/\//i.exec(trimmed)?.[1]?.toLowerCase() ?? '';
if (!scheme) return null;
@@ -106,7 +93,7 @@ export function parseLinkParts(link: string, email = ''): LinkParts | null {
protocol,
network: network.toUpperCase(),
security: security.toUpperCase(),
remark: cleanRemark(remark, email),
remark: remark.trim(),
port,
};
}
+11 -2
View File
@@ -9,8 +9,9 @@ import { Base64 } from '@/utils';
// fields the common vmess:// / vless:// links carry as query params.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux, reality shortIds,
// padding obfs key/header/placement, hysteria udphop are still left
// present in either the JSON or URL params. xmux and downloadSettings
// round-trip through the `extra` JSON blob. reality shortIds, padding
// obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — the legacy Outbound.fromLink
// was ~250 lines of dense edge-case handling we don't need to
// replicate verbatim for the common phone-to-panel workflow.
@@ -33,6 +34,10 @@ const XHTTP_NUMBER_KEYS = [
const XHTTP_BOOL_KEYS = [
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
] as const;
// Nested objects the inbound link bundles into the `extra` JSON blob
// (and vmess JSON carries inline). The outbound form adapter expands
// xmux into the XMUX sub-form (enableXmux) on load.
const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
function asBool(s: string | null): boolean | undefined {
if (s === null) return undefined;
@@ -88,6 +93,10 @@ function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): vo
for (const k of XHTTP_BOOL_KEYS) {
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
}
for (const k of XHTTP_OBJECT_KEYS) {
const v = json[k];
if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
}
}
function buildStream(network: string, security: string): Raw {
+12 -5
View File
@@ -13,16 +13,14 @@ export class AllSetting {
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
remarkModel = '-io';
remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
tgBotProxy = '';
tgBotAPIServer = '';
tgBotChatId = '';
tgRunTime = '@daily';
tgBotBackup = false;
tgBotLoginNotify = true;
tgCpu = 80;
tgLang = 'en-US';
twoFactorEnable = false;
@@ -50,8 +48,6 @@ export class AllSetting {
subKeyFile = '';
subUpdates = 12;
subEncrypt = true;
subShowInfo = true;
subEmailInRemark = true;
subURI = '';
subJsonURI = '';
subClashURI = '';
@@ -84,12 +80,23 @@ export class AllSetting {
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
tgEnabledEvents = '';
smtpEnable = false;
smtpHost = '';
smtpPort = 587;
smtpUsername = '';
smtpPassword = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
smtpCpu = 80;
hasTgBotToken = false;
hasTwoFactorToken = false;
hasLdapPassword = false;
hasApiToken = false;
hasWarpSecret = false;
hasNordSecret = false;
hasSmtpPassword = false;
constructor(data?: unknown) {
if (data != null) {
+136 -2
View File
@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/clients/get/:email',
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
},
{
method: 'POST',
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
body: '{\n "inboundIds": [5]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:email/externalLinks',
summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
],
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n ]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/resetAllTraffics',
@@ -720,6 +731,12 @@ export const sections: readonly Section[] = [
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 "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/clientIpsByGuid',
summary: 'Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.',
response: '{\n "success": true,\n "obj": {\n "a1b2-...": {\n "user1": [\n { "ip": "1.2.3.4", "timestamp": 1700000000 }\n ]\n }\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/activeInbounds',
@@ -779,6 +796,18 @@ export const sections: readonly Section[] = [
responseSchema: 'Node',
responseSchemaArray: true,
},
{
method: 'POST',
path: '/panel/api/nodes/mtls/ca',
summary: "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
response: '{\n "success": true,\n "obj": {\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n }\n}',
},
{
method: 'POST',
path: '/panel/api/nodes/mtls/trustCA',
summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
body: '{\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
},
{
method: 'GET',
path: '/panel/api/nodes/get/:id',
@@ -878,6 +907,99 @@ export const sections: readonly Section[] = [
],
},
{
id: 'hosts',
title: 'Hosts',
description:
'Per-inbound override endpoints. Each enabled host renders one extra subscription link/proxy with its own address/port/TLS, superseding the legacy externalProxy array. All endpoints under /panel/api/hosts.',
endpoints: [
{
method: 'GET',
path: '/panel/api/hosts/list',
summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.',
responseSchema: 'Host',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/hosts/get/:id',
summary: 'Fetch a single host by ID.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
],
responseSchema: 'Host',
},
{
method: 'GET',
path: '/panel/api/hosts/byInbound/:inboundId',
summary: "Fetch one inbound's hosts, ordered by sort order then id.",
params: [
{ name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
responseSchema: 'Host',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/hosts/tags',
summary: 'Distinct, sorted set of tags used across all hosts.',
response: '{\n "success": true,\n "obj": ["CDN", "EU", "FAST"]\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/add',
summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).',
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
responseSchema: 'Host',
},
{
method: 'POST',
path: '/panel/api/hosts/update/:id',
summary: 'Replace a host’s content. The inbound and sort order are immutable here (use /reorder for ordering).',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
],
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}',
responseSchema: 'Host',
},
{
method: 'POST',
path: '/panel/api/hosts/del/:id',
summary: 'Delete a host.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
],
},
{
method: 'POST',
path: '/panel/api/hosts/setEnable/:id',
summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' },
],
body: '{\n "enable": true\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/reorder',
summary: 'Set host sort order by the position of each id in the array.',
body: '{\n "ids": [3, 1, 2]\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/bulk/setEnable',
summary: 'Enable or disable many hosts in one call.',
body: '{\n "ids": [1, 2, 3],\n "enable": false\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/bulk/del',
summary: 'Delete many hosts in one call.',
body: '{\n "ids": [1, 2, 3]\n}',
},
],
},
{
id: 'backup',
title: 'Backup',
@@ -931,6 +1053,18 @@ export const sections: readonly Section[] = [
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.',
},
{
method: 'POST',
path: '/panel/api/setting/testSmtp',
summary: 'Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.',
response: '{\n "success": true,\n "stage": "send",\n "msg": "Test email sent successfully"\n}',
},
{
method: 'POST',
path: '/panel/api/setting/testTgBot',
summary: 'Test Telegram bot connection by sending a test message to the configured chat.',
response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}',
},
{
method: 'GET',
path: '/panel/api/setting/getDefaultJsonConfig',
+163 -71
View File
@@ -16,16 +16,18 @@ import {
Tabs,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -38,6 +40,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
// One editable row in the Links tab. `key` is a stable client-side id for React.
interface ExternalLinkRow {
key: number;
kind: 'link' | 'subscription';
value: string;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
@@ -51,10 +60,13 @@ interface SaveMetaEdit {
email: string;
attach: number[];
detach: number[];
externalLinks: ExternalLinkInput[];
}
interface SaveMetaCreate {
isEdit: false;
email: string;
externalLinks: ExternalLinkInput[];
}
interface SaveCreatePayload {
@@ -67,6 +79,7 @@ interface ClientFormModalProps {
mode: Mode;
client: ClientRecord | null;
inbounds: InboundOption[];
attachedExternalLinks?: ExternalLink[];
attachedIds?: number[];
tgBotEnable?: boolean;
groups?: string[];
@@ -98,6 +111,7 @@ interface FormState {
comment: string;
enable: boolean;
inboundIds: number[];
externalLinks: ExternalLinkRow[];
}
function emptyForm(): FormState {
@@ -121,9 +135,19 @@ function emptyForm(): FormState {
comment: '',
enable: true,
inboundIds: [],
externalLinks: [],
};
}
let externalLinkRowSeq = 0;
function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
return (links || []).map((l) => ({
key: (externalLinkRowSeq += 1),
kind: l.kind === 'subscription' ? 'subscription' : 'link',
value: l.value || '',
}));
}
function bytesToGB(bytes: number): number {
if (!bytes || bytes <= 0) return 0;
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
@@ -139,6 +163,7 @@ export default function ClientFormModal({
mode,
client,
inbounds,
attachedExternalLinks = [],
attachedIds = [],
tgBotEnable = false,
groups = [],
@@ -153,7 +178,7 @@ export default function ClientFormModal({
const [form, setForm] = useState<FormState>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const [resetting, setResetting] = useState(false);
const [clientIps, setClientIps] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
@@ -162,6 +187,27 @@ export default function ClientFormModal({
setForm((prev) => ({ ...prev, [key]: value }));
}
function addExternalLinkRow(kind: 'link' | 'subscription') {
setForm((prev) => ({
...prev,
externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
}));
}
function updateExternalLinkRow(key: number, value: string) {
setForm((prev) => ({
...prev,
externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
}));
}
function removeExternalLinkRow(key: number) {
setForm((prev) => ({
...prev,
externalLinks: prev.externalLinks.filter((r) => r.key !== key),
}));
}
useEffect(() => {
if (!open) return;
setIpsModalOpen(false);
@@ -186,6 +232,7 @@ export default function ClientFormModal({
comment: client.comment || '',
enable: !!client.enable,
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
externalLinks: toExternalLinkRows(attachedExternalLinks),
};
if (et < 0) {
next.delayedStart = true;
@@ -300,14 +347,16 @@ export default function ClientFormModal({
[inbounds],
);
const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
async function loadIps() {
if (!isEdit || !client?.email) return;
setIpsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
setClientIps(normalizeClientIps(msg.obj));
} finally {
setIpsLoading(false);
}
@@ -400,6 +449,10 @@ export default function ClientFormModal({
clientPayload.reverse = { tag: reverseTag };
}
const externalLinks: ExternalLinkInput[] = form.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
setSubmitting(true);
try {
let msg;
@@ -413,11 +466,12 @@ export default function ClientFormModal({
email: client.email,
attach: toAttach,
detach: toDetach,
externalLinks,
});
} else {
msg = await save(
{ client: clientPayload, inboundIds: form.inboundIds },
{ isEdit: false },
{ isEdit: false, email: clientPayload.email as string, externalLinks },
);
}
if (msg?.success) close();
@@ -624,71 +678,106 @@ export default function ClientFormModal({
label: t('pages.clients.tabCredentials'),
children: (
<>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
</Space.Compact>
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
<Row gutter={16}>
{showFlow && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Col>
)}
{showSecurity && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.vmessSecurity')}>
<Select
value={form.security}
onChange={(v) => update('security', v)}
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
/>
</Form.Item>
</Col>
)}
</Row>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
{showFlow && (
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
)}
{showSecurity && (
<Form.Item label={t('pages.clients.vmessSecurity')}>
<Select
value={form.security}
onChange={(v) => update('security', v)}
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
/>
</Form.Item>
)}
</>
),
},
{
key: 'links',
label: t('pages.clients.tabLinks'),
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
{t('pages.clients.linksHint')}
</Typography.Paragraph>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
{t('pages.clients.addExternalLink')}
</Button>
<div style={{ marginTop: 12, marginBottom: 24 }}>
{linkRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
) : linkRows.map((row) => (
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
{t('pages.clients.addExternalSubscription')}
</Button>
<div style={{ marginTop: 12 }}>
{subscriptionRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
) : subscriptionRows.map((row) => (
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="https://provider.example/sub/…"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
</div>
</>
),
},
@@ -717,7 +806,7 @@ export default function ClientFormModal({
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((ip, idx) => (
{clientIps.map((entry, idx) => (
<Tag
key={idx}
color="blue"
@@ -730,7 +819,10 @@ export default function ClientFormModal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{ip}
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
) : null}
</Tag>
))}
</div>
@@ -5,6 +5,7 @@ import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
@@ -80,7 +81,7 @@ export default function ClientInfoModal({
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
const [messageApi, messageContextHolder] = message.useMessage();
const [links, setLinks] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
@@ -144,8 +145,7 @@ export default function ClientInfoModal({
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
setClientIps(normalizeClientIps(msg.obj));
} finally {
setIpsLoading(false);
}
@@ -354,7 +354,7 @@ export default function ClientInfoModal({
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => {
const parts = parseLinkParts(link, client.email);
const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
@@ -503,7 +503,7 @@ export default function ClientInfoModal({
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((ip, idx) => (
{clientIps.map((entry, idx) => (
<Tag
key={idx}
color="blue"
@@ -516,7 +516,10 @@ export default function ClientInfoModal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{ip}
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
) : null}
</Tag>
))}
</div>
+1 -1
View File
@@ -92,7 +92,7 @@ export default function ClientQrModal({
});
}
links.forEach((link, idx) => {
const parts = parseLinkParts(link, client?.email ?? '');
const parts = parseLinkParts(link);
const meta = parts ? linkMetaText(parts) : '';
const label: React.ReactNode = parts ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
+29 -14
View File
@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
import AppSidebar from '@/layouts/AppSidebar';
import { IntlUtil, SizeFormatter } from '@/utils';
@@ -197,9 +197,9 @@ export default function ClientsPage() {
summary: serverSummary,
allGroups,
setQuery,
inbounds, onlines, loading, fetched, fetchError, subSettings,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent,
refresh,
@@ -220,6 +220,7 @@ export default function ClientsPage() {
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
const [infoOpen, setInfoOpen] = useState(false);
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
const [qrOpen, setQrOpen] = useState(false);
@@ -429,6 +430,7 @@ export default function ClientsPage() {
setFormMode('add');
setEditingClient(null);
setEditingAttachedIds([]);
setEditingExternalLinks([]);
setFormOpen(true);
}
@@ -441,6 +443,7 @@ export default function ClientsPage() {
setEditingClient(merged);
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
}
@@ -567,10 +570,18 @@ export default function ClientsPage() {
const onSave = useCallback(async (
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
meta:
| { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
| { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
) => {
if (!meta.isEdit) {
return create(payload);
const createMsg = await create(payload);
if (!createMsg?.success) return createMsg;
if (meta.email && meta.externalLinks.length > 0) {
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
}
return createMsg;
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
@@ -582,8 +593,11 @@ export default function ClientsPage() {
const r = await detach(meta.email, meta.detach);
if (!r?.success) return r;
}
// Always replace the client's external links (an empty set clears them).
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
return updateMsg;
}, [create, update, attach, detach]);
}, [create, update, attach, detach, setExternalLinks]);
const pageClass = useMemo(() => {
const classes = ['clients-page'];
@@ -605,19 +619,19 @@ export default function ClientsPage() {
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -649,7 +663,7 @@ export default function ClientsPage() {
</Tooltip>
);
if (record.enable && isOnline(record.email)) return (
<Tag color="green"><span className="online-dot" />{t('pages.clients.online')}</Tag>
<Tag color="green" className="dot-tag"><span className="online-dot" />{t('pages.clients.online')}</Tag>
);
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
@@ -1100,7 +1114,7 @@ export default function ClientsPage() {
<Table<ClientRecord>
columns={columns}
dataSource={sortedClients}
loading={loading}
loading={transitioning}
rowKey="email"
rowSelection={rowSelection}
pagination={tablePagination}
@@ -1117,7 +1131,7 @@ export default function ClientsPage() {
}}
/>
) : (
<Spin spinning={loading}>
<Spin spinning={transitioning}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
mode={formMode}
client={editingClient}
attachedIds={editingAttachedIds}
attachedExternalLinks={editingExternalLinks}
inbounds={inbounds}
tgBotEnable={tgBotEnable}
groups={allGroups}
+2 -2
View File
@@ -165,7 +165,7 @@ export default function SubLinksModal({
<Alert
type="warning"
showIcon
message={t('pages.clients.subLinksDisabled')}
title={t('pages.clients.subLinksDisabled')}
description={t('pages.clients.subLinksDisabledHint')}
style={{ marginBottom: 12 }}
/>
@@ -174,7 +174,7 @@ export default function SubLinksModal({
<Alert
type="info"
showIcon
message={t('pages.clients.subLinksEmpty')}
title={t('pages.clients.subLinksEmpty')}
style={{ marginBottom: 12 }}
/>
)}
+2 -2
View File
@@ -407,10 +407,10 @@ export default function GroupsPage() {
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
</Tooltip>
</Space>
),
+335
View File
@@ -0,0 +1,335 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
import {
ProfileOutlined,
SafetyCertificateOutlined,
ControlOutlined,
NodeIndexOutlined,
SettingOutlined,
PartitionOutlined,
DeploymentUnitOutlined,
RocketOutlined,
} from '@ant-design/icons';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { HostFormValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
// inboundId is optional in the form so a new host starts unselected (the Select
// shows its placeholder instead of 0); the required rule enforces it on submit.
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
interface HostFormModalProps {
open: boolean;
mode: 'add' | 'edit';
host: HostRecord | null;
inboundOptions: InboundOption[];
save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>;
onOpenChange: (open: boolean) => void;
}
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
function defaultsFor(host: HostRecord | null): FormShape {
return {
inboundId: host?.inboundId,
sortOrder: host?.sortOrder ?? 0,
remark: host?.remark ?? '',
serverDescription: host?.serverDescription ?? '',
enable: host ? !host.isDisabled : true,
isHidden: host?.isHidden ?? false,
tags: host?.tags ?? [],
address: host?.address ?? '',
port: host?.port ?? 0,
security: (host?.security as HostFormValues['security']) ?? 'same',
sni: host?.sni ?? '',
hostHeader: host?.hostHeader ?? '',
path: host?.path ?? '',
alpn: (host?.alpn as HostFormValues['alpn']) ?? [],
fingerprint: host?.fingerprint as HostFormValues['fingerprint'],
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
keepSniBlank: host?.keepSniBlank ?? false,
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
verifyPeerCertByName: host?.verifyPeerCertByName ?? false,
allowInsecure: host?.allowInsecure ?? false,
echConfigList: host?.echConfigList ?? '',
muxParams: asString(host?.muxParams),
sockoptParams: asString(host?.sockoptParams),
finalMask: host?.finalMask ?? '',
vlessRoute: host?.vlessRoute ?? '',
excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [],
nodeGuids: host?.nodeGuids ?? [],
mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'],
mihomoX25519: host?.mihomoX25519 ?? false,
shuffleHost: host?.shuffleHost ?? false,
};
}
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [form] = Form.useForm<FormShape>();
// Drive conditional field visibility off the selected security, like the
// legacy externalProxy form: same/none inherit fully and hide every TLS/cert
// field; reality shows only the reality-relevant subset (its keys are
// inherited from the inbound); tls shows the full TLS override set.
const security = (Form.useWatch('security', form) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls';
useEffect(() => {
if (open) form.setFieldsValue(defaultsFor(host));
}, [open, host, form]);
const { nodes } = useNodesQuery();
const inboundSelectOptions = useMemo(
() => inboundOptions.map((ib) => ({
value: ib.id,
label: ib.remark || ib.tag || `#${ib.id}`,
})),
[inboundOptions],
);
const nodeSelectOptions = useMemo(
() => nodes
.filter((n) => n.guid)
.map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
[nodes],
);
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const onOk = async () => {
let values: FormShape;
try {
values = await form.validateFields();
} catch {
return;
}
const { enable, ...rest } = values;
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
const res = await save(payload);
if (res?.success) {
message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
onOpenChange(false);
} else if (res?.msg) {
message.error(res.msg);
}
};
return (
<Modal
open={open}
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={onOk}
onCancel={() => onOpenChange(false)}
okText={t('save')}
cancelText={t('cancel')}
destroyOnHidden
width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
>
<Form
form={form}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={defaultsFor(host)}
preserve={false}
>
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
forceRender: true,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: (
<>
<Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
<Input maxLength={256} />
</Form.Item>
<Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} />
</Form.Item>
<Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
<Select
options={inboundSelectOptions}
showSearch
optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')}
/>
</Form.Item>
<Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
<Input placeholder="cdn.example.com" />
</Form.Item>
<Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} />
</Form.Item>
<Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
<Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
<Switch />
</Form.Item>
</>
),
},
{
key: 'security',
forceRender: true,
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
children: (
<>
<Form.Item name="security" label={t('pages.hosts.fields.security')}>
<Select
options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
{showTls && (
<>
<Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
</Form.Item>
<Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
<Select allowClear options={fpOptions} />
</Form.Item>
</>
)}
{showTlsExtras && (
<>
<Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
<Select mode="multiple" allowClear options={alpnOptions} />
</Form.Item>
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
<Input.TextArea rows={2} />
</Form.Item>
</>
)}
</>
),
},
{
key: 'advanced',
forceRender: true,
label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
children: (
<Tabs
size="small"
defaultActiveKey="adv-general"
items={[
{
key: 'adv-general',
forceRender: true,
label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
children: (
<>
<Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
<Input />
</Form.Item>
<Form.Item name="path" label={t('pages.hosts.fields.path')}>
<Input />
</Form.Item>
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="53,443,1000-2000" />
</Form.Item>
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select
mode="multiple"
allowClear
options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
</>
),
},
{
key: 'adv-mux',
forceRender: true,
label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
children: (
<Form.Item name="muxParams" noStyle>
<HostMuxForm />
</Form.Item>
),
},
{
key: 'adv-sockopt',
forceRender: true,
label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
children: (
<Form.Item name="sockoptParams" noStyle>
<HostSockoptForm />
</Form.Item>
),
},
{
key: 'adv-finalmask',
forceRender: true,
label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
children: (
<Form.Item name="finalMask" noStyle>
<HostFinalMaskForm />
</Form.Item>
),
},
]}
/>
),
},
{
key: 'clash',
forceRender: true,
label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
children: (
<>
<Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
<Select
allowClear
options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
<Switch />
</Form.Item>
</>
),
},
]}
/>
</Form>
</Modal>
);
}
+58
View File
@@ -0,0 +1,58 @@
.hosts-card {
width: 100%;
}
.host-remark-cell {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.host-remark {
font-weight: 500;
}
.host-desc {
font-size: 0.82em;
color: var(--ant-color-text-secondary);
}
.host-endpoint {
font-family: var(--font-mono, monospace);
font-size: 0.92em;
}
.host-muted {
color: var(--ant-color-text-quaternary);
}
/* card-toolbar is shared with the Clients/Inbounds list cards, but its rules
live in a lazily-loaded page stylesheet — re-declare here so the Hosts page
renders correctly when opened directly. */
.hosts-card .card-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 6px 0;
}
@media (min-width: 769px) and (max-width: 920px) {
.hosts-card .card-toolbar {
gap: 6px;
}
}
/* Empty-table state. The shared .card-empty rule otherwise lives only in the
lazily-loaded Clients/Inbounds/Nodes stylesheets, so a direct /hosts refresh
would render it unstyled (faint + uncentered) until another page is visited.
Re-declare it here so it's correct on first load. */
.card-empty {
text-align: center;
color: var(--ant-color-text-secondary);
padding: 24px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
+195
View File
@@ -0,0 +1,195 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
EditOutlined,
GlobalOutlined,
PlusOutlined,
} from '@ant-design/icons';
import type { HostRecord } from '@/api/queries/useHostsQuery';
import type { InboundOption } from '@/schemas/client';
import './HostList.css';
interface HostListProps {
hosts: HostRecord[];
inboundOptions: InboundOption[];
loading?: boolean;
isMobile?: boolean;
selectedIds: number[];
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onEdit: (host: HostRecord) => void;
onDelete: (host: HostRecord) => void;
onToggleEnable: (host: HostRecord, next: boolean) => void;
onMove: (host: HostRecord, dir: 'up' | 'down') => void;
onBulkEnable: (enable: boolean) => void;
onBulkDelete: () => void;
}
// Sorted by inbound then sort_order then id — the same order the subscription
// renderer uses, so the list mirrors the emitted link order.
function sortHosts(hosts: HostRecord[]): HostRecord[] {
return [...hosts].sort((a, b) => {
if (a.inboundId !== b.inboundId) return a.inboundId - b.inboundId;
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return a.id - b.id;
});
}
export default function HostList(props: HostListProps) {
const { t } = useTranslation();
const {
hosts, inboundOptions, loading, isMobile, selectedIds, onSelectionChange,
onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
} = props;
const inboundLabel = useMemo(() => {
const map = new Map<number, string>();
for (const ib of inboundOptions) map.set(ib.id, ib.remark || ib.tag || `#${ib.id}`);
return map;
}, [inboundOptions]);
const sorted = useMemo(() => sortHosts(hosts), [hosts]);
// Move is bounded to neighbours within the same inbound (sort_order is per-inbound).
const movable = useMemo(() => {
const byInbound = new Map<number, number>();
const idxInGroup = new Map<number, number>();
const counters = new Map<number, number>();
for (const h of sorted) byInbound.set(h.inboundId, (byInbound.get(h.inboundId) ?? 0) + 1);
for (const h of sorted) {
const c = counters.get(h.inboundId) ?? 0;
idxInGroup.set(h.id, c);
counters.set(h.inboundId, c + 1);
}
return { byInbound, idxInGroup };
}, [sorted]);
// Column order requested: Actions, Enable, then the rest.
const columns: ColumnsType<HostRecord> = [
{
title: t('pages.hosts.fields.actions'),
key: 'actions',
width: 168,
render: (_, h) => {
const idx = movable.idxInGroup.get(h.id) ?? 0;
const count = movable.byInbound.get(h.inboundId) ?? 1;
return (
<Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}>
<Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
</Tooltip>
<Tooltip title={t('pages.hosts.moveDown')}>
<Button size="small" type="text" icon={<ArrowDownOutlined />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(h)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(h)} />
</Tooltip>
</Space>
);
},
},
{
title: t('pages.hosts.fields.enable'),
key: 'enable',
width: 90,
render: (_, h) => (
<Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} />
),
},
{
title: t('pages.hosts.fields.remark'),
dataIndex: 'remark',
key: 'remark',
render: (_, h) => (
<div className="host-remark-cell">
<span className="host-remark">{h.remark}</span>
{h.serverDescription ? <span className="host-desc">{h.serverDescription}</span> : null}
</div>
),
},
{
title: t('pages.hosts.fields.endpoint'),
key: 'endpoint',
render: (_, h) => <span className="host-endpoint">{`${h.address || '—'}${h.port ? `:${h.port}` : ''}`}</span>,
},
{
title: t('pages.hosts.fields.inbound'),
key: 'inbound',
render: (_, h) => inboundLabel.get(h.inboundId) ?? `#${h.inboundId}`,
},
{
title: t('pages.hosts.fields.security'),
dataIndex: 'security',
key: 'security',
render: (security: string) => <Tag>{security || 'same'}</Tag>,
},
{
title: t('pages.hosts.fields.tags'),
key: 'tags',
render: (_, h) => (h.tags && h.tags.length > 0
? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
: <span className="host-muted">—</span>),
},
];
const toolbar = (
<div className="card-toolbar">
{selectedIds.length === 0 ? (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{!isMobile && t('pages.hosts.addHost')}
</Button>
) : (
<>
<Tag
color="blue"
closable
onClose={() => onSelectionChange([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
>
{t('pages.hosts.selectedCount', { count: selectedIds.length })}
</Tag>
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
<Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
</>
)}
</div>
);
return (
<Card size="small" hoverable title={toolbar} className="hosts-card">
<Table<HostRecord>
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={sorted}
pagination={false}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys as number[]),
}}
locale={{
emptyText: (
<div className="card-empty">
<GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
</Card>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
import { CheckCircleOutlined, GlobalOutlined, StopOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHostsQuery, type HostRecord } from '@/api/queries/useHostsQuery';
import { useHostMutations } from '@/api/queries/useHostMutations';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import AppSidebar from '@/layouts/AppSidebar';
import { setMessageInstance } from '@/utils/messageBus';
import type { HostFormValues } from '@/schemas/api/host';
import HostList from './HostList';
import HostFormModal from './HostFormModal';
// Hosts for one inbound in render order — used to compute a reorder payload.
function inboundHostsInOrder(hosts: HostRecord[], inboundId: number): HostRecord[] {
return hosts
.filter((h) => h.inboundId === inboundId)
.sort((a, b) => {
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return a.id - b.id;
});
}
export default function HostsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
const { create, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
const { data: inboundOptions = [] } = useInboundOptions();
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formHost, setFormHost] = useState<HostRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const onAdd = useCallback(() => {
setFormMode('add');
setFormHost(null);
setFormOpen(true);
}, []);
const onEdit = useCallback((host: HostRecord) => {
setFormMode('edit');
setFormHost({ ...host });
setFormOpen(true);
}, []);
const onSave = useCallback(async (payload: Partial<HostFormValues>) => {
if (formMode === 'edit' && formHost?.id) {
return update(formHost.id, payload);
}
return create(payload);
}, [formMode, formHost, update, create]);
const onDelete = useCallback((host: HostRecord) => {
modal.confirm({
title: t('pages.hosts.deleteConfirmTitle', { name: host.remark }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(host.id);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
},
});
}, [modal, t, remove, messageApi]);
const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
await setEnable(host.id, next);
}, [setEnable]);
const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
const group = inboundHostsInOrder(hosts, host.inboundId);
const idx = group.findIndex((h) => h.id === host.id);
const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swapWith < 0 || swapWith >= group.length) return;
const ids = group.map((h) => h.id);
[ids[idx], ids[swapWith]] = [ids[swapWith], ids[idx]];
await reorder(ids);
}, [hosts, reorder]);
const onBulkEnable = useCallback(async (enable: boolean) => {
if (selectedIds.length === 0) return;
const msg = await bulkSetEnable(selectedIds, enable);
if (msg?.success) setSelectedIds([]);
}, [selectedIds, bulkSetEnable]);
const onBulkDelete = useCallback(() => {
if (selectedIds.length === 0) return;
modal.confirm({
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedIds.length }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkDel(selectedIds);
if (msg?.success) {
messageApi.success(t('pages.hosts.toasts.delete'));
setSelectedIds([]);
}
},
});
}, [selectedIds, modal, t, bulkDel, messageApi]);
const summary = useMemo(() => {
const total = hosts.length;
const enabled = hosts.filter((h) => !h.isDisabled).length;
return { total, enabled, disabled: total - enabled };
}, [hosts]);
const pageClass = useMemo(() => {
const classes = ['hosts-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={<Button type="primary" loading={loading} onClick={() => refetch()}>{t('refresh')}</Button>}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}>
<Col xs={8} sm={8} md={8}>
<Statistic
title={t('pages.hosts.summary.total')}
value={String(summary.total)}
prefix={<GlobalOutlined />}
/>
</Col>
<Col xs={8} sm={8} md={8}>
<Statistic
title={t('pages.hosts.summary.enabled')}
value={String(summary.enabled)}
prefix={<CheckCircleOutlined style={{ color: 'var(--ant-color-success)' }} />}
/>
</Col>
<Col xs={8} sm={8} md={8}>
<Statistic
title={t('pages.hosts.summary.disabled')}
value={String(summary.disabled)}
prefix={<StopOutlined style={{ color: 'var(--ant-color-text-quaternary)' }} />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<HostList
hosts={hosts}
inboundOptions={inboundOptions}
loading={loading}
isMobile={isMobile}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onToggleEnable={onToggleEnable}
onMove={onMove}
onBulkEnable={onBulkEnable}
onBulkDelete={onBulkDelete}
/>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<HostFormModal
open={formOpen}
mode={formMode}
host={formHost}
inboundOptions={inboundOptions}
save={onSave}
onOpenChange={setFormOpen}
/>
</Layout>
</ConfigProvider>
);
}
@@ -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';
// Per-host Final Mask editor — same shape as the sub-JSON settings one
// (SubJsonFinalMaskForm) but reused for a host: reads/writes the host's
// finalMask JSON string. The masks are merged into this host's JSON stream.
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 HostFinalMaskForm({ value = '', onChange }: { value?: string; onChange?: (next: string) => void }) {
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}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={{ finalmask: initial }}
>
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
</Form>
);
}
@@ -0,0 +1,25 @@
import { MuxForm } from '@/pages/xray/outbounds/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
// settings editor). Stored in the host's muxParams JSON string. Defaults match
// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
// when the toggle is off, an explicit mux object when on.
const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
return (
<OutboundSubtreeJsonForm
value={value}
onChange={onChange}
path={['mux']}
defaultSubtree={DEFAULT_MUX}
serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
// protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
// a host's mux override is protocol-agnostic and should always be editable.
render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
/>
);
}
@@ -0,0 +1,43 @@
import { SockoptForm } from '@/pages/xray/outbounds/transport';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
// Sockopt override editor — reuses the outbound SockoptForm (which carries its
// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
// sockoptParams JSON string.
//
// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
// from the output. Verified against xray-core transport/internet/sockopt_*.go:
// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
// on the outbound/dialer socket, so they stay. The outbound form no longer shows
// the inbound-only keys, but its default object still seeds them, so strip here.
const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
function serializeClientSockopt(sockopt: unknown): string {
if (!sockopt || typeof sockopt !== 'object') return serializeOverride(sockopt);
const copy = { ...(sockopt as Record<string, unknown>) };
for (const key of INBOUND_ONLY_SOCKOPT) delete copy[key];
return serializeOverride(copy);
}
export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
// Populate the dialerProxy dropdown with the panel's outbound tags (a host can
// chain through one of the subscription's outbounds by tag). dialerProxy chains
// through a single outbound, so balancers (routing targets) are excluded — only
// the outbound group is used; blackhole is dropped too (chaining to it just
// drops the traffic).
const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
const outboundTags = tagGroups?.outbounds ?? [];
return (
<OutboundSubtreeJsonForm
value={value}
onChange={onChange}
path={['streamSettings', 'sockopt']}
serialize={serializeClientSockopt}
render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
/>
);
}
@@ -0,0 +1,68 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Form, type FormInstance } from 'antd';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
interface OutboundSubtreeJsonFormProps {
value?: string;
onChange?: (next: string) => void;
// Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
path: (string | number)[];
// Renders the reused outbound form given this wrapper's own form instance.
render: (form: FormInstance<OutboundFormValues>) => ReactNode;
// Seeds the form when the stored value is empty, so toggling a section on
// pre-fills sensible defaults instead of blanks (used by Mux).
defaultSubtree?: Record<string, unknown>;
// Turns the edited subtree into the stored JSON string (default: prune empties).
// Mux overrides this to store '' (= inherit) when its enable flag is off.
serialize?: (subtree: unknown) => string;
}
// Hosts the reused outbound transport forms (which bind to fixed form paths)
// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
// from the JSON string, watch the edited subtree, and report a JSON string back
// to the parent host form. component={false} avoids a nested <form> DOM node.
export default function OutboundSubtreeJsonForm({
value = '',
onChange,
path,
render,
defaultSubtree,
serialize = serializeOverride,
}: OutboundSubtreeJsonFormProps) {
const [form] = Form.useForm();
const [initial] = useState<Record<string, unknown>>(() => {
const parsed = parseJsonObject(value);
return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
});
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const subtree = Form.useWatch(path, form);
useEffect(() => {
const next = serialize(subtree);
if (next !== value) onChangeRef.current?.(next);
// serialize is logically stable; re-run only when the edited subtree changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subtree, value]);
const hasInitial = Object.keys(initial).length > 0;
const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
return (
<Form
form={form}
component={false}
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
initialValues={initialValues}
>
{render(form as unknown as FormInstance<OutboundFormValues>)}
</Form>
);
}
@@ -0,0 +1,50 @@
// Shared helpers for the host's structured JSON-override editors. Each host
// override is persisted as a JSON string (muxParams / sockoptParams /
// finalMask); these convert between that string and the object the reused
// outbound/sub-JSON forms edit.
export function parseJsonObject(raw: string): Record<string, unknown> {
if (!raw) return {};
try {
const v = JSON.parse(raw);
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
} catch {
return {};
}
}
// Recursively drop '', null, undefined, and empty arrays/objects so an override
// stays sparse — only the keys the operator actually set are emitted and merged
// into the inbound stream. 0 and false are kept (meaningful sockopt/mux values).
export function pruneEmptyDeep(value: unknown): unknown {
if (Array.isArray(value)) {
const arr = value.map(pruneEmptyDeep).filter((v) => v !== undefined);
return arr.length ? arr : undefined;
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const pv = pruneEmptyDeep(v);
if (pv !== undefined) out[k] = pv;
}
return Object.keys(out).length ? out : undefined;
}
if (value === '' || value === null) return undefined;
return value;
}
// Prune then stringify; an all-empty override serializes to '' (= no override).
export function serializeOverride(value: unknown): string {
const pruned = pruneEmptyDeep(value);
return pruned === undefined ? '' : JSON.stringify(pruned);
}
// Build a nested object { a: { b: leaf } } from a form path ['a','b'] so the
// inner form can be seeded with initialValues at the exact path it edits.
export function nestAtPath(path: (string | number)[], leaf: unknown): Record<string, unknown> {
let acc: unknown = leaf;
for (let i = path.length - 1; i >= 0; i -= 1) {
acc = { [path[i]]: acc };
}
return acc as Record<string, unknown>;
}
@@ -0,0 +1,3 @@
export { default as HostMuxForm } from './HostMuxForm';
export { default as HostSockoptForm } from './HostSockoptForm';
export { default as HostFinalMaskForm } from './HostFinalMaskForm';
+4 -7
View File
@@ -82,6 +82,7 @@ export default function InboundsPage() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
totals,
expireDiff,
trafficDiff,
@@ -89,7 +90,6 @@ export default function InboundsPage() {
subSettings,
tgBotEnable,
ipLimitEnable,
remarkModel,
refresh,
hydrateInbound,
applyTrafficEvent,
@@ -264,13 +264,12 @@ export default function InboundsPage() {
content: genInboundLinks({
inbound: inboundFromDb(projected),
remark: projected.remark,
remarkModel,
hostOverride: hostOverrideFor(dbInbound),
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
}),
fileName: projected.remark || 'inbound',
});
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
@@ -302,13 +301,12 @@ export default function InboundsPage() {
out.push(genInboundLinks({
inbound: inboundFromDb(projected),
remark: projected.remark,
remarkModel,
hostOverride: hostOverrideFor(ib),
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
}));
}
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
}, [dbInbounds, hydrateInbound, checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
}, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
const exportAllSubs = useCallback(async () => {
const hydrated = await Promise.all(
@@ -620,6 +618,7 @@ export default function InboundsPage() {
clientCount={clientCount}
onlineClients={onlineClients}
lastOnlineMap={lastOnlineMap}
inboundSpeed={inboundSpeed}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
pageSize={pageSize}
@@ -656,7 +655,6 @@ export default function InboundsPage() {
onClose={() => setInfoOpen(false)}
dbInbound={infoDbInbound}
clientIndex={infoClientIndex}
remarkModel={remarkModel}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
ipLimitEnable={ipLimitEnable}
@@ -672,7 +670,6 @@ export default function InboundsPage() {
onClose={() => setQrOpen(false)}
dbInbound={qrDbInbound}
client={null}
remarkModel={remarkModel}
nodeAddress={qrNodeAddress}
subSettings={subSettings}
/>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
Alert,
@@ -64,7 +65,6 @@ import {
WireguardFields,
} from './protocols';
import {
ExternalProxyForm,
GrpcForm,
HttpUpgradeForm,
KcpForm,
@@ -83,6 +83,16 @@ import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Render a field label with a hover tooltip icon instead of an `extra` help line below.
const labelWithHint = (label: string, hint: string) => (
<span>
{label}
<Tooltip title={hint}>
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
</Tooltip>
</span>
);
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
@@ -240,23 +250,6 @@ export default function InboundFormModal({
onSecurityChange,
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
const toggleExternalProxy = (on: boolean) => {
if (on) {
const port = (form.getFieldValue('port') as number) ?? 443;
form.setFieldValue(['streamSettings', 'externalProxy'], [{
forceTls: 'same',
dest: typeof window !== 'undefined' ? window.location.hostname : '',
port,
remark: '',
sni: '',
fingerprint: '',
alpn: [],
pinnedPeerCertSha256: [],
}]);
} else {
form.setFieldValue(['streamSettings', 'externalProxy'], []);
}
};
const toggleSockopt = (on: boolean) => {
if (on) {
@@ -538,16 +531,14 @@ export default function InboundFormModal({
<Form.Item
name="listen"
label={t('pages.inbounds.address')}
extra={t('pages.inbounds.form.listenHelp')}
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
>
<Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item>
<Form.Item
name="shareAddrStrategy"
label={t('pages.inbounds.form.shareAddrStrategy')}
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
>
<Select
options={SHARE_ADDR_STRATEGIES
@@ -562,8 +553,7 @@ export default function InboundFormModal({
{shareAddrStrategy === 'custom' && (
<Form.Item
name="shareAddr"
label={t('pages.inbounds.form.shareAddr')}
extra={t('pages.inbounds.form.shareAddrHelp')}
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
rules={[{
validator: (_, value) => (
isValidShareAddrInput(String(value ?? ''))
@@ -578,8 +568,7 @@ export default function InboundFormModal({
<Form.Item
name="subSortIndex"
label={t('pages.inbounds.form.subSortIndex')}
extra={t('pages.inbounds.form.subSortIndexHelp')}
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
>
<InputNumber min={1} />
</Form.Item>
@@ -696,7 +685,7 @@ export default function InboundFormModal({
className="mt-12"
type="info"
showIcon
message={t('pages.inbounds.fallbacks.needsTls')}
title={t('pages.inbounds.fallbacks.needsTls')}
/>
)}
</>
@@ -804,14 +793,11 @@ export default function InboundFormModal({
</>
)}
{/* externalProxy only feeds client share links. Wireguard's per-peer
.conf fanout resolves its host elsewhere, and tunnel (dokodemo-door)
has no clients at all — the section is dead weight on both. */}
{protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL && (
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
)}
{/* The legacy externalProxy section is replaced by the Hosts page; the
field is still parsed/rendered for backward compatibility but is no
longer editable here. */}
<SockoptForm toggleSockopt={toggleSockopt} />
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
its stream tab is just sockopt + TProxy. */}
@@ -1,209 +0,0 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import './external-proxy.css';
const newEntry = () => ({
forceTls: 'same',
dest: '',
port: 443,
remark: '',
sni: '',
fingerprint: '',
alpn: [],
pinnedPeerCertSha256: [],
echConfigList: '',
});
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
return (
<div className="ext-proxy-field">
<span className="ext-proxy-flabel">{label}</span>
{children}
</div>
);
}
export default function ExternalProxyForm({
toggleExternalProxy,
}: {
toggleExternalProxy: (on: boolean) => void;
}) {
const { t } = useTranslation();
const form = Form.useFormInstance();
const generateRandomPin = (name: number) => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256'];
const current = (form.getFieldValue(path) as string[] | undefined) ?? [];
form.setFieldValue(path, [...current, hash]);
};
return (
<Form.Item
noStyle
shouldUpdate={(prev, curr) => {
const a = (prev.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
const b = (curr.streamSettings as { externalProxy?: unknown[] } | undefined)?.externalProxy;
return (Array.isArray(a) ? a.length : 0) !== (Array.isArray(b) ? b.length : 0);
}}
>
{({ getFieldValue }) => {
const arr = getFieldValue(['streamSettings', 'externalProxy']);
const on = Array.isArray(arr) && arr.length > 0;
return (
<>
<Form.Item label={t('pages.inbounds.form.externalProxy')}>
<Switch checked={on} onChange={toggleExternalProxy} />
</Form.Item>
{on && (
<Form.Item wrapperCol={{ span: 24 }}>
<Form.List name={['streamSettings', 'externalProxy']}>
{(fields, { add, remove }) => (
<>
<div className="ext-proxy-list">
{fields.map((field, idx) => (
<div key={field.key} className="ext-proxy-card">
<div className="ext-proxy-card__head">
<span className="ext-proxy-card__title">#{idx + 1}</span>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => remove(field.name)}
/>
</div>
<div className="ext-proxy-grid ext-proxy-grid--dest">
<Field label={t('pages.inbounds.form.forceTls')}>
<Form.Item name={[field.name, 'forceTls']} noStyle>
<Select
style={{ width: '100%' }}
options={[
{ value: 'same', label: t('pages.inbounds.same') },
{ value: 'none', label: t('none') },
{ value: 'tls', label: 'TLS' },
]}
/>
</Form.Item>
</Field>
<Field label={t('pages.inbounds.address')}>
<Form.Item name={[field.name, 'dest']} noStyle>
<Input placeholder={t('pages.inbounds.address')} />
</Form.Item>
</Field>
<Field label={t('pages.inbounds.port')}>
<Form.Item name={[field.name, 'port']} noStyle>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Field>
</div>
<Field label={t('pages.inbounds.remark')}>
<Form.Item name={[field.name, 'remark']} noStyle>
<Input placeholder={t('pages.inbounds.remark')} />
</Form.Item>
</Field>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
prev.streamSettings?.externalProxy?.[field.name]?.forceTls
!== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
}
>
{({ getFieldValue }) => {
const ft = getFieldValue([
'streamSettings', 'externalProxy', field.name, 'forceTls',
]);
if (ft !== 'tls') return null;
return (
<div className="ext-proxy-tls">
<div className="ext-proxy-grid ext-proxy-grid--tls">
<Field label="SNI">
<Form.Item name={[field.name, 'sni']} noStyle>
<Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
</Form.Item>
</Field>
<Field label={t('pages.inbounds.form.fingerprint')}>
<Form.Item name={[field.name, 'fingerprint']} noStyle>
<Select
style={{ width: '100%' }}
placeholder={t('pages.inbounds.form.fingerprint')}
options={[
{ value: '', label: t('pages.inbounds.form.defaultOption') },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({
value: fp,
label: fp,
})),
]}
/>
</Form.Item>
</Field>
<Field label="ALPN">
<Form.Item name={[field.name, 'alpn']} noStyle>
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="ALPN"
options={Object.values(ALPN_OPTION).map((a) => ({
value: a,
label: a,
}))}
/>
</Form.Item>
</Field>
</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')}>
<Space.Compact block>
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 32px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => generateRandomPin(field.name)}
title={t('pages.inbounds.form.generateRandomPin')}
/>
</Space.Compact>
</Field>
</div>
);
}}
</Form.Item>
</div>
))}
</div>
<Button
className="ext-proxy-add"
block
type="dashed"
icon={<PlusOutlined />}
onClick={() => add(newEntry())}
>
{t('add')}
</Button>
</>
)}
</Form.List>
</Form.Item>
)}
</>
);
}}
</Form.Item>
);
}
@@ -4,5 +4,4 @@ export { default as GrpcForm } from './grpc';
export { default as XhttpForm } from './xhttp';
export { default as HttpUpgradeForm } from './httpupgrade';
export { default as KcpForm } from './kcp';
export { default as ExternalProxyForm } from './external-proxy';
export { default as SockoptForm } from './sockopt';
@@ -1,19 +1,71 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
import {
Address_Port_Strategy,
DOMAIN_STRATEGY_OPTION,
TCP_CONGESTION_OPTION,
} from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { CustomSockoptList } from '@/components/form';
import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
// Transport key that carries its own acceptProxyProtocol field (mirrored
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
const TRANSPORT_PROXY_FIELD: Record<string, string> = {
tcp: 'tcpSettings',
ws: 'wsSettings',
httpupgrade: 'httpupgradeSettings',
};
// Transports on which xray-core honors sockopt.trustedXForwardedFor.
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp'];
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
export default function SockoptForm({
toggleSockopt,
network,
}: {
toggleSockopt: (on: boolean) => void;
network: string;
}) {
const { t } = useTranslation();
// Presets write the same sockopt fields the user could set by hand below,
// picking the mechanism xray-core actually honors for the chosen transport:
// CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp) or the
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
const applyRealClientIpPreset = (
preset: RealClientIpPreset,
getFieldValue: (name: (string | number)[]) => unknown,
setFieldValue: (name: (string | number)[], value: unknown) => void,
) => {
const sockopt = getFieldValue(['streamSettings', 'sockopt']);
const sockoptOn =
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
if (preset !== 'off' && !sockoptOn) {
toggleSockopt(true);
}
const transportField = TRANSPORT_PROXY_FIELD[network];
if (preset === 'off') {
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
return;
}
if (preset === 'cloudflare') {
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
const list = Array.isArray(current) ? [...(current as string[])] : [];
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
return;
}
// proxy — clear trustedXForwardedFor so a lingering header can't override the
// PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp).
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
};
return (
<Form.Item
noStyle
@@ -33,6 +85,89 @@ export default function SockoptForm({
</Form.Item>
{on && (
<>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => {
type ProxyWatch = {
streamSettings?: {
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
tcpSettings?: { acceptProxyProtocol?: unknown };
wsSettings?: { acceptProxyProtocol?: unknown };
httpupgradeSettings?: { acceptProxyProtocol?: unknown };
};
};
const pick = (v: ProxyWatch) => {
const s = v.streamSettings;
return JSON.stringify([
s?.sockopt?.trustedXForwardedFor,
s?.sockopt?.acceptProxyProtocol,
s?.tcpSettings?.acceptProxyProtocol,
s?.wsSettings?.acceptProxyProtocol,
s?.httpupgradeSettings?.acceptProxyProtocol,
]);
};
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
}}
>
{({ getFieldValue, setFieldValue }) => {
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
string,
unknown
>;
const transportField = TRANSPORT_PROXY_FIELD[network];
const transportPP = transportField
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
: false;
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
const trusted = Array.isArray(sockopt.trustedXForwardedFor)
? (sockopt.trustedXForwardedFor as string[])
: [];
const value: RealClientIpPreset = proxyOn
? 'proxy'
: trusted.length > 0
? 'cloudflare'
: 'off';
const trustedMismatch =
trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
const proxyMismatch = proxyOn && network === 'kcp';
return (
<>
<Form.Item
label={t('pages.inbounds.form.realClientIp')}
tooltip={t('pages.inbounds.form.realClientIpHint')}
>
<Segmented
value={value}
onChange={(v) =>
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
}
options={[
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
]}
/>
</Form.Item>
{trustedMismatch && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
/>
)}
{proxyMismatch && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
/>
)}
</>
);
}}
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
<InputNumber min={0} />
</Form.Item>
@@ -67,6 +202,7 @@ export default function SockoptForm({
<Form.Item
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
label={t('pages.inbounds.form.proxyProtocol')}
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
valuePropName="checked"
>
<Switch />
@@ -78,13 +214,6 @@ export default function SockoptForm({
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpMptcp']}
label={t('pages.inbounds.form.multipathTcp')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'penetrate']}
label={t('pages.inbounds.form.penetrate')}
@@ -99,15 +228,6 @@ export default function SockoptForm({
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
style={{ width: '50%' }}
options={Object.values(DOMAIN_STRATEGY_OPTION).map((d) => ({ value: d, label: d }))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')}
@@ -127,18 +247,10 @@ export default function SockoptForm({
]}
/>
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'dialerProxy']} label={t('pages.inbounds.form.dialerProxy')}>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'interface']}
label={t('pages.inbounds.info.interfaceName')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
label={t('pages.inbounds.form.trustedXForwardedFor')}
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
>
<Select
mode="tags"
@@ -152,115 +264,7 @@ export default function SockoptForm({
]}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'addressPortStrategy']}
label={t('pages.inbounds.form.addressPortStrategy')}
>
<Select
style={{ width: '50%' }}
options={Object.values(Address_Port_Strategy).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{({ getFieldValue, setFieldValue }) => {
const he = getFieldValue(['streamSettings', 'sockopt', 'happyEyeballs']);
const hasHe = he != null;
return (
<>
<Form.Item label="Happy Eyeballs">
<Switch
checked={hasHe}
onChange={(v) => {
setFieldValue(
['streamSettings', 'sockopt', 'happyEyeballs'],
v ? HappyEyeballsSchema.parse({}) : undefined,
);
}}
/>
</Form.Item>
{hasHe && (
<>
<Form.Item
name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
label={t('pages.inbounds.form.tryDelayMs')}
>
<InputNumber min={0} placeholder="0 disabled — 250 recommended" />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
label={t('pages.inbounds.form.prioritizeIPv6')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
label={t('pages.inbounds.form.interleave')}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item
name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
label={t('pages.inbounds.form.maxConcurrentTry')}
>
<InputNumber min={0} />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.List name={['streamSettings', 'sockopt', 'customSockopt']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.customSockopt')}>
<Button
type="dashed"
size="small"
onClick={() => add({ type: 'int', level: '6', opt: '', value: '' })}
>
+ {t('pages.inbounds.form.addCustomOption')}
</Button>
</Form.Item>
{fields.map((field) => (
<Space.Compact key={field.key} style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item name={[field.name, 'system']} noStyle>
<Select
placeholder="all"
allowClear
style={{ width: 100 }}
options={[
{ value: 'linux', label: 'linux' },
{ value: 'windows', label: 'windows' },
{ value: 'darwin', label: 'darwin' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'type']} noStyle>
<Select
style={{ width: 80 }}
options={[
{ value: 'int', label: 'int' },
{ value: 'str', label: 'str' },
]}
/>
</Form.Item>
<Form.Item name={[field.name, 'level']} noStyle>
<Input placeholder="level (6=TCP)" style={{ width: 100 }} />
</Form.Item>
<Form.Item name={[field.name, 'opt']} noStyle>
<Input placeholder="opt" style={{ width: 120 }} />
</Form.Item>
<Form.Item name={[field.name, 'value']} noStyle>
<Input placeholder="value" style={{ flex: 1 }} />
</Form.Item>
<Button danger onClick={() => remove(field.name)}>−</Button>
</Space.Compact>
))}
</>
)}
</Form.List>
<CustomSockoptList />
</>
)}
</>
@@ -31,7 +31,6 @@ export default function InboundInfoModal({
onClose,
dbInbound,
clientIndex = 0,
remarkModel = '-io',
expireDiff = 0,
trafficDiff = 0,
ipLimitEnable = false,
@@ -120,7 +119,6 @@ export default function InboundInfoModal({
genWireguardConfigs({
inbound: inboundForLinks,
remark: dbInbound.remark,
remarkModel: '-io',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
@@ -129,7 +127,6 @@ export default function InboundInfoModal({
genWireguardLinks({
inbound: inboundForLinks,
remark: dbInbound.remark,
remarkModel: '-io',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
@@ -140,7 +137,6 @@ export default function InboundInfoModal({
genAllLinks({
inbound: inboundForLinks,
remark: dbInbound.remark,
remarkModel,
client: (clientSet ?? {}) as Parameters<typeof genAllLinks>[0]['client'],
hostOverride: nodeAddress,
fallbackHostname,
@@ -189,7 +185,7 @@ export default function InboundInfoModal({
}
});
}
}, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]);
}, [open, dbInbound, clientIndex, nodeAddress, subSettings, ipLimitEnable, t]);
const isEnable = useMemo(() => {
if (clientSettings) return !!clientSettings.enable;
@@ -76,7 +76,6 @@ export interface InboundInfoModalProps {
onClose: () => void;
dbInbound: DBInboundLike | null;
clientIndex?: number;
remarkModel?: string;
expireDiff?: number;
trafficDiff?: number;
ipLimitEnable?: boolean;
@@ -36,6 +36,7 @@ export default function InboundList({
dbInbounds,
clientCount,
lastOnlineMap: _lastOnlineMap,
inboundSpeed,
expireDiff,
trafficDiff,
pageSize,
@@ -124,6 +125,7 @@ export default function InboundList({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
@@ -271,6 +273,7 @@ export default function InboundList({
hasActiveNode={hasActiveNode}
nodesById={nodesById}
clientCount={clientCount}
inboundSpeed={inboundSpeed}
trafficDiff={trafficDiff}
expireDiff={expireDiff}
onClose={() => setStatsRecord(null)}
@@ -0,0 +1,39 @@
import { Tag, Tooltip } from 'antd';
import { SizeFormatter } from '@/utils';
import type { InboundSpeedEntry } from './types';
// True when an inbound has live throughput worth showing.
export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedEntry {
return !!speed && (speed.up > 0 || speed.down > 0);
}
interface InboundSpeedTagProps {
speed: InboundSpeedEntry;
withTooltip?: boolean;
}
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
const tag = (
<Tag color="blue">
↑ {SizeFormatter.speedFormat(speed.up)}
{' / '}
↓ {SizeFormatter.speedFormat(speed.down)}
</Tag>
);
if (!withTooltip) return tag;
return (
<Tooltip
title={(
<div>
<div>↑ {SizeFormatter.speedFormat(speed.up)}</div>
<div>↓ {SizeFormatter.speedFormat(speed.down)}</div>
</div>
)}
>
{tag}
</Tooltip>
);
}
@@ -13,7 +13,8 @@ import {
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord } from './types';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry } from './types';
interface InboundStatsModalProps {
open: boolean;
@@ -21,6 +22,7 @@ interface InboundStatsModalProps {
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
trafficDiff: number;
expireDiff: number;
onClose: () => void;
@@ -32,6 +34,7 @@ export default function InboundStatsModal({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
trafficDiff,
expireDiff,
onClose,
@@ -109,6 +112,16 @@ export default function InboundStatsModal({
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</div>
{(() => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) return null;
return (
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.speed')}</span>
<InboundSpeedTag speed={speed} />
</div>
);
})()}
{clientCount[record.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
</div>
);
@@ -44,6 +44,11 @@ export interface ClientCountEntry {
online: string[];
}
export interface InboundSpeedEntry {
up: number;
down: number;
}
export type RowAction =
| 'edit'
| 'showInfo'
@@ -63,6 +68,7 @@ export interface InboundListProps {
clientCount: Record<number, ClientCountEntry>;
onlineClients: string[];
lastOnlineMap: Record<string, number>;
inboundSpeed: Record<number, InboundSpeedEntry>;
expireDiff: number;
trafficDiff: number;
pageSize: number;
@@ -9,6 +9,7 @@ import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { RowActionsCell } from './RowActions';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
readStreamHints,
networkLabel,
@@ -17,7 +18,7 @@ import {
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
interface UseInboundColumnsParams {
hasAnyRemark: boolean;
@@ -25,6 +26,7 @@ interface UseInboundColumnsParams {
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean;
expireDiff: number;
trafficDiff: number;
@@ -38,6 +40,7 @@ export function useInboundColumns({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
@@ -262,6 +265,19 @@ export function useInboundColumns({
</Popover>
),
},
{
title: t('pages.inbounds.speed'),
key: 'speed',
align: 'center',
width: 90,
render: (_, record) => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) {
return <Tag color='default'>—</Tag>;
}
return <InboundSpeedTag speed={speed} withTooltip />;
},
},
{
title: t('pages.inbounds.expireDate'),
key: 'expiryTime',
@@ -283,5 +299,5 @@ export function useInboundColumns({
);
return cols;
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}
@@ -26,7 +26,6 @@ interface QrCodeModalProps {
onClose: () => void;
dbInbound: (DbInboundLike & { remark?: string }) | null;
client?: ClientSetting | null;
remarkModel?: string;
nodeAddress?: string;
subSettings?: SubSettings;
}
@@ -43,7 +42,6 @@ export default function QrCodeModal({
onClose,
dbInbound,
client = null,
remarkModel = '-io',
nodeAddress = '',
subSettings,
}: QrCodeModalProps) {
@@ -67,7 +65,6 @@ export default function QrCodeModal({
genWireguardConfigs({
inbound,
remark: peerRemark,
remarkModel: '-io',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
@@ -76,7 +73,6 @@ export default function QrCodeModal({
genWireguardLinks({
inbound,
remark: peerRemark,
remarkModel: '-io',
hostOverride: nodeAddress,
fallbackHostname,
}).split('\r\n'),
@@ -87,7 +83,6 @@ export default function QrCodeModal({
genAllLinks({
inbound,
remark: dbInbound.remark || '',
remarkModel,
client: client ?? {},
hostOverride: nodeAddress,
fallbackHostname,
@@ -106,7 +101,7 @@ export default function QrCodeModal({
}
setSubLink(nextSub);
setSubJsonLink(nextSubJson);
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
}, [open, dbInbound, client, nodeAddress, subSettings]);
const qrItems = useMemo<QrItem[]>(() => {
const items: QrItem[] = [];
+43 -3
View File
@@ -12,6 +12,8 @@ import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
import type { InboundSpeedEntry } from './list/types';
export interface SubSettings {
enable: boolean;
subTitle: string;
@@ -26,6 +28,17 @@ export interface SubSettings {
type DBInboundInstance = InstanceType<typeof DBInbound>;
// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
// deltas accumulated over this window, so dividing by it yields bytes/sec.
const TRAFFIC_POLL_INTERVAL_S = 5;
interface TrafficDelta {
Tag: string;
Up: number;
Down: number;
IsInbound?: boolean;
}
interface ClientRollup {
clients: number;
active: string[];
@@ -148,7 +161,6 @@ export function useInbounds() {
const tgBotEnable = !!defaults.tgBotEnable;
const ipLimitEnable = !!defaults.ipLimitEnable;
const pageSize = defaults.pageSize ?? 0;
const remarkModel = defaults.remarkModel || '-io';
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
const subSettings: SubSettings = useMemo(() => ({
@@ -179,6 +191,8 @@ export function useInbounds() {
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
@@ -383,7 +397,13 @@ export function useInbounds() {
const applyTrafficEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
const p = payload as {
traffics?: TrafficDelta[];
onlineClients?: string[];
onlineByGuid?: Record<string, string[]>;
activeInbounds?: Record<string, string[]>;
lastOnlineMap?: Record<string, number>;
};
if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients);
@@ -397,6 +417,26 @@ export function useInbounds() {
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
// Full-replace each poll so idle inbounds (and an empty array after an
// Xray stat reset) clear their speed instead of showing a stale value.
if (Array.isArray(p.traffics)) {
const byTag = new Map<string, TrafficDelta>();
for (const tr of p.traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
const nextSpeed: Record<number, InboundSpeedEntry> = {};
for (const ib of dbInboundsRef.current) {
const delta = byTag.get(ib.tag);
if (!delta) continue;
nextSpeed[ib.id] = {
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
};
}
setInboundSpeed(nextSpeed);
}
rebuildClientCount();
},
[rebuildClientCount],
@@ -481,12 +521,12 @@ export function useInbounds() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
statsVersion,
totals,
expireDiff,
trafficDiff,
subSettings,
remarkModel,
datepicker,
tgBotEnable,
ipLimitEnable,
@@ -18,6 +18,7 @@ import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { antdRule } from '@/utils/zodForm';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
pinnedCertSha256: '',
inboundSyncMode: 'all',
inboundTags: [],
outboundTag: '',
};
}
@@ -75,6 +77,23 @@ export default function NodeFormModal({
const scheme = Form.useWatch('scheme', form) ?? 'https';
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
// when balancers exist they get a labeled group so it's clear the selection
// routes through a balancer. Empty falls back to the placeholder ("Direct
// connection") rather than a synthetic option, so it can't read as a second
// "direct" next to a real freedom outbound.
const outboundOptions = useMemo<
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
>(() => {
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
if (!outboundGroups?.balancers.length) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{ label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
];
}, [outboundGroups, t]);
useEffect(() => {
if (!open) return;
@@ -117,6 +136,7 @@ export default function NodeFormModal({
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
inboundSyncMode: values.inboundSyncMode,
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
outboundTag: values.outboundTag || '',
};
}
@@ -319,6 +339,7 @@ export default function NodeFormModal({
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</Form.Item>
@@ -332,6 +353,15 @@ export default function NodeFormModal({
/>
)}
{tlsVerifyMode === 'mtls' && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.mtlsFormHint')}
/>
)}
{tlsVerifyMode === 'pin' && (
<Form.Item
label={t('pages.nodes.pinnedCert')}
@@ -356,6 +386,20 @@ export default function NodeFormModal({
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.nodes.outboundTag')}
name="outboundTag"
extra={t('pages.nodes.outboundTagHint')}
getValueProps={(v) => ({ value: (v as string) || undefined })}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={outboundOptions}
/>
</Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
+51 -3
View File
@@ -31,6 +31,10 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
const [cpuLabels, setCpuLabels] = useState<string[]>([]);
const [memPoints, setMemPoints] = useState<number[]>([]);
const [memLabels, setMemLabels] = useState<string[]>([]);
const [netUpPoints, setNetUpPoints] = useState<number[]>([]);
const [netUpLabels, setNetUpLabels] = useState<string[]>([]);
const [netDownPoints, setNetDownPoints] = useState<number[]>([]);
const [netDownLabels, setNetDownLabels] = useState<string[]>([]);
const lastNodeId = useRef<number>(node.id);
@@ -46,7 +50,9 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
return `${hh}:${mm}:${ss}`;
};
const fetchSeries = async (metric: 'cpu' | 'mem') => {
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
// as KB/s (no upper clamp, the sparkline auto-scales).
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
try {
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
@@ -55,7 +61,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
const labs: string[] = [];
for (const p of msg.obj) {
labs.push(bucketLabel(p.t));
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
const n = Number(p.v) || 0;
vals.push(kind === 'pct' ? Math.max(0, Math.min(100, n)) : Math.max(0, n / 1024));
}
return { vals, labs };
}
@@ -66,12 +73,21 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
};
const refresh = async () => {
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
const [cpu, mem, netUp, netDown] = await Promise.all([
fetchSeries('cpu', 'pct'),
fetchSeries('mem', 'pct'),
fetchSeries('netUp', 'rate'),
fetchSeries('netDown', 'rate'),
]);
if (cancelled) return;
setCpuPoints(cpu.vals);
setCpuLabels(cpu.labs);
setMemPoints(mem.vals);
setMemLabels(mem.labs);
setNetUpPoints(netUp.vals);
setNetUpLabels(netUp.labs);
setNetDownPoints(netDown.vals);
setNetDownLabels(netDown.labs);
};
refresh();
@@ -118,6 +134,38 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
showTooltip
/>
</div>
<div className="series">
<div className="series-title">{t('pages.nodes.netUp')}</div>
<Sparkline
data={netUpPoints}
labels={netUpLabels}
height={120}
stroke="#1677ff"
showGrid
showAxes
tickCountX={4}
maxPoints={netUpPoints.length || 1}
fillOpacity={0.18}
markerRadius={2.6}
showTooltip
/>
</div>
<div className="series">
<div className="series-title">{t('pages.nodes.netDown')}</div>
<Sparkline
data={netDownPoints}
labels={netDownLabels}
height={120}
stroke="#fa8c16"
showGrid
showAxes
tickCountX={4}
maxPoints={netDownPoints.length || 1}
fillOpacity={0.18}
markerRadius={2.6}
showTooltip
/>
</div>
</div>
);
}
+10 -4
View File
@@ -27,6 +27,7 @@ import {
MoreOutlined,
PlusOutlined,
RightOutlined,
SafetyCertificateOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
@@ -43,6 +44,7 @@ interface NodeListProps {
selectedIds: number[];
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onMtls: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
@@ -163,6 +165,7 @@ export default function NodeList({
selectedIds,
onSelectionChange,
onAdd,
onMtls,
onEdit,
onDelete,
onProbe,
@@ -241,18 +244,18 @@ export default function NodeList({
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -417,6 +420,9 @@ export default function NodeList({
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{t('pages.nodes.addNode')}
</Button>
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
{t('pages.nodes.mtls.title')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}

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