mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-13 08:06:06 +00:00
201d4731de2c3477d74f1554c7adf787e89ace5d
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ce8b1bed77 |
feat(iplimit): gate IP limit on fail2ban and reset stale limits
Per-client IP limit only enforces where fail2ban is installed, so the panel now reports enforceability and disables the field otherwise: - Add GET /panel/api/server/fail2banStatus (enabled/installed/usable/windows), cached 30s. - ClientFormModal and ClientBulkAddModal disable the IP Limit input when not usable and show a hover tooltip; Windows gets a platform-specific message instead of the bash-menu hint. - One-time migration ResetIpLimitNoFail2ban zeroes existing client limitIp (inbound settings JSON + clients table) on hosts without fail2ban, where the limit never applied. - Drop the recurring '[LimitIP] Fail2Ban is not installed' warning. - Add limitIpFail2banMissing/limitIpFail2banWindows/limitIpDisabled across all 13 locales. |
||
|
|
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
|
||
|
|
e6c1ce9aa9 |
feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005)
* feat(nodes): add stable panel GUID identity (multi-hop phase 0) Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983). Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on. Refs #4983 * feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1) Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched. The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id. Refs #4983 * feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2) Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983). xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly. Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983 Refs #4983 * feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a) Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983). The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next. Refs #4983 * feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b) The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983). Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983 Refs #4983 * i18n(nodes): translate subNode/subNodeTip across all locales Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation. Refs #4983 |
||
|
|
ef8882a5c0 |
fix(online): scope per-inbound online to inbounds that carried traffic
Multi-inbound clients showed online on every inbound they were attached to. Xray's user-level traffic stat aggregates across all inbounds a client belongs to, so the email signal alone can't say which inbound was used. Pair it with the inbound-level traffic signal under the same 20s grace and gate the per-inbound rollup on it: a client only shows online on inbounds that actually moved bytes this window. Remote nodes report no per-inbound activity and stay ungated (no regression). Adds GetActiveInboundsByNode, the activeInbounds WS field and POST /panel/api/clients/activeInbounds. Fixes #4859 |
||
|
|
3af2da0142 |
fix(online): scope online status per node instead of a global union
The inbounds page and Nodes page checked each client's email against a single deduped union of every node's online clients, so a client connected to one node showed as online on every inbound across every node. The local online set was also derived from the email-keyed client_traffics.last_online column, which remote-node syncs bump too, leaking remote-only clients onto local inbounds. Track online clients per node: the local panel's own xray clients under key 0 (derived from live traffic-poll deltas via RefreshLocalOnline, kept in memory and independent of the shared last_online column) and each remote node under its id. Add GetOnlineClientsByNode plus a /clients/onlinesByNode endpoint and onlineByNode WS field; node.go and the inbounds rollup now scope online by node. The flat GetOnlineClients union is kept for client-centric and total-count views (Clients page, dashboard, telegram). Closes #4809 |
||
|
|
ffe661d212 |
fix(groups): fetch full client list for Add/Remove/SubLinks modals
GroupsPage was sourcing modal candidates from useClients(), which is server-paginated at 25 rows — so "Add clients to group" only ever offered the first page, "Remove" missed members past page 1, and SubLinks silently skipped emails whose record wasn't in the cached page. Pull the unpaginated list via /panel/api/clients/list when any of the three modals open. |
||
|
|
93eda06878 |
feat(clients,groups): client groups + sub-links export + dedicated groups page
Persistent client groups
- New ClientGroup model + client_groups table that holds empty
(placeholder) groups so a user can define a label before any client
references it. ListGroups merges these with the distinct group_name
values already stored on clients and reports {name, clientCount}.
- ClientRecord gains group_name column; the model.Client wire shape
gains a matching `group` JSON field that survives the
inbound.settings → SyncInbound round-trip.
- Rename/Delete on a group mutates client_groups (rename row / delete
row) AND propagates to all matching clients in ClientRecord and in
every owning inbound's settings JSON, all in one transaction.
Bulk operations
- AssignGroup(emails, group) updates clients.group_name + patches each
affected inbound's settings JSON in one read-modify-write per inbound.
Empty group clears the label. Auto-creates the client_groups row when
the user assigns to a brand-new name.
- BulkResetTraffic(emails) loops the existing single-reset path so the
caller can zero traffic across a whole selection or a whole group.
- EmailsByGroup(name) returns just the email list (used by the groups
page to fan a single bulk action over every member).
Endpoints (all under /panel/api/clients)
- GET /groups — summaries with counts
- GET /groups/:name/emails — emails in a group
- POST /groups/create — empty placeholder group
- POST /groups/rename — rename (table + clients + JSON)
- POST /groups/delete — drop label everywhere (clients survive)
- POST /bulkAssignGroup — assign N selected clients
- POST /bulkResetTraffic — reset traffic on a list
Clients page UX
- New Group column (Actions → Client → Group → Inbounds → …) with a
click-to-filter chip.
- FilterDrawer gains a multi-select Group filter whose options come
from the new ClientPageResponse.groups field (sourced from ListGroups
so empty/placeholder groups are pickable too).
- Single-client and bulk-add forms gain a Group AutoComplete pre-loaded
with all known group names.
- New toolbar buttons when selection > 0: "Group ({n})" opens
BulkAssignGroupModal, "Sub links ({n})" opens SubLinksModal.
Sub-links export modal (new SubLinksModal.tsx)
- Table of selected clients with their subscription URL (and JSON URL
when subJsonEnable is on), per-row copy, Copy all, and Download as
sub-links-<timestamp>.txt. Warns when subscription is disabled or
none of the selected clients have a subId.
Dedicated Groups page (new pages/groups/GroupsPage.tsx)
- /groups route + sidebar entry (TagsOutlined icon) + page title key.
- Card-based layout matching Clients/Inbounds/Nodes — summary card with
Total/Grouped/Empty stats, main card with Add Group button + table.
- Per-row More dropdown (icon-first column on the left): Sub links,
Adjust (days+traffic), Reset traffic, Rename, Delete clients in
group, Delete group (keep clients). Empty groups disable the
client-targeted actions.
- Reuses SubLinksModal and ClientBulkAdjustModal — emails for the
group are fetched on demand from GET /groups/:name/emails.
Other polish
- /groups + groups-page selectors added to page-shell.css and
page-cards.css so the new page inherits the same background, padding,
card borders, hover shadow, and summary-card padding.
- .card-toolbar gains a small vertical padding so the larger toolbar
buttons (now default size, matching Inbounds) don't crowd the top of
the card-head on Clients and Groups pages.
|
||
|
|
cfe1b25ca0 |
feat(frontend): TanStack Query + React Router migration & in-panel API docs (#4541)
* feat(frontend): introduce TanStack Query with status polling
Wires @tanstack/react-query into every entry and migrates useStatus to
useStatusQuery as the foundation for the multi-page MPA → SPA migration.
- QueryProvider wraps each entry inside ThemeProvider, with devtools gated
on import.meta.env.DEV
- Shared queryClient: 30s staleTime, refetchOnWindowFocus, 1 retry
- useStatusQuery preserves the { status, fetched, refresh } shape so
IndexPage swaps in without further changes
- refetchIntervalInBackground:false stops the 2s status poll when the
panel tab is hidden, cutting idle traffic against the server
* feat(frontend): collapse panel pages into a single React Router SPA
Replaces the 7-entry MPA shell (index/clients/inbounds/nodes/settings/
xray/api-docs HTML files) with one main.tsx + createBrowserRouter. The
Go backend now serves the same index.html for every authenticated
panel route; React Router reads the URL and mounts the page from cache
on subsequent navigation — no more full reloads between tabs.
Frontend
- main.tsx: single bootstrap (setupAxios, i18n, ThemeProvider,
QueryProvider, RouterProvider) replacing 7 near-duplicate entries
- routes.tsx: declarative router with lazy()-loaded pages, basename
derived from window.X_UI_BASE_PATH so panels at /secret/panel work
- layouts/PanelLayout.tsx: shell mount-point for the WS → queryClient
bridge so connection survives navigation
- api/websocketBridge.ts: subscribes the singleton WebSocketClient to
queryClient and dispatches invalidate/outbounds events to cached
queries (page-level useWebSocket handlers stay until Phase 3 hooks
migrate)
- AppSidebar: navigates via useNavigate + useLocation instead of
window.location.href; drops basePath/requestUri props
- Pages: drop the unused basePath/requestUri locals exposed only for
the old sidebar
Build
- vite.config: 9 rollup inputs → 3 (index, login, subpage). Dev proxy
bypass collapses /panel/* to index.html and skips API prefixes
- vendor-tanstack + vendor-router chunks added to manualChunks
Backend
- xui.go: 7 per-page HTML handlers → one panelSPA handler serving
index.html for /, /inbounds, /clients, /nodes, /settings, /xray,
/api-docs. The /panel/api, /panel/setting, /panel/xray sub-routers
are untouched
* feat(frontend): migrate useNodes to TanStack Query
Splits the hand-rolled useNodes hook into useNodesQuery (server data +
NodeRecord type + derived totals) and useNodeMutations (add/update/del/
setEnable/probe/test). Mutations invalidate ['nodes'] on success, so
the list refreshes without each call awaiting a manual refresh().
NodesPage drops useWebSocket({ nodes: applyNodesEvent }) — the
WebSocket → query bridge now forwards the 'nodes' push to
setQueryData(['nodes', 'list']) once at the SPA root.
InboundsPage and the inbound form/list components import NodeRecord
from its new home next to the query hook.
* feat(frontend): migrate useAllSetting to TanStack Query
Replaces the hand-rolled fetch + dirty-tracking hook with useAllSettings
backed by useQuery + useMutation. The draft (current edits) is kept in
local state and reset whenever query.data lands. saveAll posts the
draft via a mutation; on success, invalidating ['settings'] refetches
and the useEffect resets the draft so saveDisabled flips back to true.
staleTime: Infinity prevents refetchOnWindowFocus from clobbering
in-flight edits — settings only change in response to this user's own
save.
setSpinning stays as a pass-through to a local flag so the existing
restartPanel flow in SettingsPage keeps showing its spinner.
* feat(frontend): route useInbounds fetches through TanStack Query
Rewrites useInbounds so its four server fetches (slim list, default
settings, online clients, last-online map) live in useQuery with
staleTime: Infinity. The in-place WS merge logic for traffic and
client_stats is preserved — applyTrafficEvent / applyClientStatsEvent
still mutate the locally-mirrored dbInbounds so the panel doesn't
refetch every 1-2 seconds when stats stream in.
refresh() becomes a thin invalidateQueries on the three list keys,
which mutations in the page already call after add/edit/del.
The bridge now forwards the WebSocket 'inbounds' push to
setQueryData(['inbounds', 'slim']), and InboundsPage drops its
useEffect(fetchDefaultSettings → refresh) plus the invalidate /
inbounds wiring on useWebSocket — both are owned by the bridge now.
* feat(frontend): migrate useClients to TanStack Query
Replaces 12 hand-rolled mutation callbacks and a tangle of useState +
useRef + useEffect with one useQuery (paged list) + nine useMutation
wrappers. The list query uses keepPreviousData so paging/filter
changes don't blank the table mid-fetch.
The setQuery shallow-compare logic is preserved for backward
compatibility with ClientsPage's effect that rebuilds the params on
every render. Internally setQuery only updates state when the params
actually differ — Query's queryKey equality handles the rest.
WS-driven applyTrafficEvent / applyClientStatsEvent now mutate the
query cache via setQueryData(['clients', 'list', currentParams]) so
per-second stats updates skip a full refetch. applyInvalidate is gone
from the hook — the bridge owns coarse 'clients' invalidation.
ClientsPage drops the invalidate handler from its useWebSocket
subscription; auxiliary queries (inboundOptions, defaults, onlines)
load via TanStack Query and are shared with useInbounds via the same
query keys.
* feat(frontend): route useXraySetting fetches through TanStack Query
Keeps the bidirectional xraySetting ↔ templateSettings editor sync and
the 1s dirty-tracking interval intact (those are local editor state,
not server data). All seven server calls move:
- config + traffic → useQuery on ['xray', 'config'] and
['xray', 'outboundsTraffic']
- saveAll → useMutation that invalidates the config query
- resetOutboundsTraffic → useMutation that invalidates the traffic
query
- restartXray → useMutation (fires the restart, then reads the
result string)
- resetToDefault → useMutation (fetch default config, push it into
the editor via setTemplateSettings)
The WebSocket 'outbounds' event already lands in
keys.xray.outboundsTraffic() via the bridge, so XrayPage drops its
useWebSocket({ outbounds: applyOutboundsEvent }) wiring entirely and
the hook no longer exposes applyOutboundsEvent.
A useEffect seeds xraySetting / templateSettings / tags / test URL
from query data on first fetch and on every refetch, mirroring what
the original fetchAll() did.
* fix(frontend): restore per-route document titles in the SPA
When the multi-entry MPA collapsed into a single index.html, every
route inherited the static <title>3X-UI</title> from the shared shell,
so every panel page showed "hostname - 3X-UI" instead of the original
"hostname - Overview / Clients / Inbounds / ...".
usePageTitle reads the current pathname and rewrites document.title
on every navigation, matching the titles the deleted *.html files
used to carry. Mounted in PanelLayout so it covers all panel routes
without each page having to opt in.
The startup applyDocumentTitle() call in main.tsx is gone — the hook
sets the full "hostname - PageTitle" string itself.
* feat(api-docs): expose OpenAPI spec + render Swagger UI in panel
Replaces the hand-rolled API docs UI with industry-standard tooling so
external integrations (Postman, Insomnia, openapi-generator) can
consume the panel API without parsing endpoints.js by hand.
Generator
- frontend/scripts/build-openapi.mjs: walks the existing endpoints.js
(still the single source of truth) and emits an OpenAPI 3.0.3 spec
at frontend/public/openapi.json. Handles Gin :param → {param} path
translation, body / query / path parameter splits, 200 + error
response examples, and Bearer + cookie security schemes
- npm run build now runs gen:api before vite build, so the spec is
always in sync with what's documented
Backend
- web/controller/dist.go exposes ServeOpenAPISpec which streams the
embedded dist/openapi.json with a short Cache-Control. Public
endpoint (no auth) so Postman can fetch it without first logging in
- web/web.go wires GET /panel/api/openapi.json before the auth-gated
/panel/api router
Panel
- ApiDocsPage now renders swagger-ui-react fed by the basePath-aware
openapi.json URL. Dark mode is overridden via CSS targeting the
Swagger UI internals
- CodeBlock / EndpointRow / EndpointSection are gone; the swagger-ui
vendor chunk (134 KB gzipped) only loads on this lazy route, not on
every panel page
- vite.config: vendor-swagger manualChunk keeps the new dep out of
the main vendor bundle
For Postman: import http://<panel>/panel/api/openapi.json. Everything
from /login + /panel/api/* shows up with auth, params, and examples.
* style(api-docs): dark/ultra theme for Swagger UI
Override every visual surface Swagger does not theme on its own:
opblocks, tables, model boxes, form inputs, code blocks, modals,
Servers dropdown, per-endpoint padlocks and expand chevrons. Replaces
Swagger's default light-arrow chevron on selects with a light-fill SVG
positioned at the corner so the dark background-color is visible.
Also disables deepLinking to silence the noisy v4 underscore warning;
not used in our panel.
|