Compare commits

..

50 Commits

Author SHA1 Message Date
MHSanaei b5ef412b8d v3.3.1 2026-06-12 20:39:13 +02:00
MHSanaei 41cb0b8ae7 fix(inbounds): show remark first, else inbound tag, in client labels
Revert formatInboundLabel to the pre-#5151 behavior: display the inbound
remark when set, otherwise the inbound tag, instead of "tag (remark)".
Affects the Attach clients / Attached inbounds views and client lists.
Routing keeps its own tag (remark) formatting.
2026-06-12 20:37:37 +02:00
MHSanaei cd46730bb9 Bump Go indirect deps; update frontend lock
Bump several Go indirect dependencies (golang.org/x/exp, golang.org/x/tools, google.golang.org/genproto) and update go.sum accordingly. Regenerate frontend/package-lock.json to record updated npm package versions (including Ant Design and related rc-component packages and other transitive updates).
2026-06-12 20:16:06 +02:00
MHSanaei 4eab37b66c feat(clients): restore reset traffic button in edit client form 2026-06-12 20:15:31 +02:00
MHSanaei 08bc481ae3 refactor(settings): reorganize subscription settings into clearer tabs
- Split the Happ and Clash/Mihomo routing sections out of Information into
  their own dedicated tabs.
- Extract the profile/branding fields (title, support URL, profile page,
  announcement, theme dir) out of the mislabeled "Subscription Title"
  divider into a new Profile tab.
- Move the Update Interval setting into Information and drop the
  single-field Intervals tab.
- Add the "profile" tab label across all locales.
2026-06-12 19:41:02 +02:00
MHSanaei 0f7da02a07 style(inbounds): show total up/down with directional arrows
Replace the ambiguous swap icon on the total traffic statistic with
explicit up/down arrows next to each value.
2026-06-12 19:19:42 +02:00
MHSanaei 0c73862bbe fix(clients): invalidate Xray config cache after client mutations
Client add/update/remove also rewrite settings.clients on each attached
inbound, so the Xray config query could go stale. Invalidate it alongside
the clients and inbounds buckets.
2026-06-12 19:19:42 +02:00
MHSanaei c7a0188772 feat(settings): schedule picker, toggle placement, sub-theme docs link
- Replace the Telegram "Notification Time" free-text field with a guided
  cron builder: @every + number + unit (s/m/h), the @hourly/@daily/@weekly/
  @monthly macros, and a Custom option that seeds a valid 6-field crontab
  (cron runs with seconds enabled) as an escape hatch.
- Move "Restart Xray After Auto Disable" from the External Traffic tab to
  Panel Settings, where it belongs.
- Add a "Template guide" link to the Sub Theme Directory setting pointing at
  docs/custom-subscription-templates.md.
- Localize all new strings across every locale.
2026-06-12 19:19:31 +02:00
iYuan 90e6217749 fix(inbound): preserve custom share strategy on edit (#5225) 2026-06-12 18:38:01 +02:00
MHSanaei 6e20588236 style(ui): enlarge row action icons and rebalance clients table widths
Bump row action icons to 18px across the clients, inbounds, groups and
nodes tables for better visibility.

In the clients table, cap the Client column at 220px and give Duration a
fixed width so the Traffic column becomes the flexible one that absorbs
the remaining horizontal space instead of Client growing oversized.
2026-06-12 18:25:27 +02:00
MHSanaei 5eec178483 feat(mtproto): route Telegram egress through Xray routing rules
Add a per-inbound "Route through Xray" toggle (off by default) plus an
optional outbound picker on MTProto inbounds. mtg only supports a SOCKS5
upstream, so when enabled the panel injects a loopback SOCKS bridge into
the generated Xray config — tagged with the inbound's own tag — and mtg
dials Telegram through it via a [network] proxies upstream. The router
then governs Telegram egress: matchable in the Routing tab, or forced to a
chosen outbound/balancer via the picker.

- mtproto: Instance carries RouteThroughXray + XrayRoutePort (in the
  fingerprint); InstanceFromInbound parses them; renderConfig emits the
  socks5 [network] upstream; freeLocalPort exported as FreeLocalPort.
- xray.go: injectMtprotoEgress appends the loopback SOCKS bridge and
  prepends an optional inboundTag->outbound/balancer rule, hot-appliable
  like injectPanelEgress.
- inbound.go: backend-owned egress port persisted in settings, allocated
  once and carried across edits (stored value wins); stripped with the
  inert outboundTag when routing is off; allocation failure fails the save;
  routed add/update/del force a config regen.
- mtproto_job: skip folding mtg metrics for routed inbounds (the bridge,
  carrying the inbound tag, is metered by xray_traffic_job) to avoid
  double-counting.
- frontend: toggle + outbound/balancer Select (useOutboundTags) on the
  MTProto form; i18n keys for all locales.
2026-06-12 17:58:45 +02:00
MHSanaei 5716ae5987 feat(outbound): batched connection tester with direct timed HTTP probes
Replace the per-outbound burstObservatory polling (one temp xray spawn +
up to 15s of /debug/vars polling per outbound, serialised) with one
shared temp xray instance per batch: every tested outbound gets its own
loopback SOCKS inbound plus an inboundTag->outboundTag routing rule, and
the panel times a real HTTP request through each one in parallel. The
probe returns as soon as the response lands and records the HTTP status
plus an httptrace breakdown (proxy connect / TLS via outbound / first
byte) shown in the result popover.

New POST /panel/api/xray/testOutbounds endpoint (array in, results in
input order, max 50); the legacy /testOutbound endpoint now delegates to
the same engine. Test All chunks HTTP probes 16 per request, and a batch
whose shared process never comes up (one structurally-broken outbound
poisons the config) retries each item in an isolated instance so the
broken outbound reports xray's real error while the rest still test.
2026-06-12 16:55:53 +02:00
MHSanaei 85983eec1a refactor(groups): restyle traffic summary into upload/download + usage cards
Split the group traffic summary into two inbound-style cards: a "Total
upload / download" card with up/down arrow icons and a "Total Usage" card
with the pie icon. Add the totalUpDown label across all locales.
2026-06-12 16:22:30 +02:00
MHSanaei 5af02265ec fix(inbound): remove stale mkcp-legacy finalmask when switching away from mKCP
Switching the transport to mKCP auto-seeds a mkcp-legacy entry into
finalmask.udp, but switching back to another transport only dropped the
kcpSettings blob and left the mask behind. It survived downstream pruning
(finalmask.udp was non-empty) and bled into every client share link.

Strip auto-seeded mkcp-legacy entries from finalmask.udp whenever the
network changes away from kcp, leaving user-authored masks intact.

Fixes #5221
2026-06-12 15:35:41 +02:00
MHSanaei 1c5cb84492 feat(groups): show upload/download breakdown in group traffic
Add per-group up/down to GroupSummary (backend + schema), surface them
as Upload/Download columns in the groups table, and fold upload/download
into the Total traffic summary card. Rename the group "Clients in group"
column to just "Clients" across all locales.
2026-06-12 15:30:41 +02:00
MHSanaei 7c698c4bcf feat(inbound): support abstract unix sockets (@ prefix) in Address field
Accept the @-prefixed abstract socket form (e.g. @xray/in.sock) for an
inbound listen address, not just path-based sockets. The form now allows
Port 0 for both, and the Address help text documents the @ form across
all locales. The backend already treated both prefixes as unix sockets.
@
2026-06-12 14:34:02 +02:00
MHSanaei 80e168787e fix(xray): confine log.access/error to the panel log folder
An authenticated admin could set xrayTemplateConfig.log.access/error to an
arbitrary path (via the raw Xray editor or a wholesale DB import), making the
supervised Xray process write its log there — an arbitrary file write as the
Xray user (root in many deployments). resolveXrayLogPaths now reduces any log
path to its base filename under config.GetLogFolder(), so absolute paths and
".." traversal can no longer escape the log folder; "" and "none" still
disable logging.
2026-06-12 14:25:06 +02:00
MHSanaei 3af1afc53b fix(inbound): avoid UNIQUE email constraint when importing inbounds that share clients
Importing a second inbound whose clients overlap an already-imported inbound
failed with "UNIQUE constraint failed: client_traffics.email". The import path
carries exported ClientStats, and tx.Save(inbound) cascaded that has-many
association as INSERTs whose ON CONFLICT targets only the primary key, so a
shared email (already owning a row from the first import) tripped the global
unique constraint.

Omit the ClientStats association on save and insert the carried stats ourselves
with the same OnConflict{email, DoNothing} guard AddClientStat already uses:
new clients keep their imported counters, shared emails reuse the existing row.
Then run an idempotent AddClientStat pass over all clients so any client present
in settings but missing from the stats payload still gets a traffic row (else it
would escape quota/expiry accounting), and propagate insert errors so the tx
rolls back instead of committing a partial state.
2026-06-12 13:00:04 +02:00
MHSanaei 0cefadd166 feat(ui): use CodeMirror editor for Import Inbound and Inbound JSON 2026-06-12 12:38:18 +02:00
Rouzbeh† 0766e16684 feat: implement inbound XMUX form fields (#5211)
* feat: implement inbound XMUX form fields

* fix: replace any cast to satisfy eslint

* test: update xhttp form snapshot for XMUX

* fix(inbound): persist xmux on save so the XMUX form actually round-trips

The inbound wire normalizer unconditionally deleted xhttpSettings.xmux,
so the new inbound XMUX form was stripped on save and never reached the
stored config — the subscription extra blob (buildXhttpExtra) could
never see it. Gate the deletion on the enableXmux toggle, mirroring the
outbound adapter, and add regression tests for both on/off cases.

* fix(xmux): enforce xray-core's maxConnections/maxConcurrency exclusivity

xray-core's XmuxConfig rejects a config that sets both maxConnections
and maxConcurrency. The panel pre-fills maxConcurrency ('16-32') whenever
XMUX is enabled, so an explicit maxConnections would always collide and
make xray refuse the config. Mirror core's semantics in the wire
normalizer: when maxConnections is set (>0, an explicit opt-in since it
defaults to 0), drop the leftover default maxConcurrency. Applies to both
inbound and outbound xhttp.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-12 12:31:13 +02:00
ssrlive 63a6d40457 Update ExecReload command in x-ui.service.debian (#5219)
* Update ExecReload command in x-ui.service.debian

* fix(systemd): use absolute path in ExecReload for arch and rhel units

systemd requires absolute executable paths; kill -USR1 $MAINPID fails
with 'Executable path is not absolute'. Matches the debian unit fix.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-12 12:09:48 +02:00
MHSanaei f1a4286e2f feat(sub): per-inbound sort order for subscription links
Add a subSortIndex field to inbounds that controls the order of links
in subscription output only: the raw sub body, the HTML sub page, and
the JSON/Clash formats (all served from the same query). Lower values
come first; ties keep id order. The panel inbound list is unaffected.

The value is editable in the inbound form next to the share-address
fields, propagates to nodes via wireInbound, and follows the usual
node-sync rules (copied on import, mirrored while not dirty, never a
structural change).

Rescoped from #5214 by @Ponywka.
2026-06-12 12:03:22 +02:00
MHSanaei 7ae3ea66d1 feat(ui): improve client form modal UX
- Rename tabs: "Basic" → "Basics", "Config" → "Credentials"
- Move reverseTag field from Credentials tab to Basics tab
- Move IP log button inline with limitIp field (tooltip button, edit mode only)
- Hide random email button when editing an existing client
- Add tooltips to totalGB and limitIp fields with descriptive hints
- Rename labels: "Total Sent/Received (GB)" → "Traffic Limit (GB)", "Duration" → "Duration (days)"
- Add renewDays translation key for auto-renew label with unit hint
- Remove redundant filterOption and width style from AutoComplete group selectors
- Update all 15 locale files with new and renamed translation keys
2026-06-12 10:38:26 +02:00
MHSanaei 253063b785 feat: filter inbounds and clients by node (#4997)
Multi-node panels had no way to narrow the inbounds or clients lists to
a single node. Add a node filter to both pages:

- Inbounds: a toolbar select (All / Local / each node) that filters the
  list client-side; shown only when the panel has nodes or node-attached
  inbounds.
- Clients: a Nodes multi-select in the filter drawer. Node selections
  are mapped onto inbound IDs client-side and fed through the existing
  inbound CSV paging parameter, so the paging backend is untouched; an
  impossible id (-1) is sent when no inbound matches so the filter
  yields an honest empty result. InboundOption now carries nodeId to
  make the mapping possible.

The local panel is selectable via a 0 sentinel (inbounds without a
nodeId). New i18n keys in all 13 locales.
2026-06-12 09:33:35 +02:00
MHSanaei d04cb10971 feat(wireguard): per-peer comments for identifying devices (#5168)
WG peers were only identifiable by their keys. Add an optional panel-side
comment per peer: editable in the inbound form (echoed next to "Peer N"
in the section header), stored in the settings JSON alongside the
panel-only privateKey (xray-core ignores unknown peer fields), and
appended to the share link / .conf remark so the device is identifiable
in client apps too.
2026-06-12 09:10:57 +02:00
MHSanaei d1a13844b2 feat(api): include consumed traffic in the client-get response (#4973)
GET /panel/api/clients/get/:email returned the quota (totalGB) but not
the bytes the client has actually used, forcing API consumers to scrape
it elsewhere. Add a sibling "usedTraffic" field (up+down, including the
cross-node global overlay) next to "client" and "inboundIds".
2026-06-12 09:06:35 +02:00
MHSanaei bade1fcef6 feat(ui): allow custom fragment packets ranges, not just presets (#5075)
The fragment "packets" field was a locked dropdown (tlshello / 1-3 / 1-5)
in both the finalmask TCP-mask form and the Freedom outbound form, while
xray-core accepts any "n-m" packet range. Replace both with an
AutoComplete that keeps the presets as suggestions and validates free
input as "tlshello" or a numeric range.
2026-06-12 09:04:17 +02:00
MHSanaei 0e0e41197f fix(settings): normalize tgCpu on load so a bad value can't block saving (#5091)
The settings page validates the whole AllSetting object before saving, so a
tgCpu value that isn't an integer in 0-100 (left over from an older or corrupt
setting) failed validation with "tgCpu: Invalid input" and blocked saving every
other setting too. Clamp/round tgCpu to a valid integer in the model
constructor, defaulting to 80 when it isn't a finite number.
2026-06-12 03:17:32 +02:00
MHSanaei 5c29851be1 fix(nodes): "Invalid input" when saving a node with inbound sync mode "all"
NodeFormSchema required inboundTags, but the inboundTags Form.Item is only
mounted when inboundSyncMode is "selected" - antd onFinish omits unmounted
fields, so saving with the default "all" mode failed schema validation with
Zod generic "Invalid input" (regression from #5178; same class as the
earlier pinnedCertSha256 fix).

Also tolerate null inboundTags (Go nil slice) for nodes saved before #5178,
both in the form schema and NodeRecordSchema, and normalize edit-mode values.
2026-06-12 02:29:46 +02:00
MHSanaei 60da6bed15 fix(xhttp): stop injecting scMaxEachPostBytes/scMinPostsIntervalMs defaults (#5141)
The panel seeded xhttp configs with scMaxEachPostBytes=1000000 and
scMinPostsIntervalMs=30 — xray-core''s own defaults — and emitted them
into every generated config and share link. The literal
scMinPostsIntervalMs=30 is a stable DPI fingerprint that Russia''s TSPU
keys on to block connections on mobile networks.

New configs no longer seed these values (empty schema/template defaults,
so xray-core applies its internal defaults). For configs already stored
with the old defaults, the link/subscription builders now drop values
equal to xray-core''s defaults instead of advertising them — covering
panel share links, the raw subscription, and the JSON subscription
without requiring every inbound to be re-saved. Non-default values the
user set deliberately are still emitted.
2026-06-12 01:50:37 +02:00
MHSanaei 7e87b7dc60 i18n: point API token hint at the Authentication page in all locales
The remote panel's API token moved from Settings to the Authentication
page; update the node form hint accordingly.
2026-06-12 01:32:00 +02:00
MHSanaei dbee150b33 fix(script): SSL management fixes (#4994, #5010, #5070)
- Issue acme.sh HTTP-01 over IPv4 unless the host has no global IPv4
  address: the hardcoded --listen-v6 started a v6-only standalone
  listener, so validation of a domain whose A record points at this
  host always failed (#4994).
- Add a custom cert/key path option to the "Set Cert paths" menu so
  certificates living outside /root/cert (e.g. certbot under
  /etc/letsencrypt) can be wired to the panel from the CLI (#5010).
- Derive the displayed Access URL from the certificate's actual SAN
  list instead of the cert folder name, list the other covered names,
  and show the panel's custom-path certificate in "Show Existing
  Domains" (#5070). Also silence find when /root/cert doesn't exist.
2026-06-12 01:22:30 +02:00
MHSanaei 1a525b4cb4 fix(client): apply per-field client edits to every inbound of the email (#5039)
applyClientFieldByEmail patched only the first inbound that the
client_traffics row pointed at. For a multi-inbound client the sibling
inbounds kept the old expiryTime/totalGB/limitIp in their settings JSON,
and the next SyncInbound over a stale sibling reverted the edit in the
normalized records — the Telegram bot's expiry change appeared to apply
and then sprang back. Patch the field on every inbound linked to the
email, falling back to the legacy single-inbound lookup for clients that
were never normalized.
2026-06-12 01:22:15 +02:00
MHSanaei b062cb5a14 fix(sub): tag node-hosted entries with the node name in remarks (#5035)
An inbound pushed to nodes keeps the same remark on every copy, so a
multi-node subscription (and the panel's per-client link view) listed
several identically-named entries differing only by address. Append the
node name to the remark of node-hosted inbounds unless the admin already
included it.
2026-06-12 01:22:15 +02:00
MHSanaei a27d57b2ff fix(ui): keep dropdown action menus inside the viewport (#5133)
The inbound/client context menus hold a dozen items; when antd flips a
tall menu upward near the screen edge it overflowed the top of the
viewport, hiding the first entries. Cap the overlay height and scroll.
2026-06-12 01:21:54 +02:00
MHSanaei 10a0c9131c fix(hysteria): clamp udpIdleTimeout to xray-core's accepted 2-600s range (#5117)
The schema and form inputs allowed any value >= 1, but xray-core rejects
UdpIdleTimeout outside 2-600 seconds at startup, so an out-of-range value
silently killed the whole config.
2026-06-12 01:21:54 +02:00
MHSanaei a5e5640804 fix(inbound): explain how to unlock fallbacks on the inbound form (#5014)
The fallbacks card only renders for VLESS/Trojan over RAW with TLS or
Reality security, and a new inbound starts at security=none — so the Add
Inbound page looked like it had lost fallback support entirely. Show an
inline hint in that state pointing at the Security tab.
2026-06-12 01:21:38 +02:00
MHSanaei 0711d3077b chore: pin generated files to LF to avoid phantom CRLF diffs on Windows 2026-06-11 23:41:01 +02:00
MHSanaei 8578b229ce feat(settings): allow a balancer as the panel traffic outbound
The panel egress is injected as a routing rule, so a routing balancer is
a valid target for it (unlike the geodata download, which dials a forced
outbound tag and bypasses the router). Surface routing balancers in the
panel outbound picker as a separate group, and emit balancerTag instead
of outboundTag in the injected egress rule when the configured tag names
a balancer, so the panel's own traffic load-balances across its members.
2026-06-11 23:32:58 +02:00
MHSanaei c47a905ad2 fix(inbound): offer node share-address strategy only when a node exists
The `node` share-address strategy resolves to an address only when the
inbound can live on a node; for a local inbound it is always empty and
behaves like `listen`. Drop the `node` option from the picker unless an
enabled, node-eligible node exists, and coerce the value to `listen`
otherwise so the Select never shows an option that does nothing.
2026-06-11 23:32:47 +02:00
MHSanaei 825778144c fix(outbound): widen probe timeout and surface failure reason in outbound test (#5152)
The v3 outbound test spins up a temp xray that probes the outbound via
burstObservatory. Two regressions made it report "Failed" for healthy
outbounds on high-latency / tunnel-routed boxes (e.g. default route over
an OpenVPN tun device to a remote proxy), even though client traffic over
the same outbound works:

- Each probe disables keep-alive, so every attempt is a cold round-trip
  (redial + re-handshake). The 5s per-probe timeout was too tight for such
  paths and every probe timed out. Restore the ~10s budget the pre-v3
  SOCKS-based test gave a cold connection (timeout 5s -> 10s) and widen the
  poll window 12s -> 15s so one full probe can complete and surface alive.

- The temp config set log error to "none", discarding the real failure
  reason, so "Failed" was undiagnosable. Route error logs to stderr ("")
  like the production template does, so the probe error (DNS lookup
  failure, connection refused, deadline exceeded, TLS error, ...) is
  captured into the panel/Xray log, and point the operator there in the
  generic timeout messages.
2026-06-11 22:49:22 +02:00
MHSanaei 1b0dbf8e6d fix(sub): deduplicate settings.clients entries per inbound in subscription output (#5134)
Multi-node sync/import drift can leave the same client twice inside an
inbound's legacy settings.clients JSON while the normalized
client_inbounds table stays clean (SyncInbound dedupes the rows it
writes but never rewrites the JSON). All three subscription builders
iterated that JSON verbatim, so every duplicate entry became a
duplicate profile in the raw, Clash, and JSON output.

Filter and dedupe by email in one shared helper (link generation keys
purely on inbound + email, so same-email entries are pure duplicates
and dropping them is lossless). The clash/json services' own
inboundService copies became unused and are removed.
2026-06-11 22:19:14 +02:00
MHSanaei 09a887f95c fix(warp): prefer IPv4 with v6 fallback and userspace TUN in generated WireGuard outbounds (#5205)
The generated WARP outbound used domainStrategy ForceIP, which may pick
the AAAA record for engage.cloudflareclient.com; on a host with
half-configured IPv6 the handshake then blackholes with nothing in the
logs. ForceIPv4v6 prefers IPv4 and still falls back to IPv6 on
v6-only hosts, matching the official WARP client's behavior.

It also set noKernelTun: false, so with root privileges the real
outbound used kernel TUN — a path that needs CAP_NET_ADMIN plus fwmark
routing and fails silently on many VPS setups — while the panel's
connectivity probe always tests with noKernelTun: true. The status
check and real traffic exercised different data paths and could
disagree. Generate WARP and NordVPN outbounds with the userspace TUN
so both follow the path the probe validates.

Only affects newly added/reset outbounds; existing templates keep
their saved settings.
2026-06-11 21:49:45 +02:00
MHSanaei cc65f37164 fix(sub): honor per-inbound share address strategy in subscription output (#5208)
Subscriptions resolved a node-managed inbound's address to the node's
panel address unconditionally, so an inbound bound to a specific public
IP advertised an endpoint clients could not reach. The shareAddrStrategy
field added in #5162 only applied to panel share/QR links by design.

resolveInboundAddress now follows the same order as the panel's link
builder: 'listen' prefers a routable bind, 'custom' prefers shareAddr,
and the default 'node' keeps the existing node-first behavior, so output
is unchanged for inbounds that never set the field. Applies to raw,
JSON, and Clash subscriptions, which all resolve through this path.
Help text in all locales updated to drop the 'subscriptions are not
affected' caveat.
2026-06-11 21:31:27 +02:00
MHSanaei 21143a6d72 fix(node-sync): keep node baseline while a sibling inbound still reports the email (#5202)
The orphan sweeps in setRemoteTrafficLocked deleted the (node, email)
baseline row unconditionally whenever an email was missing from one
inbound's snapshot stats — even though baselines are keyed per node, not
per inbound. For a client attached to two inbounds of the same node whose
stats the node reports under only one of them, the sweep for the other
inbound deleted the baseline at the end of every sync cycle. Depending on
inbound order, the baseline written earlier in the same transaction was
wiped each time, so the next cycle computed delta against a missing
baseline (zero) and the client's traffic froze permanently.

Scope both sweeps to the union of emails across the whole snapshot: a
baseline is only dropped when the email left the node entirely.
2026-06-11 21:20:38 +02:00
MHSanaei 1508666e52 fix: DNS server edit modal showing defaults instead of saved values (#5155)
The DNS server table columns were memoized with only [t] as deps, so
they permanently captured the first render's openEditServer callback,
which closed over the initial (null) dns settings. Clicking Edit then
resolved the server to null and the modal fell back to default values.

Stabilize openEditServer/deleteServer (and the fakedns equivalents)
with useCallback and include them in the column memo deps so the
columns refresh whenever the servers list changes.
2026-06-11 20:58:23 +02:00
MHSanaei 2db48174b0 fix: apply only the x-ui sysctl config when toggling BBR (#5160)
sysctl --system re-applies every sysctl file on the host, surfacing
unrelated "Invalid argument" errors from the distro's own defaults
(e.g. Ubuntu 22.04's 50-default.conf on kernels 5.14+). Apply only
/etc/sysctl.d/99-bbr-x-ui.conf on enable, and drop the redundant
re-apply on disable since sysctl -w already restores the live values.
2026-06-11 20:53:05 +02:00
animesha3 554d85c2f7 feat: allow selecting inbounds synchronized from nodes (#5178)
* feat: select node inbounds for synchronization

Allow node owners to import either all remote inbounds or an explicit tag-based selection. Add remote inbound discovery, persistence, snapshot filtering, API documentation, tests, and localized UI labels.

* fix

* fix: scope node reconcile and orphan sweep to selected inbound tags

In 'selected' sync mode unselected inbounds never enter the panel DB, so
ReconcileNode treated them as undesired and deleted them from the node the
first time it went config-dirty. Reconcile now only sweeps remote tags that
are part of the selection; everything else on the node is unmanaged.

Panel-created or renamed inbounds on a selected-mode node also vanished:
their tag was outside the selection, so the next traffic pull filtered them
out of the snapshot and the orphan sweep silently dropped the central row.
AddInbound/UpdateInbound now allow the tag on the node before committing.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 20:48:26 +02:00
iYuan 2a7342baa9 feat: add inbound share address strategy (#5162)
* feat: add inbound share address strategy

Allow node-managed inbounds to choose whether exported share links use the node address, routable listen address, or a custom endpoint. Preserve locally configured share address fields during remote node traffic sync.

Refs #5161

Refs #4891

* fix: preserve inbound share address settings

Forward share address fields to remote nodes, keep existing values when older update payloads omit them, align localhost handling between frontend and subscriptions, and preserve share address settings when cloning inbounds.

* fix: keep share address strategy out of subscriptions

Limit the new share address strategy to direct exported share links and QR codes. Restore subscription address resolution to the existing panel-owned behavior and update the UI help text accordingly.

* fix: address share address review feedback

* fix: validate custom share address

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 20:24:15 +02:00
w3struk ec45d3491a fix: derive JSON/Clash subscription URLs from configured subURI (#5203)
* fix: derive JSON/Clash subscription URLs from configured subURI

When subURI is explicitly configured (reverse-proxy setup) but subJsonURI
or subClashURI are not, BuildSubURIBase generates URLs with the raw sub-
server port (2096) and the wrong scheme (http), producing broken links
on the subscription page (e.g. http://domain:2096/json/SUB_ID).

Fix: in BuildURLs, when subURI is set, extract its scheme+host and use
that as the base for all unconfigured sibling URLs instead of calling
BuildSubURIBase. This ensures JSON and Clash Copy URLs match the reverse-
proxy endpoint.

Fixes: JSON/Clash subscription URLs shown on the subscription info page
now correctly inherit the configured subURI's scheme and host.

* fix(sub): fall back to request base when configured subURI is unparseable

Harden the JSON/Clash URL derivation added for the reverse-proxy fix:
extractBaseFromURI now returns "" when the configured subURI has no
scheme/host, and BuildURLs falls back to the request-derived base in
that case instead of emitting a broken value (e.g. ":///json/ABC").

Add a regression test covering a scheme-less subURI.

---------

Co-authored-by: w3struk <w3struk@gmail.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 20:05:38 +02:00
144 changed files with 6375 additions and 1121 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
# Shell scripts must stay LF so the Docker build works when the repo is
# checked out on Windows (CRLF breaks the script shebang -> exit 127).
*.sh text eol=lf *.sh text eol=lf
DockerInit.sh text eol=lf DockerInit.sh text eol=lf
DockerEntrypoint.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
+1
View File
@@ -36,6 +36,7 @@ Thumbs.db
x-ui.db x-ui.db
x-ui.db-shm x-ui.db-shm
x-ui.db-wal x-ui.db-wal
system_metrics.gob
*.dump *.dump
# Ignore Docker specific files # Ignore Docker specific files
+98 -95
View File
@@ -1,12 +1,12 @@
{ {
"name": "3x-ui-frontend", "name": "3x-ui-frontend",
"version": "0.3.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "3x-ui-frontend", "name": "3x-ui-frontend",
"version": "0.3.0", "version": "0.3.1",
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.2.5", "@ant-design/icons": "^6.2.5",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
@@ -518,9 +518,9 @@
} }
}, },
"node_modules/@codemirror/lint": { "node_modules/@codemirror/lint": {
"version": "6.9.6", "version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -561,9 +561,9 @@
} }
}, },
"node_modules/@codemirror/view": { "node_modules/@codemirror/view": {
"version": "6.43.0", "version": "6.43.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
@@ -617,9 +617,9 @@
} }
}, },
"node_modules/@csstools/css-color-parser": { "node_modules/@csstools/css-color-parser": {
"version": "4.1.1", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1062,14 +1062,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.2"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1103,9 +1103,9 @@
} }
}, },
"node_modules/@rc-component/async-validator": { "node_modules/@rc-component/async-validator": {
"version": "5.1.1", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz",
"integrity": "sha512-T03+Wk31Kz/28OC+rLlHtSNwD5Io3OWw6rPFPAp898sqALB/XnTrr3trB3mPoj379v0aRaW6t09HUG6dUyHR3g==", "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.24.4" "@babel/runtime": "^7.24.4"
@@ -1115,14 +1115,14 @@
} }
}, },
"node_modules/@rc-component/cascader": { "node_modules/@rc-component/cascader": {
"version": "1.15.0", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.15.0.tgz", "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.16.1.tgz",
"integrity": "sha512-ZzpMtwFCRo3fbXHuDnncARJMZQjdqA2w7aDuPofNQt+aDx39st1hgfIpEwTBLhe2Hqsvs/zOr8RTtgxTkCPySw==", "integrity": "sha512-wxLopwM+EBed0zNNGdnGE4coYoqcO+XD42fHgn+pDvO+XzhNFbdgSlSNXdKocIYqccvqgWvoxDPNb0OVRdi59A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rc-component/select": "~1.6.0", "@rc-component/select": "~1.7.1",
"@rc-component/tree": "~1.3.0", "@rc-component/tree": "~1.3.2",
"@rc-component/util": "^1.4.0", "@rc-component/util": "^1.11.1",
"clsx": "^2.1.1" "clsx": "^2.1.1"
}, },
"peerDependencies": { "peerDependencies": {
@@ -1236,12 +1236,12 @@
} }
}, },
"node_modules/@rc-component/form": { "node_modules/@rc-component/form": {
"version": "1.8.2", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.2.tgz", "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
"integrity": "sha512-ZidCvOLmM9Xr+3vzk4UAoR7Aj1W/5IHyrzlBB7sNkygpTeRVrohQSo4TN7W/nARTH+nt8zSAPsn4BEl4zLEO2g==", "integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rc-component/async-validator": "^5.1.0", "@rc-component/async-validator": "^6.0.0",
"@rc-component/util": "^1.11.1", "@rc-component/util": "^1.11.1",
"clsx": "^2.1.1" "clsx": "^2.1.1"
}, },
@@ -1410,12 +1410,12 @@
} }
}, },
"node_modules/@rc-component/pagination": { "node_modules/@rc-component/pagination": {
"version": "1.2.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.3.0.tgz",
"integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", "integrity": "sha512-12ahTY+HPITg1L2bjWKXUqBJe/oOnpA2QsChdCjthqLVf/e19StiCsv8OLKpWoHbc+8PFEkNjRqRqrLoRBHjFw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rc-component/util": "^1.3.0", "@rc-component/util": "^1.11.1",
"clsx": "^2.1.1" "clsx": "^2.1.1"
}, },
"peerDependencies": { "peerDependencies": {
@@ -1493,9 +1493,9 @@
} }
}, },
"node_modules/@rc-component/qrcode": { "node_modules/@rc-component/qrcode": {
"version": "1.1.2", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz",
"integrity": "sha512-CTXG18eP3sO3gc+96ep9HyVI/RzMup7L59apM/D0wWo1SHRdwOb7xyD4bMbmpu4dPlTch59Kxb8lU7U9ME60fg==", "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.24.7" "@babel/runtime": "^7.24.7"
@@ -1555,15 +1555,15 @@
} }
}, },
"node_modules/@rc-component/select": { "node_modules/@rc-component/select": {
"version": "1.6.15", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz", "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.7.1.tgz",
"integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", "integrity": "sha512-GZ1cMJk2xQh0VHyOQjjG8drYL4iu24NcbkXioUcReQOCUr+ub/3fmRonZe6cRPEZhWMbJdeHsqnEltogDaZ5Tg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rc-component/overflow": "^1.0.0", "@rc-component/overflow": "^1.0.0",
"@rc-component/trigger": "^3.0.0", "@rc-component/trigger": "^3.0.0",
"@rc-component/util": "^1.3.0", "@rc-component/util": "^1.11.1",
"@rc-component/virtual-list": "^1.0.1", "@rc-component/virtual-list": "^1.2.0",
"clsx": "^2.1.1" "clsx": "^2.1.1"
}, },
"engines": { "engines": {
@@ -1717,12 +1717,12 @@
} }
}, },
"node_modules/@rc-component/tree-select": { "node_modules/@rc-component/tree-select": {
"version": "1.9.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.10.0.tgz",
"integrity": "sha512-GXcFe15a+trUl1/J3OHWQhsVWFpwFpGFK2cqYWZ1sK22Zs3KZTvMwDpzr75PIo1s6QVioVxpE/pRwRopkeDQ6w==", "integrity": "sha512-E1U4pn2LAbXEhLJdzIzid7WYbIuFbkTIctuFoeC6weppf8UbPR3+YYB6/ay0c0ksand4gXMRQpa1Z60Auo7VJA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@rc-component/select": "~1.6.0", "@rc-component/select": "~1.7.0",
"@rc-component/tree": "~1.3.0", "@rc-component/tree": "~1.3.0",
"@rc-component/util": "^1.4.0", "@rc-component/util": "^1.4.0",
"clsx": "^2.1.1" "clsx": "^2.1.1"
@@ -3284,9 +3284,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.3", "version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -3478,9 +3478,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -3553,54 +3553,54 @@
} }
}, },
"node_modules/antd": { "node_modules/antd": {
"version": "6.4.3", "version": "6.4.4",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz", "resolved": "https://registry.npmjs.org/antd/-/antd-6.4.4.tgz",
"integrity": "sha512-6H2avkxCGfxcF67r3J2mwm9Ck50el1pks/73vfM1wDsPL/tPtj5vHuauMgJFnrqmq7CH3g8aoZ0VBQbt+jpAsw==", "integrity": "sha512-lgPz4KhfhiYddV/qPYo0ieqWimCVgV2OQF72mbeGNixE753JWNnmEc7UNGy08wBS/zZ7hxrmX0pc5aX7EUaIIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ant-design/colors": "^8.0.1", "@ant-design/colors": "^8.0.1",
"@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs": "^2.1.2",
"@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2",
"@ant-design/fast-color": "^3.0.1", "@ant-design/fast-color": "^3.0.1",
"@ant-design/icons": "^6.2.3", "@ant-design/icons": "^6.2.5",
"@ant-design/react-slick": "~2.0.0", "@ant-design/react-slick": "~2.0.0",
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
"@rc-component/cascader": "~1.15.0", "@rc-component/cascader": "~1.16.1",
"@rc-component/checkbox": "~2.0.0", "@rc-component/checkbox": "~2.0.0",
"@rc-component/collapse": "~1.2.0", "@rc-component/collapse": "~1.2.0",
"@rc-component/color-picker": "~3.1.1", "@rc-component/color-picker": "~3.1.1",
"@rc-component/dialog": "~1.9.0", "@rc-component/dialog": "~1.9.0",
"@rc-component/drawer": "~1.4.2", "@rc-component/drawer": "~1.4.2",
"@rc-component/dropdown": "~1.0.2", "@rc-component/dropdown": "~1.0.2",
"@rc-component/form": "~1.8.1", "@rc-component/form": "~1.8.3",
"@rc-component/image": "~1.9.0", "@rc-component/image": "~1.9.0",
"@rc-component/input": "~1.3.0", "@rc-component/input": "~1.3.1",
"@rc-component/input-number": "~1.6.2", "@rc-component/input-number": "~1.6.2",
"@rc-component/mentions": "~1.9.0", "@rc-component/mentions": "~1.9.0",
"@rc-component/menu": "~1.3.0", "@rc-component/menu": "~1.3.1",
"@rc-component/motion": "^1.3.2", "@rc-component/motion": "^1.3.3",
"@rc-component/mutate-observer": "^2.0.1", "@rc-component/mutate-observer": "^2.0.1",
"@rc-component/notification": "~2.0.7", "@rc-component/notification": "~2.0.7",
"@rc-component/pagination": "~1.2.0", "@rc-component/pagination": "~1.3.0",
"@rc-component/picker": "~1.10.0", "@rc-component/picker": "~1.10.0",
"@rc-component/progress": "~1.0.2", "@rc-component/progress": "~1.0.2",
"@rc-component/qrcode": "~1.1.1", "@rc-component/qrcode": "~2.0.0",
"@rc-component/rate": "~1.0.1", "@rc-component/rate": "~1.0.1",
"@rc-component/resize-observer": "^1.1.2", "@rc-component/resize-observer": "^1.1.2",
"@rc-component/segmented": "~1.3.0", "@rc-component/segmented": "~1.3.0",
"@rc-component/select": "~1.6.15", "@rc-component/select": "~1.7.1",
"@rc-component/slider": "~1.0.1", "@rc-component/slider": "~1.0.1",
"@rc-component/steps": "~1.2.2", "@rc-component/steps": "~1.2.2",
"@rc-component/switch": "~1.0.3", "@rc-component/switch": "~1.0.3",
"@rc-component/table": "~1.10.0", "@rc-component/table": "~1.10.2",
"@rc-component/tabs": "~1.9.0", "@rc-component/tabs": "~1.9.1",
"@rc-component/tooltip": "~1.4.0", "@rc-component/tooltip": "~1.4.0",
"@rc-component/tour": "~2.4.0", "@rc-component/tour": "~2.4.0",
"@rc-component/tree": "~1.3.1", "@rc-component/tree": "~1.3.2",
"@rc-component/tree-select": "~1.9.0", "@rc-component/tree-select": "~1.10.0",
"@rc-component/trigger": "^3.9.0", "@rc-component/trigger": "^3.9.1",
"@rc-component/upload": "~1.1.0", "@rc-component/upload": "~1.1.1",
"@rc-component/util": "^1.11.0", "@rc-component/util": "^1.11.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"scroll-into-view-if-needed": "^3.1.0", "scroll-into-view-if-needed": "^3.1.0",
@@ -3719,9 +3719,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.34", "version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3859,9 +3859,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001797", "version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4327,9 +4327,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.8", "version": "3.4.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -4359,9 +4359,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.368", "version": "1.5.372",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz",
"integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4431,9 +4431,9 @@
} }
}, },
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.47.0", "version": "1.47.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"docs", "docs",
@@ -4464,11 +4464,14 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.4.1", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@@ -4806,16 +4809,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -5994,9 +5997,9 @@
} }
}, },
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.2", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true, "dev": true,
"funding": [ "funding": [
"https://github.com/sponsors/sxzz", "https://github.com/sponsors/sxzz",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "3x-ui-frontend", "name": "3x-ui-frontend",
"private": true, "private": true,
"version": "0.3.0", "version": "0.3.1",
"type": "module", "type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).", "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": { "engines": {
+147
View File
@@ -1332,8 +1332,25 @@
"type": "string" "type": "string"
}, },
"settings": {}, "settings": {},
"shareAddr": {
"type": "string"
},
"shareAddrStrategy": {
"enum": [
"node",
"listen",
"custom"
],
"type": "string"
},
"sniffing": {}, "sniffing": {},
"streamSettings": {}, "streamSettings": {},
"subSortIndex": {
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
"example": 1,
"minimum": 1,
"type": "integer"
},
"tag": { "tag": {
"example": "in-443-tcp", "example": "in-443-tcp",
"type": "string" "type": "string"
@@ -1370,8 +1387,11 @@
"protocol", "protocol",
"remark", "remark",
"settings", "settings",
"shareAddr",
"shareAddrStrategy",
"sniffing", "sniffing",
"streamSettings", "streamSettings",
"subSortIndex",
"tag", "tag",
"total", "total",
"trafficReset", "trafficReset",
@@ -1446,6 +1466,11 @@
"example": 1, "example": 1,
"type": "integer" "type": "integer"
}, },
"nodeId": {
"description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
"nullable": true,
"type": "integer"
},
"port": { "port": {
"example": 443, "example": 443,
"type": "integer" "type": "integer"
@@ -1559,6 +1584,19 @@
"example": 5, "example": 5,
"type": "integer" "type": "integer"
}, },
"inboundSyncMode": {
"enum": [
"all",
"selected"
],
"type": "string"
},
"inboundTags": {
"items": {
"type": "string"
},
"type": "array"
},
"lastError": { "lastError": {
"type": "string" "type": "string"
}, },
@@ -1662,6 +1700,8 @@
"guid", "guid",
"id", "id",
"inboundCount", "inboundCount",
"inboundSyncMode",
"inboundTags",
"lastError", "lastError",
"lastHeartbeat", "lastHeartbeat",
"latencyMs", "latencyMs",
@@ -2116,8 +2156,11 @@
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
"settings": null, "settings": null,
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": null, "sniffing": null,
"streamSettings": null, "streamSettings": null,
"subSortIndex": 1,
"tag": "in-443-tcp", "tag": "in-443-tcp",
"total": 0, "total": 0,
"trafficReset": "never", "trafficReset": "never",
@@ -2214,6 +2257,7 @@
"obj": [ "obj": [
{ {
"id": 1, "id": 1,
"nodeId": null,
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
@@ -5996,6 +6040,10 @@
"guid": "", "guid": "",
"id": 1, "id": 1,
"inboundCount": 5, "inboundCount": 5,
"inboundSyncMode": "all",
"inboundTags": [
""
],
"lastError": "", "lastError": "",
"lastHeartbeat": 1700000000, "lastHeartbeat": 1700000000,
"latencyMs": 42, "latencyMs": 42,
@@ -6436,6 +6484,65 @@
} }
} }
}, },
"/panel/api/nodes/inbounds": {
"post": {
"tags": [
"Nodes"
],
"summary": "Use unsaved node connection details to list the remote inbounds available for selective import.",
"operationId": "post_panel_api_nodes_inbounds",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"name": "de-fra-1",
"scheme": "https",
"address": "node1.example.com",
"port": 2053,
"basePath": "/",
"apiToken": "abcdef..."
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": [
{
"tag": "inbound-443",
"remark": "VLESS",
"protocol": "vless",
"port": 443
}
]
}
}
}
}
}
}
},
"/panel/api/nodes/probe/{id}": { "/panel/api/nodes/probe/{id}": {
"post": { "post": {
"tags": [ "tags": [
@@ -7417,6 +7524,46 @@
} }
} }
}, },
"/panel/api/xray/testOutbounds": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.",
"operationId": "post_panel_api_xray_testOutbounds",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/balancerStatus": { "/panel/api/xray/balancerStatus": {
"post": { "post": {
"tags": [ "tags": [
@@ -15,6 +15,13 @@ export interface NodeUpdateResult {
error?: string; error?: string;
} }
export interface RemoteInboundOption {
tag: string;
remark?: string;
protocol?: string;
port?: number;
}
export function useNodeMutations() { export function useNodeMutations() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() }); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.nodes.root() });
@@ -72,5 +79,7 @@ export function useNodeMutations() {
}, },
fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> => fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload), HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
fetchInbounds: (payload: Partial<NodeRecord>): Promise<Msg<RemoteInboundOption[]>> =>
HttpUtil.post<RemoteInboundOption[]>('/panel/api/nodes/inbounds', payload),
}; };
} }
@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { keys } from '@/api/queryKeys';
import { fetchXrayConfig } from '@/hooks/useXraySetting';
// Available outbound (and balancer-eligible) tags the user can route an mtproto
// 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() {
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
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);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) tags.add(t);
}
// Balancers are valid routing targets too — injectMtprotoEgress emits a
// balancerTag rule when the chosen tag names a balancer.
const balancers = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
for (const b of balancers ?? []) {
if (b?.tag) tags.add(b.tag);
}
return [...tags];
},
});
}
@@ -3,6 +3,8 @@ import { Input, Modal } from 'antd';
import type { InputRef } from 'antd'; import type { InputRef } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import JsonEditor from '@/components/form/JsonEditor';
interface PromptModalProps { interface PromptModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -11,6 +13,7 @@ interface PromptModalProps {
type?: 'input' | 'textarea'; type?: 'input' | 'textarea';
initialValue?: string; initialValue?: string;
loading?: boolean; loading?: boolean;
json?: boolean;
onConfirm: (value: string) => void; onConfirm: (value: string) => void;
} }
@@ -22,6 +25,7 @@ export default function PromptModal({
type = 'input', type = 'input',
initialValue = '', initialValue = '',
loading = false, loading = false,
json = false,
onConfirm, onConfirm,
}: PromptModalProps) { }: PromptModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,7 +67,9 @@ export default function PromptModal({
onCancel={onClose} onCancel={onClose}
destroyOnHidden destroyOnHidden
> >
{type === 'textarea' ? ( {json ? (
<JsonEditor value={value} onChange={setValue} minHeight="240px" maxHeight="60vh" />
) : type === 'textarea' ? (
<Input.TextArea <Input.TextArea
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }} ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
value={value} value={value}
+17 -11
View File
@@ -2,6 +2,7 @@ import { Button, Input, Modal, message } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons'; import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import JsonEditor from '@/components/form/JsonEditor';
import { ClipboardManager, FileManager } from '@/utils'; import { ClipboardManager, FileManager } from '@/utils';
interface TextModalProps { interface TextModalProps {
@@ -10,9 +11,10 @@ interface TextModalProps {
title: string; title: string;
content: string; content: string;
fileName?: string; fileName?: string;
json?: boolean;
} }
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) { export default function TextModal({ open, onClose, title, content, fileName = '', json = false }: TextModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
async function copy() { async function copy() {
@@ -45,16 +47,20 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
</> </>
)} )}
> >
<Input.TextArea {json ? (
value={content} <JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
readOnly ) : (
autoSize={{ minRows: 10, maxRows: 20 }} <Input.TextArea
style={{ value={content}
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', readOnly
fontSize: 12, autoSize={{ minRows: 10, maxRows: 20 }}
overflowY: 'auto', style={{
}} fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
/> fontSize: 12,
overflowY: 'auto',
}}
/>
)}
</Modal> </Modal>
</> </>
); );
+8
View File
@@ -288,8 +288,11 @@ export const EXAMPLES: Record<string, unknown> = {
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
"settings": null, "settings": null,
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": null, "sniffing": null,
"streamSettings": null, "streamSettings": null,
"subSortIndex": 1,
"tag": "in-443-tcp", "tag": "in-443-tcp",
"total": 0, "total": 0,
"trafficReset": "never", "trafficReset": "never",
@@ -313,6 +316,7 @@ export const EXAMPLES: Record<string, unknown> = {
}, },
"InboundOption": { "InboundOption": {
"id": 1, "id": 1,
"nodeId": null,
"port": 443, "port": 443,
"protocol": "vless", "protocol": "vless",
"remark": "VLESS-443", "remark": "VLESS-443",
@@ -340,6 +344,10 @@ export const EXAMPLES: Record<string, unknown> = {
"guid": "", "guid": "",
"id": 1, "id": 1,
"inboundCount": 5, "inboundCount": 5,
"inboundSyncMode": "all",
"inboundTags": [
""
],
"lastError": "", "lastError": "",
"lastHeartbeat": 1700000000, "lastHeartbeat": 1700000000,
"latencyMs": 42, "latencyMs": 42,
+40
View File
@@ -1306,8 +1306,25 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string" "type": "string"
}, },
"settings": {}, "settings": {},
"shareAddr": {
"type": "string"
},
"shareAddrStrategy": {
"enum": [
"node",
"listen",
"custom"
],
"type": "string"
},
"sniffing": {}, "sniffing": {},
"streamSettings": {}, "streamSettings": {},
"subSortIndex": {
"description": "1-based sort order of this inbound's links in subscription output only (lower first; ties by id)",
"example": 1,
"minimum": 1,
"type": "integer"
},
"tag": { "tag": {
"example": "in-443-tcp", "example": "in-443-tcp",
"type": "string" "type": "string"
@@ -1344,8 +1361,11 @@ export const SCHEMAS: Record<string, unknown> = {
"protocol", "protocol",
"remark", "remark",
"settings", "settings",
"shareAddr",
"shareAddrStrategy",
"sniffing", "sniffing",
"streamSettings", "streamSettings",
"subSortIndex",
"tag", "tag",
"total", "total",
"trafficReset", "trafficReset",
@@ -1420,6 +1440,11 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 1, "example": 1,
"type": "integer" "type": "integer"
}, },
"nodeId": {
"description": "Hosting node; nil for this panel's own inbounds. Lets the clients\npage map a node filter onto inbound IDs (#4997).",
"nullable": true,
"type": "integer"
},
"port": { "port": {
"example": 443, "example": 443,
"type": "integer" "type": "integer"
@@ -1533,6 +1558,19 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 5, "example": 5,
"type": "integer" "type": "integer"
}, },
"inboundSyncMode": {
"enum": [
"all",
"selected"
],
"type": "string"
},
"inboundTags": {
"items": {
"type": "string"
},
"type": "array"
},
"lastError": { "lastError": {
"type": "string" "type": "string"
}, },
@@ -1636,6 +1674,8 @@ export const SCHEMAS: Record<string, unknown> = {
"guid", "guid",
"id", "id",
"inboundCount", "inboundCount",
"inboundSyncMode",
"inboundTags",
"lastError", "lastError",
"lastHeartbeat", "lastHeartbeat",
"latencyMs", "latencyMs",
+6
View File
@@ -289,8 +289,11 @@ export interface Inbound {
protocol: Protocol; protocol: Protocol;
remark: string; remark: string;
settings: unknown; settings: unknown;
shareAddr: string;
shareAddrStrategy: string;
sniffing: unknown; sniffing: unknown;
streamSettings: unknown; streamSettings: unknown;
subSortIndex: number;
tag: string; tag: string;
total: number; total: number;
trafficReset: string; trafficReset: string;
@@ -317,6 +320,7 @@ export interface InboundFallback {
export interface InboundOption { export interface InboundOption {
id: number; id: number;
nodeId?: number | null;
port: number; port: number;
protocol: string; protocol: string;
remark: string; remark: string;
@@ -346,6 +350,8 @@ export interface Node {
guid: string; guid: string;
id: number; id: number;
inboundCount: number; inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
lastError: string; lastError: string;
lastHeartbeat: number; lastHeartbeat: number;
latencyMs: number; latencyMs: number;
+6
View File
@@ -310,8 +310,11 @@ export const InboundSchema = z.object({
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']), protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
remark: z.string(), remark: z.string(),
settings: z.unknown(), settings: z.unknown(),
shareAddr: z.string(),
shareAddrStrategy: z.enum(['node', 'listen', 'custom']),
sniffing: z.unknown(), sniffing: z.unknown(),
streamSettings: z.unknown(), streamSettings: z.unknown(),
subSortIndex: z.number().int().min(1),
tag: z.string(), tag: z.string(),
total: z.number().int(), total: z.number().int(),
trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']), trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
@@ -341,6 +344,7 @@ export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const InboundOptionSchema = z.object({ export const InboundOptionSchema = z.object({
id: z.number().int(), id: z.number().int(),
nodeId: z.number().int().nullable().optional(),
port: z.number().int(), port: z.number().int(),
protocol: z.string(), protocol: z.string(),
remark: z.string(), remark: z.string(),
@@ -372,6 +376,8 @@ export const NodeSchema = z.object({
guid: z.string(), guid: z.string(),
id: z.number().int(), id: z.number().int(),
inboundCount: z.number().int(), inboundCount: z.number().int(),
inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()),
lastError: z.string(), lastError: z.string(),
lastHeartbeat: z.number().int(), lastHeartbeat: z.number().int(),
latencyMs: z.number().int(), latencyMs: z.number().int(),
+1 -6
View File
@@ -255,12 +255,6 @@ export function useClients() {
return { ...live, total: serverSummary.total || live.total }; return { ...live, total: serverSummary.total || live.total };
}, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]); }, [allClientStats, onlines, expireDiff, trafficDiff, listQuery.data?.summary]);
// Client mutations (add/update/remove/attach/detach/resetTraffic/…) all
// mutate inbound rows server-side too — adding a client appends to
// settings.clients on each attached inbound, the slim list's per-inbound
// client count is derived from that. Invalidate both buckets so the
// Inbounds page and any open edit modal pick up the new shape without
// a manual reload.
const invalidateAll = useCallback( const invalidateAll = useCallback(
() => { () => {
markLocalInvalidate(); markLocalInvalidate();
@@ -268,6 +262,7 @@ export function useClients() {
return Promise.all([ return Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }), queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }), queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]); ]);
}, },
[queryClient], [queryClient],
+52 -21
View File
@@ -7,7 +7,7 @@ import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import { import {
OutboundTrafficListSchema, OutboundTrafficListSchema,
OutboundTestResultSchema, OutboundTestResultListSchema,
XrayConfigPayloadSchema, XrayConfigPayloadSchema,
XraySettingsValueSchema, XraySettingsValueSchema,
type OutboundTestResult, type OutboundTestResult,
@@ -16,6 +16,10 @@ import {
const DIRTY_POLL_MS = 1000; const DIRTY_POLL_MS = 1000;
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// shared temp xray instance; chunking keeps responses bounded (~15s worst
// case) and lands Test All results progressively.
const HTTP_BATCH_CHUNK = 16;
export function isUdpOutbound(outbound: unknown): boolean { export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined; const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined;
@@ -77,7 +81,7 @@ export interface UseXraySettingResult {
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>; type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
async function fetchXrayConfig(): Promise<XrayConfigPayload> { export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string'); if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
@@ -254,21 +258,26 @@ export function useXraySetting(): UseXraySettingResult {
const spinning = saveMut.isPending || resetDefaultMut.isPending; const spinning = saveMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult // Shared POST + parse for a batch of outbound tests. The backend probes the
// (success or a failure-shaped result); callers store it under their own key. // whole batch through one shared temp xray instance and returns results in
const postOutboundTest = useCallback( // request order; this aligns them by index and shapes failures so every
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => { // input gets an OutboundTestResult.
const postOutboundTestBatch = useCallback(
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
const failAll = (error: string): OutboundTestResult[] =>
outbounds.map(() => ({ success: false, error, mode: effMode }));
try { try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', { const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
outbound: JSON.stringify(outbound), outbounds: JSON.stringify(outbounds),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode, mode: effMode,
}); });
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound'); const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
if (msg?.success && msg.obj) return msg.obj; if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
return { success: false, error: msg?.msg || 'Unknown error', mode: effMode }; const list = msg.obj;
return outbounds.map((_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode });
} catch (e) { } catch (e) {
return { success: false, error: String(e), mode: effMode }; return failAll(String(e));
} }
}, },
[], [],
@@ -282,11 +291,11 @@ export function useXraySetting(): UseXraySettingResult {
...prev, ...prev,
[index]: { testing: true, result: null, mode: effMode }, [index]: { testing: true, result: null, mode: effMode },
})); }));
const result = await postOutboundTest(outbound, effMode); const [result] = await postOutboundTestBatch([outbound], effMode);
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } })); setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
return result.success ? result : null; return result.success ? result : null;
}, },
[postOutboundTest], [postOutboundTestBatch],
); );
// Test a subscription outbound (not present in templateSettings.outbounds); // Test a subscription outbound (not present in templateSettings.outbounds);
@@ -299,11 +308,11 @@ export function useXraySetting(): UseXraySettingResult {
...prev, ...prev,
[tag]: { testing: true, result: null, mode: effMode }, [tag]: { testing: true, result: null, mode: effMode },
})); }));
const result = await postOutboundTest(outbound, effMode); const [result] = await postOutboundTestBatch([outbound], effMode);
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } })); setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
return result.success ? result : null; return result.success ? result : null;
}, },
[postOutboundTest], [postOutboundTestBatch],
); );
const testAllOutbounds = useCallback(async (mode = 'tcp') => { const testAllOutbounds = useCallback(async (mode = 'tcp') => {
@@ -324,7 +333,10 @@ export function useXraySetting(): UseXraySettingResult {
tcpQueue.push({ index: i, outbound: ob }); tcpQueue.push({ index: i, outbound: ob });
} }
}); });
const runLane = async (queue: { index: number; outbound: unknown }[], concurrency: number) => { // TCP probes are dial-only and cheap server-side; per-item requests
// keep results landing one by one.
const runTcpLane = async () => {
const queue = [...tcpQueue];
const worker = async () => { const worker = async () => {
while (queue.length > 0) { while (queue.length > 0) {
const item = queue.shift(); const item = queue.shift();
@@ -332,14 +344,33 @@ export function useXraySetting(): UseXraySettingResult {
await testOutbound(item.index, item.outbound, mode); await testOutbound(item.index, item.outbound, mode);
} }
}; };
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker()); await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
await Promise.all(workers);
}; };
await Promise.all([runLane(tcpQueue, 8), runLane(httpQueue, 1)]); // 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);
setOutboundTestStates((prev) => {
const next = { ...prev };
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
return next;
});
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
setOutboundTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.index] = { testing: false, result: results[i] };
});
return next;
});
}
};
await Promise.all([runTcpLane(), runHttpLane()]);
} finally { } finally {
setTestingAll(false); setTestingAll(false);
} }
}, [testingAll, testOutbound]); }, [testingAll, testOutbound, postOutboundTestBatch]);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
+4 -7
View File
@@ -1,12 +1,9 @@
/** /**
* Display label for an inbound: `tag (remark)` when a distinct remark exists, * Display label for an inbound: the remark when one is set, otherwise the
* otherwise just the tag. Falls back to the remark when no tag is set, and to an * inbound tag. Falls back to an empty string when neither is present.
* empty string when neither is present.
*/ */
export function formatInboundLabel(tag?: string, remark?: string): string { export function formatInboundLabel(tag?: string, remark?: string): string {
const tagText = (tag || '').trim();
const remarkText = (remark || '').trim(); const remarkText = (remark || '').trim();
if (!tagText) return remarkText; if (remarkText) return remarkText;
if (!remarkText || remarkText === tagText) return tagText; return (tag || '').trim();
return `${tagText} (${remarkText})`;
} }
@@ -1,4 +1,4 @@
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { FormInstance } from 'antd/es/form'; import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface'; import type { NamePath } from 'antd/es/form/interface';
@@ -205,13 +205,18 @@ function TcpMaskItem({
if (type === 'fragment') { if (type === 'fragment') {
return ( return (
<> <>
<Form.Item label="Packets" name={[fieldName, 'settings', 'packets']}> <Form.Item
<Select label="Packets"
name={[fieldName, 'settings', 'packets']}
rules={[{ validator: validateFragmentPackets }]}
>
<AutoComplete
options={[ options={[
{ value: 'tlshello', label: 'tlshello' }, { value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' }, { value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' }, { value: '1-5', label: '1-5' },
]} ]}
placeholder="tlshello or n-m, e.g. 1-3"
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -264,6 +269,16 @@ function TcpMaskItem({
); );
} }
// xray's fragment `packets` accepts "tlshello" or an arbitrary packet-number
// range like "1-3" (#5075 — presets only covered the common cases).
function validateFragmentPackets(_rule: unknown, value: unknown): Promise<void> {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
if (str.length === 0 || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
return Promise.resolve();
}
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
}
// Walks a deep object path safely. Used inside shouldUpdate which gets // Walks a deep object path safely. Used inside shouldUpdate which gets
// the whole form values blob; we need to compare a deep field across // the whole form values blob; we need to compare a deep field across
// prev/curr without crashing on missing intermediates. // prev/curr without crashing on missing intermediates.
+33 -1
View File
@@ -1,4 +1,4 @@
import type { InboundFormValues, TrafficReset } from '@/schemas/forms/inbound-form'; import type { InboundFormValues, ShareAddrStrategy, TrafficReset } from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound'; import type { InboundSettings } from '@/schemas/protocols/inbound';
import { import {
HysteriaClientSchema, HysteriaClientSchema,
@@ -12,6 +12,9 @@ import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod'; import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities'; import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
// Plain-data adapter between the panel's stored inbound row shape and // Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside // the typed InboundFormValues that Form.useForm<T> carries inside
@@ -37,6 +40,9 @@ export interface RawInboundRow {
trafficReset?: string; trafficReset?: string;
lastTrafficResetTime?: number; lastTrafficResetTime?: number;
nodeId?: number | null; nodeId?: number | null;
shareAddrStrategy?: string;
shareAddr?: string;
subSortIndex?: number;
clientStats?: unknown; clientStats?: unknown;
} }
@@ -61,6 +67,9 @@ export interface WireInboundPayload {
tag: string; tag: string;
clientStats?: unknown; clientStats?: unknown;
nodeId?: number; nodeId?: number;
shareAddrStrategy: ShareAddrStrategy;
shareAddr: string;
subSortIndex: number;
} }
function coerceJsonObject(value: unknown): Record<string, unknown> { function coerceJsonObject(value: unknown): Record<string, unknown> {
@@ -82,6 +91,7 @@ function coerceJsonObject(value: unknown): Record<string, unknown> {
} }
const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly']; const TRAFFIC_RESETS: TrafficReset[] = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
const SHARE_ADDR_STRATEGIES: ShareAddrStrategy[] = ['node', 'listen', 'custom'];
function coerceTrafficReset(v: unknown): TrafficReset { function coerceTrafficReset(v: unknown): TrafficReset {
return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v) return typeof v === 'string' && (TRAFFIC_RESETS as string[]).includes(v)
@@ -89,6 +99,12 @@ function coerceTrafficReset(v: unknown): TrafficReset {
: 'never'; : 'never';
} }
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
? (v as ShareAddrStrategy)
: 'node';
}
// Network values that map to a required `${network}Settings` key in // Network values that map to a required `${network}Settings` key in
// NetworkSettingsSchema. Older saved inbounds may be missing the per- // NetworkSettingsSchema. Older saved inbounds may be missing the per-
// network sub-object (the legacy panel sometimes emitted streamSettings // network sub-object (the legacy panel sometimes emitted streamSettings
@@ -144,6 +160,16 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
if (streamSettings) { if (streamSettings) {
healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>); healStreamNetworkKey(streamSettings as unknown as Record<string, unknown>);
synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>); synthesizeTlsCertUseFile(streamSettings as unknown as Record<string, unknown>);
const streamRecord = streamSettings as unknown as Record<string, unknown>;
const xh = streamRecord.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const xhttp = xh as Record<string, unknown>;
const xmux = xhttp.xmux;
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
xhttp.enableXmux = true;
xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
}
}
} }
const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing; const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
@@ -162,6 +188,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
trafficReset: coerceTrafficReset(row.trafficReset), trafficReset: coerceTrafficReset(row.trafficReset),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0, lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null, nodeId: row.nodeId ?? null,
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
protocol, protocol,
settings, settings,
} as InboundFormValues; } as InboundFormValues;
@@ -307,6 +336,9 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
// rather than the default { enabled: false } so the row carries no sniffing. // rather than the default { enabled: false } so the row carries no sniffing.
sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '', sniffing: canEnableSniffing({ protocol: values.protocol }) ? JSON.stringify(normalizeSniffing(values.sniffing)) : '',
tag: values.tag, tag: values.tag,
shareAddrStrategy: values.shareAddrStrategy,
shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex,
}; };
if (values.nodeId != null) payload.nodeId = values.nodeId; if (values.nodeId != null) payload.nodeId = values.nodeId;
return payload; return payload;
+4
View File
@@ -18,6 +18,8 @@ export interface DbInboundLike {
up?: number; up?: number;
down?: number; down?: number;
total?: number; total?: number;
shareAddrStrategy?: string;
shareAddr?: string;
} }
function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> { function fillProtocolSettingsDefaults(protocol: string, settings: Record<string, unknown>): Record<string, unknown> {
@@ -48,6 +50,8 @@ export function inboundFromDb(raw: DbInboundLike): Inbound {
up: raw.up ?? 0, up: raw.up ?? 0,
down: raw.down ?? 0, down: raw.down ?? 0,
total: raw.total ?? 0, total: raw.total ?? 0,
shareAddrStrategy: raw.shareAddrStrategy ?? 'node',
shareAddr: raw.shareAddr ?? '',
settings, settings,
streamSettings, streamSettings,
sniffing, sniffing,
+89 -19
View File
@@ -21,6 +21,7 @@ import { getHeaderValue } from './headers';
// directly. // directly.
type ForceTls = 'same' | 'tls' | 'none'; 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])?)*$/;
// xHTTP headers ship as Record<string, string> on the wire (Zod schema) // xHTTP headers ship as Record<string, string> on the wire (Zod schema)
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key. // rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
@@ -58,9 +59,15 @@ function buildXhttpExtra(xhttp: XHttpStreamSettings | undefined): Record<string,
'uplinkDataKey', 'uplinkDataKey',
'scMaxEachPostBytes', 'scMaxEachPostBytes',
] as const; ] as const;
// Values matching xray-core's own defaults stay off the wire — old panels
// seeded them into every config and the literal values are a DPI
// fingerprint (#5141). Mirrors the sub service's filter.
const coreDefaults: Partial<Record<(typeof stringFields)[number], string>> = {
scMaxEachPostBytes: '1000000',
};
for (const k of stringFields) { for (const k of stringFields) {
const v = xhttp[k]; const v = xhttp[k];
if (typeof v === 'string' && v.length > 0) extra[k] = v; if (typeof v === 'string' && v.length > 0 && v !== coreDefaults[k]) extra[k] = v;
} }
// Headers on the wire are a record; emit them as a map upstream's // Headers on the wire are a record; emit them as a map upstream's
@@ -777,19 +784,76 @@ function isUnixSocketListen(listen: string): boolean {
return listen.startsWith('/') || listen.startsWith('@'); return listen.startsWith('/') || listen.startsWith('@');
} }
function normalizeShareHost(host: string): string {
const h = host.trim();
if (
h.length === 0
|| h.includes('://')
|| h.startsWith('//')
|| /[/?#@]/.test(h)
) {
return '';
}
if (h.startsWith('[')) {
if (!h.endsWith(']')) return '';
try {
return new URL(`http://${h}`).hostname;
} catch {
return '';
}
}
if (h.includes(':')) {
try {
return new URL(`http://[${h}]`).hostname;
} catch {
return '';
}
}
return SHARE_HOSTNAME_RE.test(h) ? h : '';
}
function isShareableHost(host: string): boolean {
const h = normalizeShareHost(host).replace(/^\[|\]$/g, '').toLowerCase();
if (h.length === 0) return false;
if (h === '0.0.0.0' || h === '::' || h === '::0') return false;
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return false;
return true;
}
function shareableListen(inbound: Inbound): string {
const listen = inbound.listen.trim();
return listen.length > 0 && !isUnixSocketListen(listen) && isShareableHost(listen)
? normalizeShareHost(listen)
: '';
}
type ShareAddrStrategy = 'node' | 'listen' | 'custom';
function shareAddrStrategy(inbound: Inbound): ShareAddrStrategy {
const strategy = inbound.shareAddrStrategy;
return strategy === 'listen' || strategy === 'custom'
? strategy
: 'node';
}
// Orchestrators. // Orchestrators.
// resolveAddr picks the host that goes into share/sub links. Order: // resolveAddr picks the host that goes into share/QR links. The default
// 1. hostOverride (caller supplies node address for node-managed inbounds) // `node` strategy keeps the previous node-address-first behavior for
// 2. inbound's bind listen (when it's an explicit reachable address — // node-managed inbounds; other strategies let a row prefer its listen address
// not 0.0.0.0 and not a unix domain socket path) // or a custom endpoint.
// 3. fallbackHostname (caller-supplied — typically window.location.hostname
// in the browser; tests pass a fixed value)
export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string { export function resolveAddr(inbound: Inbound, hostOverride: string, fallbackHostname: string): string {
if (hostOverride.length > 0) return hostOverride; const nodeAddr = normalizeShareHost(hostOverride);
if (inbound.listen.length > 0 && inbound.listen !== '0.0.0.0' && !isUnixSocketListen(inbound.listen)) { const listenAddr = shareableListen(inbound);
return inbound.listen; const customAddr = normalizeShareHost(inbound.shareAddr ?? '');
const fallbackAddr = normalizeShareHost(fallbackHostname);
switch (shareAddrStrategy(inbound)) {
case 'listen':
return listenAddr || nodeAddr || fallbackAddr;
case 'custom':
return customAddr || nodeAddr || listenAddr || fallbackAddr;
default:
return nodeAddr || listenAddr || fallbackAddr;
} }
return fallbackHostname;
} }
// A loopback browser host means the panel was reached through a tunnel (e.g. // A loopback browser host means the panel was reached through a tunnel (e.g.
@@ -801,10 +865,9 @@ function isLoopbackHost(host: string): boolean {
// preferPublicHost is the browser-side analog of the backend's // preferPublicHost is the browser-side analog of the backend's
// configuredPublicHost: when the panel is reached on a loopback host, prefer a // configuredPublicHost: when the panel is reached on a loopback host, prefer a
// configured public host (Sub/Web Domain) for share/QR links so they match the // configured public host (Sub/Web Domain) for share/QR links instead of leaking
// subscription links instead of leaking localhost. An explicit per-inbound // localhost. An explicit per-inbound listen or node override still wins, since
// listen or node override still wins, since resolveAddr only reaches the // resolveAddr only reaches the fallbackHostname after those.
// fallbackHostname after those.
export function preferPublicHost(browserHost: string, publicHost: string): string { export function preferPublicHost(browserHost: string, publicHost: string): string {
return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost; return publicHost && isLoopbackHost(browserHost) ? publicHost : browserHost;
} }
@@ -1016,11 +1079,11 @@ export function genWireguardLinks(input: GenWireguardFanoutInput): string {
const addr = resolveAddr(inbound, hostOverride, fallbackHostname); const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = remarkModel.charAt(0); const sep = remarkModel.charAt(0);
return inbound.settings.peers return inbound.settings.peers
.map((_p, i) => genWireguardLink({ .map((p, i) => genWireguardLink({
settings: inbound.settings as WireguardInboundSettings, settings: inbound.settings as WireguardInboundSettings,
address: addr, address: addr,
port: inbound.port, port: inbound.port,
remark: `${remark}${sep}${i + 1}`, remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
peerIndex: i, peerIndex: i,
})) }))
.join('\r\n'); .join('\r\n');
@@ -1032,16 +1095,23 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
const addr = resolveAddr(inbound, hostOverride, fallbackHostname); const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = remarkModel.charAt(0); const sep = remarkModel.charAt(0);
return inbound.settings.peers return inbound.settings.peers
.map((_p, i) => genWireguardConfig({ .map((p, i) => genWireguardConfig({
settings: inbound.settings as WireguardInboundSettings, settings: inbound.settings as WireguardInboundSettings,
address: addr, address: addr,
port: inbound.port, port: inbound.port,
remark: `${remark}${sep}${i + 1}`, remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
peerIndex: i, peerIndex: i,
})) }))
.join('\r\n'); .join('\r\n');
} }
// Peer comments (#5168) are panel-side annotations; when present they ride
// along in the share remark so the device is identifiable in client apps.
function wgPeerCommentSuffix(peer: unknown): string {
const comment = (peer as { comment?: unknown })?.comment;
return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
}
export function isPostQuantumLink(link: string): boolean { export function isPostQuantumLink(link: string): boolean {
if (/[?&]pqv=/.test(link)) return true; if (/[?&]pqv=/.test(link)) return true;
if (link.includes('mlkem768') || link.includes('mldsa65')) return true; if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
@@ -114,7 +114,7 @@ function buildStream(network: string, security: string): Raw {
case 'xhttp': case 'xhttp':
stream.xhttpSettings = { stream.xhttpSettings = {
path: '/', host: '', mode: 'auto', headers: {}, path: '/', host: '', mode: 'auto', headers: {},
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000', xPaddingBytes: '100-1000',
}; };
break; break;
default: default:
+38 -2
View File
@@ -41,6 +41,36 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
return isRecord(headers) && Object.keys(headers).length > 0; return isRecord(headers) && Object.keys(headers).length > 0;
} }
// Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
// 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
// mutual-exclusivity check on the `.To` (upper) side.
function int32RangeUpper(v: unknown): number {
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
if (typeof v !== 'string') return 0;
const trimmed = v.trim();
if (trimmed === '') return 0;
const parts = trimmed.split('-');
const n = Number(parts[parts.length - 1]);
return Number.isFinite(n) ? n : 0;
}
// xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
// and maxConcurrency ("maxConnections cannot be specified together with
// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
// XMUX is enabled, so any explicit maxConnections would otherwise always
// collide and make xray refuse the config. maxConnections defaults to 0
// (off), so a positive value is an explicit opt-in to connection-pool
// mode — honor it and drop the leftover default maxConcurrency, matching
// core's "one strategy at a time" semantics.
function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
const out = { ...xmux };
delete out.maxConcurrency;
return out;
}
return xmux;
}
/** Validates REALITY inbound `target` / `dest` (must include a port). */ /** Validates REALITY inbound `target` / `dest` (must include a port). */
export function validateRealityTarget(target: string): string | undefined { export function validateRealityTarget(target: string): string | undefined {
const trimmed = target.trim(); const trimmed = target.trim();
@@ -115,16 +145,22 @@ export function normalizeXhttpForWire(
): Record<string, unknown> { ): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw }; const out: Record<string, unknown> = { ...raw };
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto'; const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
const enableXmux = out.enableXmux === true;
delete out.enableXmux; delete out.enableXmux;
if (side === 'inbound') { if (side === 'inbound') {
delete out.xmux; if (!enableXmux) delete out.xmux;
delete out.scMinPostsIntervalMs; delete out.scMinPostsIntervalMs;
delete out.uplinkChunkSize; delete out.uplinkChunkSize;
} }
if (isRecord(out.xmux)) {
out.xmux = resolveXmuxExclusivity(out.xmux);
}
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS); dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
// Empty tuning fields mean "use xray-core's default" — never emit them.
dropEmptyStrings(out, ['scMaxEachPostBytes', 'scMinPostsIntervalMs', 'scStreamUpServerSecs']);
if (!hasMeaningfulHeaders(out.headers)) { if (!hasMeaningfulHeaders(out.headers)) {
delete out.headers; delete out.headers;
+9
View File
@@ -40,6 +40,9 @@ export type DBInboundInit = Partial<{
sniffing: RawJsonField; sniffing: RawJsonField;
clientStats: ClientStats[]; clientStats: ClientStats[];
nodeId: number | null; nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
originNodeGuid: string; originNodeGuid: string;
fallbackParent: FallbackParentRef | null; fallbackParent: FallbackParentRef | null;
}>; }>;
@@ -84,6 +87,9 @@ export class DBInbound {
sniffing: RawJsonField; sniffing: RawJsonField;
clientStats: ClientStats[]; clientStats: ClientStats[];
nodeId: number | null; nodeId: number | null;
shareAddrStrategy: string;
shareAddr: string;
subSortIndex: number;
originNodeGuid: string; originNodeGuid: string;
fallbackParent: FallbackParentRef | null; fallbackParent: FallbackParentRef | null;
@@ -110,6 +116,9 @@ export class DBInbound {
this.sniffing = ""; this.sniffing = "";
this.clientStats = []; this.clientStats = [];
this.nodeId = null; this.nodeId = null;
this.shareAddrStrategy = "node";
this.shareAddr = "";
this.subSortIndex = 1;
this.originNodeGuid = ""; this.originNodeGuid = "";
this.fallbackParent = null; this.fallbackParent = null;
if (data == null) { if (data == null) {
+2
View File
@@ -95,6 +95,8 @@ export class AllSetting {
if (data != null) { if (data != null) {
ObjectUtil.cloneProps(this, data); ObjectUtil.cloneProps(this, data);
} }
const cpu = Math.round(Number(this.tgCpu));
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
} }
equals(other: AllSetting): boolean { equals(other: AllSetting): boolean {
+18
View File
@@ -843,6 +843,13 @@ export const sections: readonly Section[] = [
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}', body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/"\n}',
response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}', response: '{\n "success": true,\n "obj": "k3b1...base64-sha256...="\n}',
}, },
{
method: 'POST',
path: '/panel/api/nodes/inbounds',
summary: 'Use unsaved node connection details to list the remote inbounds available for selective import.',
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": [\n { "tag": "inbound-443", "remark": "VLESS", "protocol": "vless", "port": 443 }\n ]\n}',
},
{ {
method: 'POST', method: 'POST',
path: '/panel/api/nodes/probe/:id', path: '/panel/api/nodes/probe/:id',
@@ -1056,6 +1063,17 @@ export const sections: readonly Section[] = [
], ],
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp', body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
}, },
{
method: 'POST',
path: '/panel/api/xray/testOutbounds',
summary: 'Test a batch of outbounds (max 50) through one shared temp xray instance. Returns an array of results in input order, each with the outbound tag, delay, HTTP status and a connect/TLS/TTFB timing breakdown.',
params: [
{ name: 'outbounds', in: 'body (form)', type: 'string', desc: 'JSON array of outbound configs to test (required).' },
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for fast dial-only probes (UDP-transport outbounds are still probed over HTTP). Default/empty routes a real HTTP request through each outbound.' },
],
body: 'outbounds=[{"tag":"direct","protocol":"freedom","settings":{}}]&mode=http',
},
{ {
method: 'POST', method: 'POST',
path: '/panel/api/xray/balancerStatus', path: '/panel/api/xray/balancerStatus',
@@ -66,11 +66,7 @@ export default function BulkAddToGroupModal({
placeholder={t('pages.clients.groupName')} placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))} options={groups.map((g) => ({ value: g }))}
onChange={(v) => setValue(v ?? '')} onChange={(v) => setValue(v ?? '')}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
}
allowClear allowClear
style={{ width: '100%' }}
autoFocus autoFocus
/> />
</Form.Item> </Form.Item>
@@ -288,11 +288,7 @@ export default function ClientBulkAddModal({
placeholder={t('pages.clients.groupPlaceholder')} placeholder={t('pages.clients.groupPlaceholder')}
options={groups.map((g) => ({ value: g }))} options={groups.map((g) => ({ value: g }))}
onChange={(v) => update('group', v ?? '')} onChange={(v) => update('group', v ?? '')}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
}
allowClear allowClear
style={{ width: '100%' }}
/> />
</Form.Item> </Form.Item>
+90 -45
View File
@@ -8,15 +8,17 @@ import {
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Popconfirm,
Row, Row,
Select, Select,
Space, Space,
Switch, Switch,
Tabs, Tabs,
Tag, Tag,
Tooltip,
message, message,
} from 'antd'; } from 'antd';
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'; import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils'; import { HttpUtil, RandomUtil } from '@/utils';
@@ -38,6 +40,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
interface ApiMsg<T = unknown> { interface ApiMsg<T = unknown> {
success?: boolean; success?: boolean;
msg?: string;
obj?: T; obj?: T;
} }
@@ -71,6 +74,7 @@ interface ClientFormModalProps {
payload: Record<string, unknown> | SaveCreatePayload, payload: Record<string, unknown> | SaveCreatePayload,
meta: SaveMetaEdit | SaveMetaCreate, meta: SaveMetaEdit | SaveMetaCreate,
) => Promise<ApiMsg | null>; ) => Promise<ApiMsg | null>;
resetTraffic?: (client: ClientRecord) => Promise<ApiMsg | null>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -139,6 +143,7 @@ export default function ClientFormModal({
tgBotEnable = false, tgBotEnable = false,
groups = [], groups = [],
save, save,
resetTraffic,
onOpenChange, onOpenChange,
}: ClientFormModalProps) { }: ClientFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -147,6 +152,7 @@ export default function ClientFormModal({
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [resetting, setResetting] = useState(false);
const [clientIps, setClientIps] = useState<string[]>([]); const [clientIps, setClientIps] = useState<string[]>([]);
const [ipsLoading, setIpsLoading] = useState(false); const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false); const [ipsClearing, setIpsClearing] = useState(false);
@@ -327,6 +333,21 @@ export default function ClientFormModal({
onOpenChange(false); onOpenChange(false);
} }
async function onResetTraffic() {
if (!isEdit || !client?.email || !resetTraffic) return;
setResetting(true);
try {
const msg = await resetTraffic(client);
if (msg?.success) {
messageApi.success(t('pages.clients.toasts.trafficReset'));
} else {
messageApi.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
setResetting(false);
}
}
async function onSubmit() { async function onSubmit() {
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema; const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
const validated = schema.safeParse({ const validated = schema.safeParse({
@@ -412,15 +433,35 @@ export default function ClientFormModal({
open={open} open={open}
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')} title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
destroyOnHidden destroyOnHidden
okText={isEdit ? t('save') : t('create')}
cancelText={t('cancel')}
okButtonProps={{ loading: submitting }}
width={720} width={720}
zIndex={CLIENT_FORM_MODAL_Z_INDEX} zIndex={CLIENT_FORM_MODAL_Z_INDEX}
style={{ top: 20 }} style={{ top: 20 }}
styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }} styles={{ body: { maxHeight: 'calc(100vh - 160px)', overflowY: 'auto', overflowX: 'hidden' } }}
onOk={onSubmit}
onCancel={close} onCancel={close}
footer={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isEdit && resetTraffic && (
<Popconfirm
title={t('pages.inbounds.resetTraffic')}
description={t('pages.inbounds.resetTrafficContent')}
okText={t('reset')}
cancelText={t('cancel')}
zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
onConfirm={onResetTraffic}
>
<Button color="danger" variant="filled" icon={<RetweetOutlined />} loading={resetting}>
{t('pages.inbounds.resetTraffic')}
</Button>
</Popconfirm>
)}
<div style={{ marginInlineStart: 'auto', display: 'flex', gap: 8 }}>
<Button onClick={close}>{t('cancel')}</Button>
<Button type="primary" loading={submitting} onClick={onSubmit}>
{isEdit ? t('save') : t('create')}
</Button>
</div>
</div>
}
> >
<Form layout="vertical"> <Form layout="vertical">
<Tabs <Tabs
@@ -428,7 +469,7 @@ export default function ClientFormModal({
items={[ items={[
{ {
key: 'basic', key: 'basic',
label: t('pages.clients.tabBasic'), label: t('pages.clients.tabBasics'),
children: ( children: (
<> <>
<Row gutter={16}> <Row gutter={16}>
@@ -441,20 +482,31 @@ export default function ClientFormModal({
style={{ flex: 1 }} style={{ flex: 1 }}
onChange={(e) => update('email', e.target.value)} onChange={(e) => update('email', e.target.value)}
/> />
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} /> {!isEdit && (
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
)}
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={8}> <Col xs={24} md={6}>
<Form.Item label={t('pages.clients.totalGB')}> <Form.Item label={t('pages.clients.totalGB')} tooltip={t('pages.clients.totalGBDesc')}>
<InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }} <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
onChange={(v) => update('totalGB', Number(v) || 0)} /> onChange={(v) => update('totalGB', Number(v) || 0)} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={4}> <Col xs={24} md={6}>
<Form.Item label={t('pages.clients.limitIp')}> <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
<InputNumber value={form.limitIp} min={0} style={{ width: '100%' }} <Space.Compact style={{ display: 'flex' }}>
onChange={(v) => update('limitIp', Number(v) || 0)} /> <InputNumber value={form.limitIp} min={0} style={{ flex: 1 }}
onChange={(v) => update('limitIp', Number(v) || 0)} />
{isEdit && (
<Tooltip title={t('pages.clients.ipLog')}>
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</Tooltip>
)}
</Space.Compact>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -489,7 +541,7 @@ export default function ClientFormModal({
</Col> </Col>
<Col xs={12} md={6}> <Col xs={12} md={6}>
<Form.Item <Form.Item
label={t('pages.clients.renew')} label={t('pages.clients.renewDays')}
tooltip={t('pages.clients.renewDesc')} tooltip={t('pages.clients.renewDesc')}
> >
<InputNumber value={form.reset} min={0} style={{ width: '100%' }} <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
@@ -499,16 +551,7 @@ export default function ClientFormModal({
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
{tgBotEnable && ( <Col xs={24} md={12}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.telegramId')}>
<InputNumber value={form.tgId} min={0} controls={false}
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
onChange={(v) => update('tgId', Number(v) || 0)} />
</Form.Item>
</Col>
)}
<Col xs={24} md={tgBotEnable ? 12 : 24}>
<Form.Item label={t('pages.clients.comment')}> <Form.Item label={t('pages.clients.comment')}>
<Input value={form.comment} onChange={(e) => update('comment', e.target.value)} /> <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
</Form.Item> </Form.Item>
@@ -520,16 +563,34 @@ export default function ClientFormModal({
placeholder={t('pages.clients.groupPlaceholder')} placeholder={t('pages.clients.groupPlaceholder')}
options={groups.map((g) => ({ value: g }))} options={groups.map((g) => ({ value: g }))}
onChange={(v) => update('group', v ?? '')} onChange={(v) => update('group', v ?? '')}
filterOption={(input, option) =>
String(option?.value ?? '').toLowerCase().includes((input || '').toLowerCase())
}
allowClear allowClear
style={{ width: '100%' }}
/> />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
{(tgBotEnable || showReverseTag) && (
<Row gutter={16}>
{tgBotEnable && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.telegramId')}>
<InputNumber value={form.tgId} min={0} controls={false}
placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
onChange={(v) => update('tgId', Number(v) || 0)} />
</Form.Item>
</Col>
)}
{showReverseTag && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.reverseTag')}>
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
onChange={(e) => update('reverseTag', e.target.value)} />
</Form.Item>
</Col>
)}
</Row>
)}
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}> <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
<SelectAllClearButtons <SelectAllClearButtons
options={inboundOptions} options={inboundOptions}
@@ -555,20 +616,12 @@ export default function ClientFormModal({
<Switch checked={form.enable} onChange={(v) => update('enable', v)} /> <Switch checked={form.enable} onChange={(v) => update('enable', v)} />
<span style={{ marginLeft: 8 }}>{t('enable')}</span> <span style={{ marginLeft: 8 }}>{t('enable')}</span>
</Form.Item> </Form.Item>
{isEdit && (
<Form.Item label={t('pages.clients.ipLog')}>
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</Form.Item>
)}
</> </>
), ),
}, },
{ {
key: 'config', key: 'config',
label: t('pages.clients.tabConfig'), label: t('pages.clients.tabCredentials'),
children: ( children: (
<> <>
<Row gutter={16}> <Row gutter={16}>
@@ -635,14 +688,6 @@ export default function ClientFormModal({
</Form.Item> </Form.Item>
</Col> </Col>
)} )}
{showReverseTag && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.reverseTag')}>
<Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
onChange={(e) => update('reverseTag', e.target.value)} />
</Form.Item>
</Col>
)}
</Row> </Row>
</> </>
), ),
+77 -50
View File
@@ -51,6 +51,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket'; import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients'; import { useClients } from '@/hooks/useClients';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useDatepicker } from '@/hooks/useDatepicker'; import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients'; import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import ClientTrafficCell from '@/components/clients/ClientTrafficCell'; import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
@@ -148,6 +149,7 @@ function readFilterState(): PersistedFilterState {
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [], buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [], protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [], inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
nodeIds: Array.isArray(fromRaw.nodeIds) ? fromRaw.nodeIds : [],
groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [], groups: Array.isArray(fromRaw.groups) ? fromRaw.groups : [],
}, },
sort: typeof raw.sort === 'string' ? raw.sort : '', sort: typeof raw.sort === 'string' ? raw.sort : '',
@@ -163,15 +165,15 @@ function gbToBytes(gb: number | undefined): number {
} }
const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [ const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
{ value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' }, { value: 'createdAt:ascend', column: 'createdAt', order: 'ascend', labelKey: 'pages.clients.sortOldest' },
{ value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' }, { value: 'createdAt:descend', column: 'createdAt', order: 'descend', labelKey: 'pages.clients.sortNewest' },
{ value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' }, { value: 'updatedAt:descend', column: 'updatedAt', order: 'descend', labelKey: 'pages.clients.sortRecentlyUpdated' },
{ value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' }, { value: 'lastOnline:descend', column: 'lastOnline', order: 'descend', labelKey: 'pages.clients.sortRecentlyOnline' },
{ value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' }, { value: 'email:ascend', column: 'email', order: 'ascend', labelKey: 'pages.clients.sortEmailAZ' },
{ value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' }, { value: 'email:descend', column: 'email', order: 'descend', labelKey: 'pages.clients.sortEmailZA' },
{ value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' }, { value: 'traffic:descend', column: 'traffic', order: 'descend', labelKey: 'pages.clients.sortMostTraffic' },
{ value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' }, { value: 'remaining:descend', column: 'remaining', order: 'descend', labelKey: 'pages.clients.sortHighestRemaining' },
{ value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' }, { value: 'expiryTime:ascend', column: 'expiryTime', order: 'ascend', labelKey: 'pages.clients.sortExpiringSoonest' },
]; ];
const DEFAULT_SORT = SORT_OPTIONS[0]; const DEFAULT_SORT = SORT_OPTIONS[0];
@@ -209,6 +211,10 @@ export default function ClientsPage() {
client_stats: applyClientStatsEvent, client_stats: applyClientStatsEvent,
}); });
// Node list for the Nodes filter; the section only renders when the panel
// actually manages nodes (#4997).
const { nodes } = useNodesQuery();
const [togglingEmail, setTogglingEmail] = useState<string | null>(null); const [togglingEmail, setTogglingEmail] = useState<string | null>(null);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
@@ -255,6 +261,23 @@ export default function ClientsPage() {
setCurrentPage(1); setCurrentPage(1);
}, [debouncedSearch, filters, sortColumn, sortOrder]); }, [debouncedSearch, filters, sortColumn, sortOrder]);
// The node filter maps onto inbound ids client-side (#4997): the paging API
// already accepts an inbound CSV, so nodes never have to reach the backend.
// Sentinel 0 = "local panel" (inbounds without a nodeId).
const effectiveInboundCsv = useMemo(() => {
if (!filters.nodeIds.length) return filters.inboundIds.join(',');
const nodeSet = new Set(filters.nodeIds);
const nodeInboundIds = inbounds
.filter((ib) => nodeSet.has(ib.nodeId ?? 0))
.map((ib) => ib.id);
const pool = filters.inboundIds.length
? nodeInboundIds.filter((id) => filters.inboundIds.includes(id))
: nodeInboundIds;
// Nothing matches the selected nodes: send an impossible id so the filter
// yields an honest empty result instead of being silently ignored.
return pool.length ? pool.join(',') : '-1';
}, [filters.nodeIds, filters.inboundIds, inbounds]);
useEffect(() => { useEffect(() => {
setQuery({ setQuery({
page: currentPage, page: currentPage,
@@ -262,7 +285,7 @@ export default function ClientsPage() {
search: debouncedSearch, search: debouncedSearch,
filter: filters.buckets.join(','), filter: filters.buckets.join(','),
protocol: filters.protocols.join(','), protocol: filters.protocols.join(','),
inbound: filters.inboundIds.join(','), inbound: effectiveInboundCsv,
expiryFrom: filters.expiryFrom, expiryFrom: filters.expiryFrom,
expiryTo: filters.expiryTo, expiryTo: filters.expiryTo,
usageFrom: gbToBytes(filters.usageFromGB), usageFrom: gbToBytes(filters.usageFromGB),
@@ -274,7 +297,7 @@ export default function ClientsPage() {
sort: sortColumn || undefined, sort: sortColumn || undefined,
order: sortOrder || undefined, order: sortOrder || undefined,
}); });
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]); }, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, effectiveInboundCsv, sortColumn, sortOrder]);
const activeCount = activeFilterCount(filters); const activeCount = activeFilterCount(filters);
@@ -582,19 +605,19 @@ export default function ClientsPage() {
render: (_v, record) => ( render: (_v, record) => (
<Space size={4}> <Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} /> <Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip> </Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}> <Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} /> <Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip> </Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}> <Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} /> <Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
</Tooltip> </Tooltip>
<Tooltip title={t('edit')}> <Tooltip title={t('edit')}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} /> <Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip> </Tooltip>
<Tooltip title={t('delete')}> <Tooltip title={t('delete')}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} /> <Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip> </Tooltip>
</Space> </Space>
), ),
@@ -640,6 +663,7 @@ export default function ClientsPage() {
{ {
title: t('pages.clients.client'), title: t('pages.clients.client'),
key: 'email', key: 'email',
width: 220,
render: (_v, record) => ( render: (_v, record) => (
<div className="email-cell"> <div className="email-cell">
<span className="email">{record.email}</span> <span className="email">{record.email}</span>
@@ -719,7 +743,7 @@ export default function ClientsPage() {
{ {
title: t('pages.clients.traffic'), title: t('pages.clients.traffic'),
key: 'traffic', key: 'traffic',
width: 240, width: 300,
render: (_v, record) => ( render: (_v, record) => (
<ClientTrafficCell <ClientTrafficCell
up={record.traffic?.up} up={record.traffic?.up}
@@ -739,6 +763,7 @@ export default function ClientsPage() {
{ {
title: t('pages.clients.duration'), title: t('pages.clients.duration'),
key: 'expiryTime', key: 'expiryTime',
width: 130,
render: (_v, record) => ( render: (_v, record) => (
<Tooltip title={expiryLabel(record)}> <Tooltip title={expiryLabel(record)}>
<Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag> <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
@@ -900,40 +925,40 @@ export default function ClientsPage() {
menu={{ menu={{
items: selectedRowKeys.length > 0 items: selectedRowKeys.length > 0
? [ ? [
{ {
key: 'adjust', key: 'adjust',
icon: <ClockCircleOutlined />, icon: <ClockCircleOutlined />,
label: t('pages.clients.adjust'), label: t('pages.clients.adjust'),
onClick: () => setBulkAdjustOpen(true), onClick: () => setBulkAdjustOpen(true),
}, },
{ {
key: 'subLinks', key: 'subLinks',
icon: <LinkOutlined />, icon: <LinkOutlined />,
label: t('pages.clients.subLinks'), label: t('pages.clients.subLinks'),
onClick: () => setSubLinksOpen(true), onClick: () => setSubLinksOpen(true),
}, },
] ]
: [ : [
{ {
key: 'bulk', key: 'bulk',
icon: <UsergroupAddOutlined />, icon: <UsergroupAddOutlined />,
label: t('pages.clients.bulk'), label: t('pages.clients.bulk'),
onClick: () => setBulkAddOpen(true), onClick: () => setBulkAddOpen(true),
}, },
{ {
key: 'resetAll', key: 'resetAll',
icon: <RetweetOutlined />, icon: <RetweetOutlined />,
label: t('pages.clients.resetAllTraffics'), label: t('pages.clients.resetAllTraffics'),
onClick: onResetAllTraffics, onClick: onResetAllTraffics,
}, },
{ {
key: 'delDepleted', key: 'delDepleted',
icon: <RestOutlined />, icon: <RestOutlined />,
label: t('pages.clients.delDepleted'), label: t('pages.clients.delDepleted'),
danger: true, danger: true,
onClick: onDelDepleted, onClick: onDelDepleted,
}, },
], ],
}} }}
> >
<Button icon={<MoreOutlined />}> <Button icon={<MoreOutlined />}>
@@ -1222,6 +1247,7 @@ export default function ClientsPage() {
tgBotEnable={tgBotEnable} tgBotEnable={tgBotEnable}
groups={allGroups} groups={allGroups}
save={onSave} save={onSave}
resetTraffic={resetTraffic}
onOpenChange={setFormOpen} onOpenChange={setFormOpen}
/> />
</LazyMount> </LazyMount>
@@ -1333,6 +1359,7 @@ export default function ClientsPage() {
inbounds={inbounds} inbounds={inbounds}
protocols={protocolOptions} protocols={protocolOptions}
groups={groupOptions} groups={groupOptions}
nodes={nodes}
/> />
</LazyMount> </LazyMount>
</Layout> </Layout>
@@ -18,6 +18,7 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { InboundOption } from '@/hooks/useClients'; import type { InboundOption } from '@/hooks/useClients';
import type { NodeRecord } from '@/schemas/node';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
import { emptyFilters, type ClientFilters } from './filters'; import { emptyFilters, type ClientFilters } from './filters';
@@ -29,6 +30,7 @@ interface FilterDrawerProps {
inbounds: InboundOption[]; inbounds: InboundOption[];
protocols: string[]; protocols: string[];
groups: string[]; groups: string[];
nodes: NodeRecord[];
} }
const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const; const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
@@ -41,6 +43,7 @@ export default function FilterDrawer({
inbounds, inbounds,
protocols, protocols,
groups, groups,
nodes,
}: FilterDrawerProps) { }: FilterDrawerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -66,6 +69,16 @@ export default function FilterDrawer({
[groups], [groups],
); );
// 0 is the "local panel" sentinel (inbounds without a nodeId) — see
// ClientFilters.nodeIds (#4997).
const nodeOptions = useMemo(
() => [
{ value: 0, label: t('pages.clients.filters.localPanel') },
...nodes.map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
],
[nodes, t],
);
const dateRange: [Dayjs | null, Dayjs | null] = [ const dateRange: [Dayjs | null, Dayjs | null] = [
filters.expiryFrom ? dayjs(filters.expiryFrom) : null, filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
filters.expiryTo ? dayjs(filters.expiryTo) : null, filters.expiryTo ? dayjs(filters.expiryTo) : null,
@@ -132,6 +145,23 @@ export default function FilterDrawer({
/> />
</Form.Item> </Form.Item>
{nodes.length > 0 && (
<Form.Item label={t('pages.clients.filters.nodes')}>
<Select
mode="multiple"
value={filters.nodeIds}
onChange={(v) => patch('nodeIds', v as number[])}
options={nodeOptions}
placeholder={t('pages.clients.filters.nodes')}
maxTagCount="responsive"
allowClear
showSearch
optionFilterProp="label"
listHeight={220}
/>
</Form.Item>
)}
<Form.Item label={t('pages.clients.group')}> <Form.Item label={t('pages.clients.group')}>
<Select <Select
mode="multiple" mode="multiple"
+5
View File
@@ -2,6 +2,9 @@ export interface ClientFilters {
buckets: string[]; buckets: string[];
protocols: string[]; protocols: string[];
inboundIds: number[]; inboundIds: number[];
// Node ids to filter by; 0 is the "local panel" sentinel (inbounds with
// no nodeId). Mapped onto inbound ids client-side — see ClientsPage.
nodeIds: number[];
groups: string[]; groups: string[];
expiryFrom?: number; expiryFrom?: number;
expiryTo?: number; expiryTo?: number;
@@ -17,6 +20,7 @@ export function emptyFilters(): ClientFilters {
buckets: [], buckets: [],
protocols: [], protocols: [],
inboundIds: [], inboundIds: [],
nodeIds: [],
groups: [], groups: [],
autoRenew: '', autoRenew: '',
hasTgId: '', hasTgId: '',
@@ -29,6 +33,7 @@ export function activeFilterCount(f: ClientFilters): number {
if (f.buckets.length) n++; if (f.buckets.length) n++;
if (f.protocols.length) n++; if (f.protocols.length) n++;
if (f.inboundIds.length) n++; if (f.inboundIds.length) n++;
if (f.nodeIds.length) n++;
if (f.groups.length) n++; if (f.groups.length) n++;
if (f.expiryFrom || f.expiryTo) n++; if (f.expiryFrom || f.expiryTo) n++;
if (f.usageFromGB || f.usageToGB) n++; if (f.usageFromGB || f.usageToGB) n++;
+44 -6
View File
@@ -22,11 +22,14 @@ import {
} from 'antd'; } from 'antd';
import type { MenuProps, TableColumnsType } from 'antd'; import type { MenuProps, TableColumnsType } from 'antd';
import { import {
ArrowDownOutlined,
ArrowUpOutlined,
ClockCircleOutlined, ClockCircleOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
LinkOutlined, LinkOutlined,
MoreOutlined, MoreOutlined,
PieChartOutlined,
PlusOutlined, PlusOutlined,
RetweetOutlined, RetweetOutlined,
TagsOutlined, TagsOutlined,
@@ -165,6 +168,14 @@ export default function GroupsPage() {
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0), () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups], [groups],
); );
const totalUpload = useMemo(
() => groups.reduce((acc, g) => acc + (g.up || 0), 0),
[groups],
);
const totalDownload = useMemo(
() => groups.reduce((acc, g) => acc + (g.down || 0), 0),
[groups],
);
function openCreate() { function openCreate() {
setCreateName(''); setCreateName('');
@@ -396,10 +407,10 @@ export default function GroupsPage() {
render: (_v, row) => ( render: (_v, row) => (
<Space size={4}> <Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}> <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button size="small" type="text" icon={<MoreOutlined />} /> <Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
</Dropdown> </Dropdown>
<Tooltip title={t('pages.groups.rename')}> <Tooltip title={t('pages.groups.rename')}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} /> <Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
</Tooltip> </Tooltip>
</Space> </Space>
), ),
@@ -417,6 +428,20 @@ export default function GroupsPage() {
width: 180, width: 180,
render: (count: number) => <span>{count || 0}</span>, render: (count: number) => <span>{count || 0}</span>,
}, },
{
title: t('pages.groups.upload'),
dataIndex: 'up',
key: 'up',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.download'),
dataIndex: 'down',
key: 'down',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{ {
title: t('pages.groups.trafficUsed'), title: t('pages.groups.trafficUsed'),
dataIndex: 'trafficUsed', dataIndex: 'trafficUsed',
@@ -456,25 +481,38 @@ export default function GroupsPage() {
<Col span={24}> <Col span={24}>
<Card size="small" hoverable className="summary-card"> <Card size="small" hoverable className="summary-card">
<Row gutter={[16, isMobile ? 16 : 12]}> <Row gutter={[16, isMobile ? 16 : 12]}>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={12} md={6}>
<Statistic <Statistic
title={t('pages.groups.totalGroups')} title={t('pages.groups.totalGroups')}
value={String(totalGroups)} value={String(totalGroups)}
prefix={<TagsOutlined />} prefix={<TagsOutlined />}
/> />
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={12} md={6}>
<Statistic <Statistic
title={t('pages.groups.totalGroupedClients')} title={t('pages.groups.totalGroupedClients')}
value={String(totalClients)} value={String(totalClients)}
prefix={<TeamOutlined />} prefix={<TeamOutlined />}
/> />
</Col> </Col>
<Col xs={12} sm={8} md={6}> <Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalUpDown')}
value={0}
formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
</span>
)}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic <Statistic
title={t('pages.groups.totalTraffic')} title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)} value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<RetweetOutlined />} prefix={<PieChartOutlined />}
/> />
</Col> </Col>
</Row> </Row>
+22 -5
View File
@@ -16,7 +16,8 @@ import {
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import { import {
SwapOutlined, ArrowUpOutlined,
ArrowDownOutlined,
PieChartOutlined, PieChartOutlined,
BarsOutlined, BarsOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -146,12 +147,14 @@ export default function InboundsPage() {
const [textTitle, setTextTitle] = useState(''); const [textTitle, setTextTitle] = useState('');
const [textContent, setTextContent] = useState(''); const [textContent, setTextContent] = useState('');
const [textFileName, setTextFileName] = useState(''); const [textFileName, setTextFileName] = useState('');
const [textJson, setTextJson] = useState(false);
const [promptOpen, setPromptOpen] = useState(false); const [promptOpen, setPromptOpen] = useState(false);
const [promptTitle, setPromptTitle] = useState(''); const [promptTitle, setPromptTitle] = useState('');
const [promptOkText, setPromptOkText] = useState('OK'); const [promptOkText, setPromptOkText] = useState('OK');
const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea'); const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
const [promptInitial, setPromptInitial] = useState(''); const [promptInitial, setPromptInitial] = useState('');
const [promptJson, setPromptJson] = useState(false);
const [promptLoading, setPromptLoading] = useState(false); const [promptLoading, setPromptLoading] = useState(false);
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null); const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
@@ -163,10 +166,11 @@ export default function InboundsPage() {
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]); const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]); const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => { const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean }) => {
setTextTitle(opts.title); setTextTitle(opts.title);
setTextContent(opts.content); setTextContent(opts.content);
setTextFileName(opts.fileName || ''); setTextFileName(opts.fileName || '');
setTextJson(opts.json || false);
setTextOpen(true); setTextOpen(true);
}, []); }, []);
@@ -175,12 +179,14 @@ export default function InboundsPage() {
okText?: string; okText?: string;
type?: 'textarea' | 'input'; type?: 'textarea' | 'input';
value?: string; value?: string;
json?: boolean;
confirm: (value: string) => Promise<boolean | void> | boolean | void; confirm: (value: string) => Promise<boolean | void> | boolean | void;
}) => { }) => {
setPromptTitle(opts.title); setPromptTitle(opts.title);
setPromptOkText(opts.okText || t('confirm')); setPromptOkText(opts.okText || t('confirm'));
setPromptType(opts.type || 'textarea'); setPromptType(opts.type || 'textarea');
setPromptInitial(opts.value || ''); setPromptInitial(opts.value || '');
setPromptJson(opts.json || false);
setPromptHandler(() => opts.confirm); setPromptHandler(() => opts.confirm);
setPromptOpen(true); setPromptOpen(true);
}, [t]); }, [t]);
@@ -267,7 +273,7 @@ export default function InboundsPage() {
}, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]); }, [checkFallback, remarkModel, hostOverrideFor, subSettings.publicHost, openText, t]);
const exportInboundClipboard = useCallback((dbInbound: DBInbound) => { const exportInboundClipboard = useCallback((dbInbound: DBInbound) => {
openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2) }); openText({ title: t('pages.inbounds.inboundJsonTitle'), content: JSON.stringify(dbInbound, null, 2), json: true });
}, [openText, t]); }, [openText, t]);
const exportInboundSubs = useCallback((dbInbound: DBInbound) => { const exportInboundSubs = useCallback((dbInbound: DBInbound) => {
@@ -327,6 +333,7 @@ export default function InboundsPage() {
okText: t('pages.inbounds.import'), okText: t('pages.inbounds.import'),
type: 'textarea', type: 'textarea',
value: '', value: '',
json: true,
confirm: async (value) => { confirm: async (value) => {
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value }); const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
if (msg?.success) { if (msg?.success) {
@@ -457,6 +464,8 @@ export default function InboundsPage() {
settings: clonedSettings, settings: clonedSettings,
streamSettings: streamSettingsString, streamSettings: streamSettingsString,
sniffing: sniffingString, sniffing: sniffingString,
shareAddrStrategy: dbInbound.shareAddrStrategy,
shareAddr: dbInbound.shareAddr,
}; };
const msg = await HttpUtil.post('/panel/api/inbounds/add', data); const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg?.success) await refresh(); if (msg?.success) await refresh();
@@ -577,8 +586,14 @@ export default function InboundsPage() {
<Col xs={12} sm={12} md={8}> <Col xs={12} sm={12} md={8}>
<Statistic <Statistic
title={t('pages.inbounds.totalDownUp')} title={t('pages.inbounds.totalDownUp')}
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`} value={0}
prefix={<SwapOutlined />} formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totals.up)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totals.down)}
</span>
)}
/> />
</Col> </Col>
<Col xs={12} sm={12} md={8}> <Col xs={12} sm={12} md={8}>
@@ -703,6 +718,7 @@ export default function InboundsPage() {
title={textTitle} title={textTitle}
content={textContent} content={textContent}
fileName={textFileName} fileName={textFileName}
json={textJson}
/> />
</LazyMount> </LazyMount>
<LazyMount when={promptOpen}> <LazyMount when={promptOpen}>
@@ -714,6 +730,7 @@ export default function InboundsPage() {
type={promptType} type={promptType}
initialValue={promptInitial} initialValue={promptInitial}
loading={promptLoading} loading={promptLoading}
json={promptJson}
onConfirm={onPromptConfirm} onConfirm={onPromptConfirm}
/> />
</LazyMount> </LazyMount>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
Alert,
Form, Form,
Input, Input,
InputNumber, InputNumber,
@@ -84,6 +85,8 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_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])?)*$/;
const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([ const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
Protocols.VLESS, Protocols.VLESS,
Protocols.VMESS, Protocols.VMESS,
@@ -93,6 +96,30 @@ const NODE_ELIGIBLE_PROTOCOLS = new Set<string>([
Protocols.WIREGUARD, Protocols.WIREGUARD,
]); ]);
function isValidShareAddrInput(value: string): boolean {
const v = value.trim();
if (v.length === 0) return true;
if (v.includes('://') || v.startsWith('//') || /[/?#@]/.test(v)) return false;
if (v.startsWith('[')) {
if (!v.endsWith(']')) return false;
try {
new URL(`http://${v}`);
return true;
} catch {
return false;
}
}
if (v.includes(':')) {
try {
new URL(`http://[${v}]`);
return true;
} catch {
return false;
}
}
return SHARE_ADDR_HOSTNAME_RE.test(v);
}
interface InboundFormModalProps { interface InboundFormModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -150,6 +177,10 @@ export default function InboundFormModal({
const selectableNodes = (availableNodes || []).filter((n) => n.enable); const selectableNodes = (availableNodes || []).filter((n) => n.enable);
const protocol = (Form.useWatch('protocol', form) ?? '') as string; const protocol = (Form.useWatch('protocol', form) ?? '') as string;
const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol); const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
// The `node` share-address strategy only means something when the inbound can
// actually live on a node — otherwise the node address it would resolve to is
// always empty. Offer it only then; `listen`/`custom` work for local inbounds.
const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false; const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false;
const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? ''; const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
const ssMethod = Form.useWatch(['settings', 'method'], form); const ssMethod = Form.useWatch(['settings', 'method'], form);
@@ -174,8 +205,9 @@ export default function InboundFormModal({
const wPort = Form.useWatch('port', form); const wPort = Form.useWatch('port', form);
const wListen = (Form.useWatch('listen', form) ?? '') as string; const wListen = (Form.useWatch('listen', form) ?? '') as string;
const isUdsListen = wListen.startsWith('/'); const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
const wNodeId = Form.useWatch('nodeId', form) ?? null; const wNodeId = Form.useWatch('nodeId', form) ?? null;
const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node';
const wTag = Form.useWatch('tag', form) ?? ''; const wTag = Form.useWatch('tag', form) ?? '';
const wSsNetwork = Form.useWatch(['settings', 'network'], form); const wSsNetwork = Form.useWatch(['settings', 'network'], form);
const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form); const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
@@ -343,6 +375,19 @@ export default function InboundFormModal({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]); }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
// Keep the strategy value inside the visible option set: when `node` isn't
// offered (no node, or a protocol that can't deploy to one) fall back to
// `listen`, which yields the same link for a local inbound. Mirrors how the
// protocol reset drops a nodeId that no longer applies.
useEffect(() => {
if (!open) return;
const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
form.setFieldValue('shareAddrStrategy', 'listen');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, nodeShareOptionAvailable, shareAddrStrategy]);
// Why: protocol picker reset cascades through the form — clearing the // Why: protocol picker reset cascades through the form — clearing the
// settings DU branch and dropping a nodeId that no longer applies. The // settings DU branch and dropping a nodeId that no longer applies. The
// legacy modal did this imperatively in onProtocolChange; here we hook // legacy modal did this imperatively in onProtocolChange; here we hook
@@ -499,6 +544,46 @@ export default function InboundFormModal({
<Input placeholder={t('pages.inbounds.monitorDesc')} /> <Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item> </Form.Item>
<Form.Item
name="shareAddrStrategy"
label={t('pages.inbounds.form.shareAddrStrategy')}
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
>
<Select
options={SHARE_ADDR_STRATEGIES
.filter((strategy) => strategy !== 'node' || nodeShareOptionAvailable)
.map((strategy) => ({
value: strategy,
label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
}))}
/>
</Form.Item>
{shareAddrStrategy === 'custom' && (
<Form.Item
name="shareAddr"
label={t('pages.inbounds.form.shareAddr')}
extra={t('pages.inbounds.form.shareAddrHelp')}
rules={[{
validator: (_, value) => (
isValidShareAddrInput(String(value ?? ''))
? Promise.resolve()
: Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
),
}]}
>
<Input placeholder="edge.example.com" />
</Form.Item>
)}
<Form.Item
name="subSortIndex"
label={t('pages.inbounds.form.subSortIndex')}
extra={t('pages.inbounds.form.subSortIndexHelp')}
>
<InputNumber min={1} />
</Form.Item>
<Form.Item <Form.Item
name="port" name="port"
label={t('pages.inbounds.port')} label={t('pages.inbounds.port')}
@@ -605,6 +690,15 @@ export default function InboundFormModal({
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />} {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
{isFallbackHost && fallbacksCard} {isFallbackHost && fallbacksCard}
{(protocol === Protocols.VLESS || protocol === Protocols.TROJAN)
&& network === 'tcp' && !isFallbackHost && (
<Alert
className="mt-12"
type="info"
showIcon
message={t('pages.inbounds.fallbacks.needsTls')}
/>
)}
</> </>
); );
@@ -656,6 +750,12 @@ export default function InboundFormModal({
udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }], udp: [...udp, { type: 'mkcp-legacy', settings: { header: '', value: '' } }],
}; };
} }
} else {
const fm = cleaned.finalmask as Record<string, unknown> | undefined;
if (fm && Array.isArray(fm.udp)) {
const udp = (fm.udp as unknown[]).filter((m) => (m as { type?: string })?.type !== 'mkcp-legacy');
cleaned.finalmask = { ...fm, udp };
}
} }
form.setFieldValue('streamSettings', cleaned); form.setFieldValue('streamSettings', cleaned);
}; };
@@ -19,7 +19,7 @@ export default function HysteriaFields({ form }: { form: FormInstance }) {
label={t('pages.inbounds.form.udpIdleTimeout')} label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']} name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
> >
<InputNumber min={1} style={{ width: '100%' }} /> <InputNumber min={2} max={600} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.form.masquerade')}> <Form.Item label={t('pages.inbounds.form.masquerade')}>
@@ -3,10 +3,13 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults'; import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
import { useOutboundTags } from '@/api/queries/useOutboundTags';
export default function MtprotoFields() { export default function MtprotoFields() {
const { t } = useTranslation(); const { t } = useTranslation();
const form = Form.useFormInstance(); const form = Form.useFormInstance();
const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
const { data: outboundTags } = useOutboundTags();
return ( return (
<> <>
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}> <Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
@@ -71,6 +74,28 @@ export default function MtprotoFields() {
<Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked"> <Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
name={['settings', 'routeThroughXray']}
label={t('pages.inbounds.form.mtgRouteThroughXray')}
tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
valuePropName="checked"
>
<Switch />
</Form.Item>
{routeThroughXray && (
<Form.Item
name={['settings', 'outboundTag']}
label={t('pages.inbounds.form.mtgRouteOutbound')}
tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
>
<Select
allowClear
showSearch
placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
/>
</Form.Item>
)}
</> </>
); );
} }
@@ -102,6 +102,12 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
<Divider titlePlacement="center"> <Divider titlePlacement="center">
<Space> <Space>
<span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span> <span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
return comment ? <span style={{ opacity: 0.65 }}>— {comment}</span> : null;
}}
</Form.Item>
{fields.length > 1 && ( {fields.length > 1 && (
<Button <Button
size="small" size="small"
@@ -112,6 +118,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
)} )}
</Space> </Space>
</Divider> </Divider>
<Form.Item name={[field.name, 'comment']} label={t('comment')}>
<Input placeholder="e.g. Alice's laptop" />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.secretKey')}> <Form.Item label={t('pages.xray.wireguard.secretKey')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={[field.name, 'privateKey']} noStyle> <Form.Item name={[field.name, 'privateKey']} noStyle>
@@ -3,6 +3,9 @@ import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'ant
import { HeaderMapEditor } from '@/components/form'; import { HeaderMapEditor } from '@/components/form';
import type { InboundFormValues } from '@/schemas/forms/inbound-form'; import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) { export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -11,6 +14,15 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form); const xhttpSessionPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionPlacement'], form);
const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form); const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form); const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
function onXmuxToggle(checked: boolean) {
if (!checked) return;
const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
}
return ( return (
<> <>
<Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}> <Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
@@ -213,6 +225,65 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
{/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs
when off. */}
<Form.Item
label="XMUX"
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
valuePropName="checked"
>
<Switch onChange={onXmuxToggle} />
</Form.Item>
<Form.Item shouldUpdate noStyle>
{() => {
if (!form.getFieldValue([
'streamSettings', 'xhttpSettings', 'enableXmux',
])) return null;
return (
<>
<Form.Item
label={t('pages.xray.outboundForm.maxConcurrency')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
>
<Input placeholder="16-32" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxConnections')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
>
<Input placeholder="0" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxReuseTimes')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
>
<Input />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxRequestTimes')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
>
<Input placeholder="600-900" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.maxReusableSecs')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
>
<Input placeholder="1800-3000" />
</Form.Item>
<Form.Item
label={t('pages.xray.outboundForm.keepAlivePeriod')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</>
);
}}
</Form.Item>
</> </>
); );
} }
@@ -5,6 +5,7 @@ import {
Card, Card,
Checkbox, Checkbox,
Dropdown, Dropdown,
Select,
Space, Space,
Switch, Switch,
Table, Table,
@@ -50,6 +51,29 @@ export default function InboundList({
const { t } = useTranslation(); const { t } = useTranslation();
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null); const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
// Node filter (#4997): 'all' shows everything, 0 is the local-panel
// sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
const showNodeFilter = useMemo(
() => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
[nodesById, dbInbounds],
);
const nodeFilterOptions = useMemo(
() => [
{ value: 'all' as const, label: t('pages.clients.filters.nodes') },
{ value: 0, label: t('pages.clients.filters.localPanel') },
...Array.from(nodesById.values()).map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
],
[nodesById, t],
);
const visibleInbounds = useMemo(() => {
if (nodeFilter === 'all') return dbInbounds;
if (nodeFilter === 0) return dbInbounds.filter((ib) => ib.nodeId == null);
return dbInbounds.filter((ib) => ib.nodeId === nodeFilter);
}, [dbInbounds, nodeFilter]);
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => { const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
const previous = dbInbound.enable; const previous = dbInbound.enable;
@@ -69,6 +93,11 @@ export default function InboundList({
[dbInbounds], [dbInbounds],
); );
const hasAnySubSortIndex = useMemo(
() => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
[dbInbounds],
);
const toggleSelect = useCallback((id: number, checked: boolean) => { const toggleSelect = useCallback((id: number, checked: boolean) => {
setSelectedRowKeys((prev) => { setSelectedRowKeys((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -78,11 +107,11 @@ export default function InboundList({
}, []); }, []);
const selectAll = useCallback((checked: boolean) => { const selectAll = useCallback((checked: boolean) => {
setSelectedRowKeys(checked ? dbInbounds.map((i) => i.id) : []); setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []);
}, [dbInbounds]); }, [visibleInbounds]);
const allSelected = dbInbounds.length > 0 && selectedRowKeys.length === dbInbounds.length; const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length;
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < dbInbounds.length; const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length;
const handleBulkDelete = useCallback(async () => { const handleBulkDelete = useCallback(async () => {
const ok = await onBulkDelete(selectedRowKeys); const ok = await onBulkDelete(selectedRowKeys);
@@ -91,6 +120,7 @@ export default function InboundList({
const columns = useInboundColumns({ const columns = useInboundColumns({
hasAnyRemark, hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
clientCount, clientCount,
@@ -131,6 +161,15 @@ export default function InboundList({
{!isMobile && t('pages.inbounds.generalActions')} {!isMobile && t('pages.inbounds.generalActions')}
</Button> </Button>
</Dropdown> </Dropdown>
{showNodeFilter && (
<Select
value={nodeFilter}
onChange={(v) => setNodeFilter(v)}
options={nodeFilterOptions}
popupMatchSelectWidth={false}
style={{ minWidth: isMobile ? 90 : 140 }}
/>
)}
{selectedRowKeys.length > 0 && ( {selectedRowKeys.length > 0 && (
<> <>
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}> <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
@@ -147,7 +186,7 @@ export default function InboundList({
<Space orientation="vertical" style={{ width: '100%' }}> <Space orientation="vertical" style={{ width: '100%' }}>
{isMobile ? ( {isMobile ? (
<div className="inbound-cards"> <div className="inbound-cards">
{dbInbounds.length === 0 ? ( {visibleInbounds.length === 0 ? (
<div className="card-empty"> <div className="card-empty">
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} /> <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div> <div>{t('noData')}</div>
@@ -166,7 +205,7 @@ export default function InboundList({
<span className="bulk-count">{selectedRowKeys.length}</span> <span className="bulk-count">{selectedRowKeys.length}</span>
)} )}
</div> </div>
{dbInbounds.map((record) => ( {visibleInbounds.map((record) => (
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}> <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
<div className="card-head"> <div className="card-head">
<Checkbox <Checkbox
@@ -204,13 +243,13 @@ export default function InboundList({
) : ( ) : (
<Table <Table
columns={columns} columns={columns}
dataSource={dbInbounds} dataSource={visibleInbounds}
rowKey={(r) => r.id} rowKey={(r) => r.id}
rowSelection={{ rowSelection={{
selectedRowKeys, selectedRowKeys,
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]), onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
}} }}
pagination={paginationFor(dbInbounds)} pagination={paginationFor(visibleInbounds)}
scroll={{ x: 1000 }} scroll={{ x: 1000 }}
style={{ marginTop: 10 }} style={{ marginTop: 10 }}
size="small" size="small"
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="action-buttons"> <div className="action-buttons">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} /> <Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown <Dropdown
trigger={['click']} trigger={['click']}
menu={{ menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction), onClick: ({ key }) => onClick(key as RowAction),
}} }}
> >
<Button type="text" size="small" icon={<MoreOutlined />} /> <Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
</Dropdown> </Dropdown>
</div> </div>
); );
@@ -22,6 +22,7 @@ export interface DBInboundRecord extends ProtocolFlags {
id: number; id: number;
enable: boolean; enable: boolean;
remark: string; remark: string;
subSortIndex: number;
port: number; port: number;
protocol: string; protocol: string;
up: number; up: number;
@@ -1,6 +1,6 @@
import { useMemo, type ReactElement } from 'react'; import { useMemo, type ReactElement } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Popover, Switch, Tag, type TableColumnType } from 'antd'; import { Popover, Switch, Tag, Tooltip, type TableColumnType } from 'antd';
import { TeamOutlined } from '@ant-design/icons'; import { TeamOutlined } from '@ant-design/icons';
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils'; import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
@@ -21,6 +21,7 @@ import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
interface UseInboundColumnsParams { interface UseInboundColumnsParams {
hasAnyRemark: boolean; hasAnyRemark: boolean;
hasAnySubSortIndex: boolean;
hasActiveNode: boolean; hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>; nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>; clientCount: Record<number, ClientCountEntry>;
@@ -33,6 +34,7 @@ interface UseInboundColumnsParams {
export function useInboundColumns({ export function useInboundColumns({
hasAnyRemark, hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
clientCount, clientCount,
@@ -113,6 +115,20 @@ export function useInboundColumns({
}); });
} }
if (hasAnySubSortIndex) {
cols.push({
title: (
<Tooltip title={t('pages.inbounds.form.subSortIndex')}>
{t('pages.inbounds.subSortIndex')}
</Tooltip>
),
dataIndex: 'subSortIndex',
key: 'subSortIndex',
align: 'right',
width: 70,
});
}
cols.push( cols.push(
{ {
title: t('pages.inbounds.port'), title: t('pages.inbounds.port'),
@@ -267,5 +283,5 @@ export function useInboundColumns({
); );
return cols; return cols;
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]); }, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
} }
@@ -14,6 +14,7 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils'; import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { antdRule } from '@/utils/zodForm'; import { antdRule } from '@/utils/zodForm';
@@ -27,6 +28,7 @@ interface NodeFormModalProps {
node: NodeRecord | null; node: NodeRecord | null;
testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>; testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>; fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>; save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -45,6 +47,8 @@ function defaultValues(): NodeFormValues {
allowPrivateAddress: false, allowPrivateAddress: false,
tlsVerifyMode: 'verify', tlsVerifyMode: 'verify',
pinnedCertSha256: '', pinnedCertSha256: '',
inboundSyncMode: 'all',
inboundTags: [],
}; };
} }
@@ -54,6 +58,7 @@ export default function NodeFormModal({
node, node,
testConnection, testConnection,
fetchFingerprint, fetchFingerprint,
fetchInbounds,
save, save,
onOpenChange, onOpenChange,
}: NodeFormModalProps) { }: NodeFormModalProps) {
@@ -64,9 +69,12 @@ export default function NodeFormModal({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [fetchingPin, setFetchingPin] = useState(false); const [fetchingPin, setFetchingPin] = useState(false);
const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(null); const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const scheme = Form.useWatch('scheme', form) ?? 'https'; const scheme = Form.useWatch('scheme', form) ?? 'https';
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify'; const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -77,11 +85,14 @@ export default function NodeFormModal({
...(node as unknown as Partial<NodeFormValues>), ...(node as unknown as Partial<NodeFormValues>),
id: node.id, id: node.id,
scheme: (node.scheme as 'http' | 'https') || base.scheme, scheme: (node.scheme as 'http' | 'https') || base.scheme,
inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
inboundTags: node.inboundTags ?? [],
} }
: base; : base;
if (next.scheme === 'http') next.tlsVerifyMode = 'skip'; if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
form.resetFields(); form.resetFields();
form.setFieldsValue(next); form.setFieldsValue(next);
setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
setTestResult(null); setTestResult(null);
}, [open, mode, node, form]); }, [open, mode, node, form]);
@@ -104,6 +115,8 @@ export default function NodeFormModal({
allowPrivateAddress: values.allowPrivateAddress, allowPrivateAddress: values.allowPrivateAddress,
tlsVerifyMode: values.tlsVerifyMode, tlsVerifyMode: values.tlsVerifyMode,
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '', pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
inboundSyncMode: values.inboundSyncMode,
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
}; };
} }
@@ -149,6 +162,26 @@ export default function NodeFormModal({
} }
} }
async function onFetchInbounds() {
try {
await form.validateFields(['name', 'address', 'port', 'apiToken']);
} catch {
return;
}
setFetchingInbounds(true);
try {
const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(msg.obj);
messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
} else {
messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
}
} finally {
setFetchingInbounds(false);
}
}
async function onFinish(values: NodeFormValues) { async function onFinish(values: NodeFormValues) {
const result = NodeFormSchema.safeParse(values); const result = NodeFormSchema.safeParse(values);
if (!result.success) { if (!result.success) {
@@ -323,6 +356,46 @@ export default function NodeFormModal({
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} /> <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
extra={t('pages.nodes.inboundSyncModeHint')}
>
<Select
options={[
{ value: 'all', label: t('pages.nodes.allInbounds') },
{ value: 'selected', label: t('pages.nodes.selectedInbounds') },
]}
/>
</Form.Item>
{inboundSyncMode === 'selected' && (
<Form.Item
label={t('pages.nodes.inboundTags')}
name="inboundTags"
extra={t('pages.nodes.inboundTagsHint')}
>
<Select
mode="multiple"
allowClear
loading={fetchingInbounds}
placeholder={t('pages.nodes.inboundTagsPlaceholder')}
popupRender={(menu) => (
<>
<Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
{t('pages.nodes.loadInbounds')}
</Button>
{menu}
</>
)}
options={inboundOptions.map((inbound) => ({
value: inbound.tag,
label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
}))}
/>
</Form.Item>
)}
<div className="test-row"> <div className="test-row">
<Button type="default" loading={testing} onClick={onTest}> <Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')} {t('pages.nodes.testConnection')}
+4 -4
View File
@@ -241,18 +241,18 @@ export default function NodeList({
) : ( ) : (
<Space> <Space>
<Tooltip title={t('pages.nodes.probe')}> <Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} /> <Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip> </Tooltip>
{isUpdateEligible(record) && ( {isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}> <Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} /> <Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
</Tooltip> </Tooltip>
)} )}
<Tooltip title={t('edit')}> <Tooltip title={t('edit')}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} /> <Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip> </Tooltip>
<Tooltip title={t('delete')}> <Tooltip title={t('delete')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} /> <Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip> </Tooltip>
</Space> </Space>
), ),
+2 -1
View File
@@ -30,7 +30,7 @@ export default function NodesPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery(); const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations(); const { create, update, remove, setEnable, testConnection, fetchFingerprint, fetchInbounds, probe, updatePanels } = useNodeMutations();
const { data: latestVersion = '' } = useQuery({ const { data: latestVersion = '' } = useQuery({
queryKey: ['server', 'panelUpdateInfo'], queryKey: ['server', 'panelUpdateInfo'],
@@ -235,6 +235,7 @@ export default function NodesPage() {
node={formNode} node={formNode}
testConnection={testConnection} testConnection={testConnection}
fetchFingerprint={fetchFingerprint} fetchFingerprint={fetchFingerprint}
fetchInbounds={fetchInbounds}
save={onSave} save={onSave}
onOpenChange={setFormOpen} onOpenChange={setFormOpen}
/> />
+38 -10
View File
@@ -43,7 +43,8 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage()); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]); const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]); const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -69,9 +70,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
// Outbound tags for the panel egress picker: template outbounds plus // Candidates for the panel egress picker: template outbounds plus
// subscription-derived outbounds, same candidate set as the geodata // subscription-derived outbounds, and routing balancers. The panel egress
// download picker. // is injected as a routing rule, so a balancer tag is a valid target
// (it load-balances the panel's own traffic). The geodata picker, by
// contrast, dials a forced tag and can only use a concrete outbound.
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>; const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return; if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
try { try {
@@ -90,14 +93,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
for (const tag of subTags) { for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag); if (typeof tag === 'string' && tag) tags.add(tag);
} }
setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag }))); const balancerTags: string[] = [];
const routing = (template.routing || {}) as Record<string, unknown>;
const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
for (const b of balancers) {
if (!b || typeof b !== 'object') continue;
const tag = (b as Record<string, unknown>).tag;
if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
}
setOutboundTagList([...tags]);
setBalancerTagList(balancerTags);
} catch { } catch {
setOutboundOptions([]); setOutboundTagList([]);
setBalancerTagList([]);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
// Outbound tags and balancer tags share one picker. When balancers exist they
// get their own labeled group so it's clear the selection routes through a
// balancer rather than a single outbound.
const outboundOptions = useMemo<
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
>(() => {
const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
if (balancerTagList.length === 0) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{ label: t('pages.xray.Balancers'), options: balancerTagList.map((tag) => ({ label: tag, value: tag })) },
];
}, [outboundTagList, balancerTagList, t]);
const ldapInboundTagList = useMemo(() => { const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || ''; const csv = allSetting.ldapInboundTags || '';
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : []; return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
@@ -183,6 +210,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} /> onChange={(v) => updateSetting({ pageSize: Number(v) || 0 })} />
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
<Switch checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}> <SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select <Select
value={lang} value={lang}
@@ -240,10 +272,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })} onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/> />
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.restartXrayOnClientDisable')} description={t('pages.settings.restartXrayOnClientDisableDesc')}>
<Switch checked={allSetting.restartXrayOnClientDisable}
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })} />
</SettingListItem>
</> </>
), ),
}, },
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Divider, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd'; import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { ClockCircleOutlined, InfoCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons'; import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
@@ -139,8 +139,18 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
</Space.Compact> </Space.Compact>
</SettingListItem> </SettingListItem>
<Divider>{t('pages.settings.subTitle')}</Divider> <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
<InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} /> <Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
</SettingListItem> </SettingListItem>
@@ -156,14 +166,47 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<Input.TextArea value={allSetting.subAnnounce} <Input.TextArea value={allSetting.subAnnounce}
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} /> onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
</SettingListItem> </SettingListItem>
<SettingListItem
<SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}> paddings="small"
title={t('pages.settings.subThemeDir')}
description={(
<>
{t('pages.settings.subThemeDirDesc')}{' '}
<a
href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
target="_blank"
rel="noopener noreferrer"
>
{t('pages.settings.subThemeDirDocs')}
</a>
</>
)}
>
<Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/" <Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
onChange={(e) => updateSetting({ subThemeDir: e.target.value })} /> onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
</SettingListItem> </SettingListItem>
</>
<Divider>Happ</Divider> ),
},
{
key: '4',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
<Input value={allSetting.subCertFile} onChange={(e) => updateSetting({ subCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subKeyPath')} description={t('pages.settings.subKeyPathDesc')}>
<Input value={allSetting.subKeyFile} onChange={(e) => updateSetting({ subKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
<Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} /> <Switch checked={allSetting.subEnableRouting} onChange={(v) => updateSetting({ subEnableRouting: v })} />
</SettingListItem> </SettingListItem>
@@ -171,9 +214,14 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..." <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} /> onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem> </SettingListItem>
</>
<Divider>Clash / Mihomo</Divider> ),
},
{
key: '6',
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} /> <Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
</SettingListItem> </SettingListItem>
@@ -188,32 +236,6 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
</> </>
), ),
}, },
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
<Input value={allSetting.subCertFile} onChange={(e) => updateSetting({ subCertFile: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subKeyPath')} description={t('pages.settings.subKeyPathDesc')}>
<Input value={allSetting.subKeyFile} onChange={(e) => updateSetting({ subKeyFile: e.target.value })} />
</SettingListItem>
</>
),
},
{
key: '4',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.intervals'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
<InputNumber value={allSetting.subUpdates} min={1} style={{ width: '100%' }}
onChange={(v) => updateSetting({ subUpdates: Number(v) || 0 })} />
</SettingListItem>
</>
),
},
]} /> ]} />
); );
} }
+131 -3
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd'; import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { BellOutlined, SettingOutlined } from '@ant-design/icons'; import { BellOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils'; import { LanguageManager } from '@/utils';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
@@ -13,6 +13,134 @@ interface TelegramTabProps {
updateSetting: (patch: Partial<AllSetting>) => void; updateSetting: (patch: Partial<AllSetting>) => void;
} }
// The notification schedule is fed straight to robfig/cron's AddJob (see
// web.go startTask), which accepts @every <duration>, the @hourly/@daily/...
// macros, and full crontab expressions. This builder covers the common cases
// with dropdowns so users don't have to memorise the syntax, while "Custom"
// preserves the raw crontab escape hatch.
type Unit = 's' | 'm' | 'h';
type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
type Mode = 'every' | Macro | 'custom';
const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
interface RunTime {
mode: Mode;
num: number;
unit: Unit;
custom: string;
}
function parseRunTime(raw: string): RunTime {
const v = (raw ?? '').trim();
const m = v.match(EVERY_RE);
if (m) {
return { mode: 'every', num: Math.max(1, Number(m[1]) || 1), unit: m[2].toLowerCase() as Unit, custom: '' };
}
if ((MACROS as string[]).includes(v)) {
return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
}
return { mode: 'custom', num: 1, unit: 'h', custom: v };
}
function composeRunTime(s: RunTime): string {
if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`;
if (s.mode === 'custom') return s.custom;
return s.mode;
}
// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so
// crontab expressions are 6-field: "second minute hour day month weekday". When
// the user drops into Custom we seed the box with the crontab equivalent of the
// current selection rather than a bare @macro, so they get a real expression to
// edit (and one that the 6-field parser accepts).
function toCrontab(s: RunTime): string {
switch (s.mode) {
case '@hourly': return '0 0 * * * *';
case '@daily': return '0 0 0 * * *';
case '@weekly': return '0 0 0 * * 0';
case '@monthly': return '0 0 0 1 * *';
case 'every': {
const n = Math.max(1, s.num || 1);
if (s.unit === 's') return `*/${n} * * * * *`;
if (s.unit === 'm') return `0 */${n} * * * *`;
return `0 0 */${n} * * *`;
}
default: return s.custom;
}
}
function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
// Init once: the Settings tabs only mount after settings are fetched, so the
// incoming value is already the persisted one.
const [state, setState] = useState<RunTime>(() => parseRunTime(value));
function update(patch: Partial<RunTime>) {
const next = { ...state, ...patch };
setState(next);
onChange(composeRunTime(next));
}
function onModeChange(mode: Mode) {
// Seed Custom with the crontab equivalent of the current selection so the
// box starts from a real expression (e.g. "0 0 0 * * *", not "@daily").
if (mode === 'custom' && !state.custom.trim()) {
update({ mode, custom: toCrontab(state) });
} else {
update({ mode });
}
}
const modeOptions = [
{ value: 'every', label: t('pages.settings.notifyTime.every') },
{ value: '@hourly', label: t('pages.settings.notifyTime.hourly') },
{ value: '@daily', label: t('pages.settings.notifyTime.daily') },
{ value: '@weekly', label: t('pages.settings.notifyTime.weekly') },
{ value: '@monthly', label: t('pages.settings.notifyTime.monthly') },
{ value: 'custom', label: t('pages.settings.notifyTime.custom') },
];
const unitOptions = [
{ value: 's', label: t('pages.settings.notifyTime.seconds') },
{ value: 'm', label: t('pages.settings.notifyTime.minutes') },
{ value: 'h', label: t('pages.settings.notifyTime.hours') },
];
return (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Select<Mode>
style={{ width: '100%' }}
value={state.mode}
options={modeOptions}
onChange={onModeChange}
/>
{state.mode === 'every' && (
<Space.Compact style={{ width: '100%' }}>
<InputNumber
min={1}
style={{ width: '50%' }}
value={state.num}
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
/>
<Select<Unit>
style={{ width: '50%' }}
value={state.unit}
options={unitOptions}
onChange={(unit) => update({ unit })}
/>
</Space.Compact>
)}
{state.mode === 'custom' && (
<Input
value={state.custom}
placeholder="0 30 8 * * *"
onChange={(e) => update({ custom: e.target.value })}
/>
)}
</Space>
);
}
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) { export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
@@ -79,7 +207,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}> <SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>
<Input value={allSetting.tgRunTime} onChange={(e) => updateSetting({ tgRunTime: e.target.value })} /> <NotifyTimeField value={allSetting.tgRunTime} onChange={(v) => updateSetting({ tgRunTime: v })} />
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}> <SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} /> <Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
+42 -26
View File
@@ -128,6 +128,26 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return list.map((server, idx) => ({ key: idx, server })); return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]); }, [dns?.servers]);
// Stable callbacks: the column definitions in useDnsServerColumns are
// memoized, so they must be able to depend on these (see issue #5155)
const openEditServer = useCallback(
(idx: number) => {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
},
[dns?.servers],
);
const deleteServer = useCallback(
(idx: number) => {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
},
[mutate],
);
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer }); const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
function openAddServer() { function openAddServer() {
@@ -135,11 +155,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
setEditingIndex(null); setEditingIndex(null);
setServerModalOpen(true); setServerModalOpen(true);
} }
function openEditServer(idx: number) {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
}
function onServerConfirm(value: DnsServerValue) { function onServerConfirm(value: DnsServerValue) {
mutate((tt) => { mutate((tt) => {
if (!tt.dns) return; if (!tt.dns) return;
@@ -150,12 +165,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
}); });
setServerModalOpen(false); setServerModalOpen(false);
} }
function deleteServer(idx: number) {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
}
function clearAllServers() { function clearAllServers() {
modal.confirm({ modal.confirm({
title: t('pages.xray.dns.clearAllTitle'), title: t('pages.xray.dns.clearAllTitle'),
@@ -182,6 +191,28 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return list.map((entry, idx) => ({ key: idx, ...entry })); return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]); }, [templateSettings?.fakedns]);
const deleteFakedns = useCallback(
(idx: number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
},
[mutate],
);
const updateFakednsField = useCallback(
(idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
},
[mutate],
);
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField }); const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
function addFakedns() { function addFakedns() {
@@ -190,21 +221,6 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS()); (tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
}); });
} }
function deleteFakedns(idx: number) {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
}
function updateFakednsField(idx: number, field: 'ipPool' | 'poolSize', value: string | number) {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
}
const items = useMemo(() => { const items = useMemo(() => {
const out = [ const out = [
@@ -61,8 +61,7 @@ export function useDnsServerColumns({
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>, render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
}, },
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps [t, openEditServer, deleteServer],
[t],
); );
} }
@@ -116,7 +115,6 @@ export function useFakednsColumns({
), ),
}, },
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps [deleteFakedns, updateFakednsField],
[],
); );
} }
@@ -7,8 +7,6 @@ import {
DeleteOutlined, DeleteOutlined,
VerticalAlignTopOutlined, VerticalAlignTopOutlined,
ThunderboltOutlined, ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined, LoadingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -17,6 +15,7 @@ import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
import { import {
isTesting, isTesting,
isUntestable, isUntestable,
@@ -102,10 +101,7 @@ export default function OutboundCardList({
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span> <span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
<span className="card-test"> <span className="card-test">
{testResult(outboundTestStates, index) ? ( {testResult(outboundTestStates, index) ? (
<span className={testResult(outboundTestStates, index)!.success ? 'pill-ok' : 'pill-fail'}> <TestResultPopover result={testResult(outboundTestStates, index)!} />
{testResult(outboundTestStates, index)!.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{testResult(outboundTestStates, index)!.success ? <span>{testResult(outboundTestStates, index)!.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
) : isTesting(outboundTestStates, index) ? ( ) : isTesting(outboundTestStates, index) ? (
<LoadingOutlined /> <LoadingOutlined />
) : null} ) : null}
@@ -210,6 +210,25 @@
color: #e04141; color: #e04141;
} }
.outbound-test-popover .breakdown-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .breakdown-row .bd-label {
flex: 1;
min-width: 0;
opacity: 0.85;
}
.outbound-test-popover .breakdown-row .bd-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
opacity: 0.75;
}
.subscription-outbounds-head { .subscription-outbounds-head {
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -1,12 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Popover, Table, Tag, Tooltip } from 'antd'; import { Button, Table, Tag, Tooltip } from 'antd';
import { import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils'; import { SizeFormatter } from '@/utils';
@@ -15,8 +10,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
import { import {
hasBreakdown,
isTesting, isTesting,
isUntestable, isUntestable,
outboundAddresses, outboundAddresses,
@@ -103,36 +98,7 @@ export default function SubscriptionOutbounds({
const key = record.tag || ''; const key = record.tag || '';
const r = testResult(subscriptionTestStates, key); const r = testResult(subscriptionTestStates, key);
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>; if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
return ( return <TestResultPopover result={r} />;
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
}; };
const testButton = (record: OutboundRow) => { const testButton = (record: OutboundRow) => {
@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover } from 'antd';
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import type { OutboundTestResult } from '@/hooks/useXraySetting';
interface TestResultPopoverProps {
result: OutboundTestResult;
// Custom trigger element; defaults to the ok/fail latency pill.
children?: ReactNode;
}
// Latency pill + detail popover for an outbound test result: per-endpoint
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
// HTTP probes.
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
const { t } = useTranslation();
const breakdown: Array<{ key: string; label: string; value: string }> = [];
if (typeof r.httpStatus === 'number') {
breakdown.push({ key: 'status', label: t('pages.xray.outbound.httpStatus'), value: String(r.httpStatus) });
}
if (typeof r.connectMs === 'number') {
breakdown.push({ key: 'connect', label: t('pages.xray.outbound.breakdownConnect'), value: `${r.connectMs} ms` });
}
if (typeof r.tlsMs === 'number') {
breakdown.push({ key: 'tls', label: t('pages.xray.outbound.breakdownTls'), value: `${r.tlsMs} ms` });
}
if (typeof r.ttfbMs === 'number') {
breakdown.push({ key: 'ttfb', label: t('pages.xray.outbound.breakdownTtfb'), value: `${r.ttfbMs} ms` });
}
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
{breakdown.map((row) => (
<div key={row.key} className="breakdown-row">
<span className="bd-label">{row.label}</span>
<span className="bd-value">{row.value}</span>
</div>
))}
</div>
}
>
{children ?? (
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
)}
</Popover>
);
}
@@ -45,7 +45,7 @@ export function newStreamSlice(network: string): Record<string, unknown> {
network: 'xhttp', network: 'xhttp',
xhttpSettings: { xhttpSettings: {
path: '/', host: '', mode: '', headers: [], path: '/', host: '', mode: '', headers: [],
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000', xPaddingBytes: '100-1000',
}, },
}; };
case 'hysteria': case 'hysteria':
@@ -42,12 +42,6 @@ export function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality'; return security === 'tls' || security === 'reality';
} }
export function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean {
if (!r) return false;
if (r.endpoints?.length) return true;
return !!r.error;
}
export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } { export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRow): { up: number; down: number } {
const tr = outboundsTraffic.find((x) => x.tag === o.tag); const tr = outboundsTraffic.find((x) => x.tag === o.tag);
return { up: tr?.up || 0, down: tr?.down || 0 }; return { up: tr?.up || 0, down: tr?.down || 0 };
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd'; import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives'; import { OutboundDomainStrategies } from '@/schemas/primitives';
@@ -67,12 +67,24 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
<Form.Item <Form.Item
label={t('pages.settings.subFormats.packets')} label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']} name={['settings', 'fragment', 'packets']}
rules={[{
validator: (_rule, value) => {
const str = String(value ?? '').trim();
// xray accepts "tlshello" or any packet-number range (#5075)
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
return Promise.resolve();
}
return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
},
}]}
> >
<Select <AutoComplete
options={[ options={[
{ value: '1-3', label: '1-3' },
{ value: 'tlshello', label: 'tlshello' }, { value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]} ]}
placeholder="tlshello or n-m, e.g. 1-3"
/> />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}> <Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
@@ -25,7 +25,7 @@ export default function HysteriaForm({ form }: { form: FormInstance }) {
label={t('pages.inbounds.form.udpIdleTimeout')} label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']} name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
> >
<InputNumber min={1} style={{ width: '100%' }} /> <InputNumber min={2} max={600} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.form.masquerade')}> <Form.Item label={t('pages.inbounds.form.masquerade')}>
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Popover, Tag, Tooltip } from 'antd'; import { Button, Dropdown, Tag, Tooltip } from 'antd';
import { import {
RetweetOutlined, RetweetOutlined,
MoreOutlined, MoreOutlined,
@@ -8,8 +8,6 @@ import {
DeleteOutlined, DeleteOutlined,
VerticalAlignTopOutlined, VerticalAlignTopOutlined,
ThunderboltOutlined, ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined, LoadingOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined, ArrowDownOutlined,
@@ -22,8 +20,8 @@ import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover';
import { import {
hasBreakdown,
isTesting, isTesting,
isUntestable, isUntestable,
outboundAddresses, outboundAddresses,
@@ -160,36 +158,7 @@ export function useOutboundColumns({
render: (_v, _record, index) => { render: (_v, _record, index) => {
const r = testResult(outboundTestStates, index); const r = testResult(outboundTestStates, index);
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>; if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
return ( return <TestResultPopover result={r} />;
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
}, },
}, },
{ {
@@ -209,7 +209,10 @@ export default function NordModal({
secretKey: nordData?.private_key, secretKey: nordData?.private_key,
address: ['10.5.0.2/32'], address: ['10.5.0.2/32'],
peers: [{ publicKey, endpoint: `${server.station}:51820` }], peers: [{ publicKey, endpoint: `${server.station}:51820` }],
noKernelTun: false, // Userspace TUN — same reasoning as the WARP outbound (#5205): kernel
// TUN fails silently on many VPS setups and diverges from the data
// path the panel's connectivity test exercises.
noKernelTun: true,
}, },
}; };
} }
@@ -103,9 +103,17 @@ export default function WarpModal({
secretKey: data?.private_key, secretKey: data?.private_key,
address: addressesFor(cfg.interface?.addresses || {}), address: addressesFor(cfg.interface?.addresses || {}),
reserved: reservedFor(cfg.client_id ?? data?.client_id), reserved: reservedFor(cfg.client_id ?? data?.client_id),
domainStrategy: 'ForceIP', // Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
// record for engage.cloudflareclient.com, and a host with
// half-configured IPv6 then blackholes the handshake with no error
// logged (#5205).
domainStrategy: 'ForceIPv4v6',
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }], peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
noKernelTun: false, // Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
// fails silently on many VPS setups, and it is a different data path
// than the panel's connectivity test (which always probes with
// noKernelTun=true), so "test ok" and "traffic flows" can disagree.
noKernelTun: true,
}, },
}; };
setStagedOutbound(outbound); setStagedOutbound(outbound);
+2
View File
@@ -34,6 +34,8 @@ export const InboundCoreSchema = z.object({
listen: z.string().default(''), listen: z.string().default(''),
port: InboundPortSchema, port: InboundPortSchema,
tag: z.string().default(''), tag: z.string().default(''),
shareAddrStrategy: z.enum(['node', 'listen', 'custom']).default('node'),
shareAddr: z.string().default(''),
sniffing: SniffingSchema.default({ sniffing: SniffingSchema.default({
enabled: false, enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'], destOverride: ['http', 'tls', 'quic', 'fakedns'],
+4
View File
@@ -44,6 +44,8 @@ export const InboundOptionSchema = z.object({
port: z.number().optional(), port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(), tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(), ssMethod: z.string().optional(),
// Hosting node id; absent/null for this panel's own inbounds (#4997).
nodeId: z.number().nullable().optional(),
}).loose(); }).loose();
export const InboundOptionsSchema = z.array(InboundOptionSchema); export const InboundOptionsSchema = z.array(InboundOptionSchema);
@@ -127,6 +129,8 @@ export const GroupSummarySchema = z.object({
name: z.string(), name: z.string(),
clientCount: z.number(), clientCount: z.number(),
trafficUsed: z.number().nullable().transform((v) => v ?? 0), trafficUsed: z.number().nullable().transform((v) => v ?? 0),
up: z.number().nullable().transform((v) => v ?? 0),
down: z.number().nullable().transform((v) => v ?? 0),
}); });
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []); export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
@@ -25,6 +25,8 @@ export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']); export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
export type TrafficReset = z.infer<typeof TrafficResetSchema>; export type TrafficReset = z.infer<typeof TrafficResetSchema>;
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
// Db-side fields layered on top of the xray slice. These mirror the // Db-side fields layered on top of the xray slice. These mirror the
// DBInbound model — they live in the SQL row, not in xray's config. // DBInbound model — they live in the SQL row, not in xray's config.
@@ -35,6 +37,9 @@ export const InboundDbFieldsSchema = z.object({
trafficReset: TrafficResetSchema.default('never'), trafficReset: TrafficResetSchema.default('never'),
lastTrafficResetTime: z.number().int().default(0), lastTrafficResetTime: z.number().int().default(0),
nodeId: z.number().int().nullable().optional(), nodeId: z.number().int().nullable().optional(),
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
shareAddr: z.string().default(''),
subSortIndex: z.number().int().min(1).default(1),
}); });
export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>; export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;
+7
View File
@@ -31,6 +31,9 @@ export const NodeRecordSchema = z.object({
allowPrivateAddress: z.boolean().optional(), allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(), pinnedCertSha256: z.string().optional(),
inboundSyncMode: z.enum(['all', 'selected']).optional(),
// Backend serializes a nil []string as null for nodes saved before #5178.
inboundTags: z.array(z.string()).nullish(),
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
// whether it's a read-only transitive sub-node surfaced from a downstream node. // whether it's a read-only transitive sub-node surfaced from a downstream node.
guid: z.string().optional(), guid: z.string().optional(),
@@ -63,6 +66,10 @@ export const NodeFormSchema = z.object({
allowPrivateAddress: z.boolean(), allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']), tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
pinnedCertSha256: z.string().optional().default(''), pinnedCertSha256: z.string().optional().default(''),
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
// serialized as null by the backend for a nil slice — tolerate both.
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
}); });
export type NodeRecord = z.infer<typeof NodeRecordSchema>; export type NodeRecord = z.infer<typeof NodeRecordSchema>;
@@ -22,5 +22,12 @@ export const MtprotoInboundSettingsSchema = z.object({
preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(), preferIp: z.enum(['prefer-ipv6', 'prefer-ipv4', 'only-ipv6', 'only-ipv4']).optional(),
debug: z.boolean().optional(), debug: z.boolean().optional(),
domainFronting: MtprotoDomainFrontingSchema.optional(), domainFronting: MtprotoDomainFrontingSchema.optional(),
// When set, the mtg sidecar dials Telegram through a loopback SOCKS bridge in
// the Xray config so the egress obeys routing rules. `outboundTag` optionally
// forces that traffic out a specific outbound/balancer. `routeXrayPort` is the
// bridge port; it is allocated and owned by the backend (never edited here).
routeThroughXray: z.boolean().optional(),
outboundTag: z.string().optional(),
routeXrayPort: z.number().int().min(0).max(65535).optional(),
}); });
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>; export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
@@ -26,6 +26,10 @@ export const WireguardInboundPeerSchema = z.object({
preSharedKey: z.string().optional(), preSharedKey: z.string().optional(),
allowedIPs: z.array(z.string()).default([]), allowedIPs: z.array(z.string()).default([]),
keepAlive: optionalClearedInt(z.number().int().min(0)), keepAlive: optionalClearedInt(z.number().int().min(0)),
// Panel-only annotation (#5168): which client/device this peer belongs to.
// Rides along in the settings JSON like privateKey does; xray-core ignores
// unknown peer fields.
comment: z.string().optional(),
}); });
export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>; export type WireguardInboundPeer = z.infer<typeof WireguardInboundPeerSchema>;
@@ -25,7 +25,8 @@ export type HysteriaMasquerade = z.infer<typeof HysteriaMasqueradeSchema>;
export const HysteriaStreamSettingsSchema = z.object({ export const HysteriaStreamSettingsSchema = z.object({
version: z.literal(2).default(2), version: z.literal(2).default(2),
auth: z.string().default(''), auth: z.string().default(''),
udpIdleTimeout: z.number().int().min(1).default(60), // Xray-core rejects values outside 2-600 seconds at startup.
udpIdleTimeout: z.number().int().min(2).max(600).default(60),
masquerade: HysteriaMasqueradeSchema.optional(), masquerade: HysteriaMasqueradeSchema.optional(),
}); });
export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>; export type HysteriaStreamSettings = z.infer<typeof HysteriaStreamSettingsSchema>;
@@ -41,7 +41,10 @@ export const XHttpStreamSettingsSchema = z.object({
seqKey: z.string().default(''), seqKey: z.string().default(''),
uplinkDataPlacement: z.string().default(''), uplinkDataPlacement: z.string().default(''),
uplinkDataKey: z.string().default(''), uplinkDataKey: z.string().default(''),
scMaxEachPostBytes: z.string().default('1000000'), // Empty default on purpose: xray-core already defaults to 1MB/30ms, and
// baking the literal values into every config and share link gives DPI a
// stable fingerprint (#5141 — TSPU keys on scMinPostsIntervalMs=30).
scMaxEachPostBytes: z.string().default(''),
noSSEHeader: z.boolean().default(false), noSSEHeader: z.boolean().default(false),
scMaxBufferedPosts: z.number().int().min(0).default(30), scMaxBufferedPosts: z.number().int().min(0).default(30),
scStreamUpServerSecs: z.string().default('20-80'), scStreamUpServerSecs: z.string().default('20-80'),
@@ -51,7 +54,7 @@ export const XHttpStreamSettingsSchema = z.object({
// Outbound-only fields. Server (inbound) listener ignores these. The // Outbound-only fields. Server (inbound) listener ignores these. The
// panel embeds them in share-link `extra` blobs so the same xhttp // panel embeds them in share-link `extra` blobs so the same xhttp
// config can roundtrip on both sides. // config can roundtrip on both sides.
scMinPostsIntervalMs: z.string().default('30'), scMinPostsIntervalMs: z.string().default(''),
uplinkChunkSize: z.number().int().min(0).default(0), uplinkChunkSize: z.number().int().min(0).default(0),
noGRPCHeader: z.boolean().default(false), noGRPCHeader: z.boolean().default(false),
xmux: XHttpXmuxSchema.optional(), xmux: XHttpXmuxSchema.optional(),
+11
View File
@@ -56,10 +56,18 @@ export const OutboundTrafficRowSchema = z.object({
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema); export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
export const OutboundTestResultSchema = z.object({ export const OutboundTestResultSchema = z.object({
tag: z.string().optional(),
success: z.boolean(), success: z.boolean(),
delay: z.number().optional(), delay: z.number().optional(),
error: z.string().optional(), error: z.string().optional(),
mode: z.string().optional(), mode: z.string().optional(),
// HTTP-mode extras: status answered by the test URL plus the httptrace
// timing breakdown (dial to local inbound / target TLS via the outbound /
// time to first byte).
httpStatus: z.number().optional(),
connectMs: z.number().optional(),
tlsMs: z.number().optional(),
ttfbMs: z.number().optional(),
endpoints: z endpoints: z
.array( .array(
z.object({ z.object({
@@ -72,6 +80,9 @@ export const OutboundTestResultSchema = z.object({
.optional(), .optional(),
}).loose(); }).loose();
// Batch results from /xray/testOutbounds, aligned with the request order.
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
export const RuleFormSchema = z.object({ export const RuleFormSchema = z.object({
domain: z.string(), domain: z.string(),
ip: z.string(), ip: z.string(),
+7
View File
@@ -89,6 +89,13 @@
min-height: calc(100vh - 120px); min-height: calc(100vh - 120px);
} }
/* Tall action menus (e.g. the inbound/client context menus) must stay inside
the viewport even when antd flips them upward near the screen edge. */
.ant-dropdown .ant-dropdown-menu {
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover, .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover,
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover .ant-dropdown-menu-title-content, .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover .ant-dropdown-menu-title-content,
.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover > .anticon { .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):not(.ant-dropdown-menu-item-danger):hover > .anticon {
@@ -130,5 +130,6 @@ exports[`inbound transport forms > XhttpForm field structure is stable 1`] = `
"Session Placement", "Session Placement",
"Sequence Placement", "Sequence Placement",
"No SSE Header", "No SSE Header",
"XMUX",
] ]
`; `;
@@ -6,6 +6,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > http
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -20,6 +22,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > hyste
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -34,6 +38,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > mixed
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -48,6 +54,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > shado
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -62,6 +70,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > troja
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -76,6 +86,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > tun 1
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -90,6 +102,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > tunne
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -104,6 +118,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > vless
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -118,6 +134,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > vmess
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -132,6 +150,8 @@ exports[`InboundFormModal > field structure is stable for every protocol > wireg
"Remark", "Remark",
"Protocol", "Protocol",
"Address", "Address",
"Share address strategy",
"Subscription sort order",
"Port", "Port",
"Total Flow", "Total Flow",
"Traffic Reset", "Traffic Reset",
@@ -27,6 +27,8 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
], ],
"version": 1, "version": 1,
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -112,6 +114,8 @@ exports[`InboundSchema (full) fixtures > parses shadowsocks-tcp-2022 byte-stably
"network": "tcp,udp", "network": "tcp,udp",
"password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==", "password": "ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ==",
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -168,6 +172,8 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
], ],
"fallbacks": [], "fallbacks": [],
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -257,6 +263,8 @@ exports[`InboundSchema (full) fixtures > parses vless-tcp-reality byte-stably 1`
"encryption": "none", "encryption": "none",
"fallbacks": [], "fallbacks": [],
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -341,6 +349,8 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
"encryption": "none", "encryption": "none",
"fallbacks": [], "fallbacks": [],
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -430,6 +440,8 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably
"encryption": "none", "encryption": "none",
"fallbacks": [], "fallbacks": [],
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -520,6 +532,8 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
}, },
], ],
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -603,6 +617,8 @@ exports[`InboundSchema (full) fixtures > parses wireguard-server byte-stably 1`]
], ],
"secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=", "secretKey": "iJ2cBkrSGqRwIfYIDIxk7hr5RXfdR93MfJUL7yqkkH8=",
}, },
"shareAddr": "",
"shareAddrStrategy": "node",
"sniffing": { "sniffing": {
"destOverride": [ "destOverride": [
"http", "http",
@@ -47,8 +47,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-basic byte-stably 1`] = `
"noSSEHeader": false, "noSSEHeader": false,
"path": "/sp", "path": "/sp",
"scMaxBufferedPosts": 30, "scMaxBufferedPosts": 30,
"scMaxEachPostBytes": "1000000", "scMaxEachPostBytes": "",
"scMinPostsIntervalMs": "30", "scMinPostsIntervalMs": "",
"scStreamUpServerSecs": "20-80", "scStreamUpServerSecs": "20-80",
"seqKey": "", "seqKey": "",
"seqPlacement": "", "seqPlacement": "",
@@ -81,8 +81,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
"noSSEHeader": false, "noSSEHeader": false,
"path": "/sp", "path": "/sp",
"scMaxBufferedPosts": 30, "scMaxBufferedPosts": 30,
"scMaxEachPostBytes": "1000000", "scMaxEachPostBytes": "",
"scMinPostsIntervalMs": "30", "scMinPostsIntervalMs": "",
"scStreamUpServerSecs": "20-80", "scStreamUpServerSecs": "20-80",
"seqKey": "", "seqKey": "",
"seqPlacement": "", "seqPlacement": "",
@@ -115,8 +115,8 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
"noSSEHeader": false, "noSSEHeader": false,
"path": "/sp", "path": "/sp",
"scMaxBufferedPosts": 30, "scMaxBufferedPosts": 30,
"scMaxEachPostBytes": "1000000", "scMaxEachPostBytes": "",
"scMinPostsIntervalMs": "30", "scMinPostsIntervalMs": "",
"scStreamUpServerSecs": "20-80", "scStreamUpServerSecs": "20-80",
"seqKey": "X-Seq", "seqKey": "X-Seq",
"seqPlacement": "cookie", "seqPlacement": "cookie",
+46 -1
View File
@@ -6,7 +6,7 @@ import {
formValuesToWirePayload, formValuesToWirePayload,
type RawInboundRow, type RawInboundRow,
} from '@/lib/xray/inbound-form-adapter'; } from '@/lib/xray/inbound-form-adapter';
import { InboundFormSchema } from '@/schemas/forms/inbound-form'; import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -104,6 +104,8 @@ describe('rawInboundToFormValues', () => {
if (name === 'empty stream settings drop to undefined') { if (name === 'empty stream settings drop to undefined') {
expect(values.streamSettings).toBeUndefined(); expect(values.streamSettings).toBeUndefined();
} }
expect(values.shareAddrStrategy).toBe('node');
expect(values.shareAddr).toBe('');
}); });
} }
@@ -215,6 +217,17 @@ describe('formValuesToWirePayload', () => {
expect(payload.nodeId).toBe(42); expect(payload.nodeId).toBe(42);
}); });
it('round-trips share address strategy fields', () => {
const values = rawInboundToFormValues({
...vlessRow,
shareAddrStrategy: 'custom',
shareAddr: 'edge.example.test',
});
const payload = formValuesToWirePayload(values);
expect(payload.shareAddrStrategy).toBe('custom');
expect(payload.shareAddr).toBe('edge.example.test');
});
it('round-trips top-level fields through raw → values → payload → values', () => { it('round-trips top-level fields through raw → values → payload → values', () => {
// settings/streamSettings/sniffing don't round-trip byte-equal because // settings/streamSettings/sniffing don't round-trip byte-equal because
// the wire payload prunes empty arrays and collapses disabled sniffing // the wire payload prunes empty arrays and collapses disabled sniffing
@@ -249,3 +262,35 @@ describe('formValuesToWirePayload', () => {
expect(replay.streamSettings).toEqual(original.streamSettings); expect(replay.streamSettings).toEqual(original.streamSettings);
}); });
}); });
describe('subSortIndex', () => {
it('rawInboundToFormValues defaults to 1 when field is absent', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: undefined });
expect(values.subSortIndex).toBe(1);
});
it('rawInboundToFormValues preserves valid values and clamps below-minimum ones to 1', () => {
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 5 }).subSortIndex).toBe(5);
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: 0 }).subSortIndex).toBe(1);
expect(rawInboundToFormValues({ ...vlessRow, subSortIndex: -10 }).subSortIndex).toBe(1);
});
it('formValuesToWirePayload includes subSortIndex in the payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 3 });
const payload = formValuesToWirePayload(values);
expect(payload.subSortIndex).toBe(3);
});
it('subSortIndex round-trips through raw → values → payload', () => {
const values = rawInboundToFormValues({ ...vlessRow, subSortIndex: 42 });
const payload = formValuesToWirePayload(values);
const replay = rawInboundToFormValues({ ...vlessRow, subSortIndex: payload.subSortIndex });
expect(replay.subSortIndex).toBe(42);
});
it('InboundDbFieldsSchema enforces an integer minimum of 1 and defaults to 1', () => {
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 1.5 }).success).toBe(false);
expect(InboundDbFieldsSchema.partial().safeParse({ subSortIndex: 0 }).success).toBe(false);
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
});
});
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal'; import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { import {
renderWithProviders, renderWithProviders,
fieldLabels, fieldLabels,
@@ -38,4 +40,39 @@ describe('InboundFormModal', () => {
expect(fieldLabels()).toMatchSnapshot(proto); expect(fieldLabels()).toMatchSnapshot(proto);
} }
}); });
it('preserves custom share address strategy when editing a local inbound', async () => {
renderWithProviders(
<InboundFormModal
open
mode="edit"
dbInbound={new DBInbound({
id: 1,
port: 12345,
listen: '',
protocol: 'shadowsocks',
remark: 'edge',
enable: true,
settings: {
method: '2022-blake3-aes-128-gcm',
password: 'server-password',
network: 'tcp,udp',
clients: [],
},
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
sniffing: { enabled: false },
nodeId: null,
shareAddrStrategy: 'custom',
shareAddr: 'edge.example.test',
})}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>,
);
const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
});
}); });
+52
View File
@@ -309,6 +309,58 @@ describe('resolveAddr precedence', () => {
'fallback.test', 'fallback.test',
)).toBe('fallback.test'); )).toBe('fallback.test');
}); });
it('uses listen strategy with a shareable IPv6 listen before node override', () => {
expect(resolveAddr(
{ ...baseInbound, listen: '[2001:db8::1]', shareAddrStrategy: 'listen', shareAddr: '' } as never,
'node.example.test',
'fallback.test',
)).toBe('[2001:db8::1]');
});
it('uses listen strategy to prefer listen and fall back to node override', () => {
expect(resolveAddr(
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'listen', shareAddr: '' } as never,
'node.example.test',
'fallback.test',
)).toBe('10.0.0.1');
expect(resolveAddr(
{ ...baseInbound, listen: '0.0.0.0', shareAddrStrategy: 'listen', shareAddr: '' } as never,
'node.example.test',
'fallback.test',
)).toBe('node.example.test');
expect(resolveAddr(
{ ...baseInbound, listen: 'localhost', shareAddrStrategy: 'listen', shareAddr: '' } as never,
'node.example.test',
'fallback.test',
)).toBe('node.example.test');
});
it('uses custom strategy address before node override', () => {
expect(resolveAddr(
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: 'edge.example.test' } as never,
'node.example.test',
'fallback.test',
)).toBe('edge.example.test');
});
it('normalizes a bare IPv6 custom strategy address', () => {
expect(resolveAddr(
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr: '2001:db8::2' } as never,
'node.example.test',
'fallback.test',
)).toBe('[2001:db8::2]');
});
it('ignores invalid custom strategy addresses and falls back to node override', () => {
for (const shareAddr of ['https://edge.example.test', 'edge.example.test:8443', '[2001:db8::2]:8443', 'bad host']) {
expect(resolveAddr(
{ ...baseInbound, listen: '10.0.0.1', shareAddrStrategy: 'custom', shareAddr } as never,
'node.example.test',
'fallback.test',
)).toBe('node.example.test');
}
});
}); });
// #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not // #4829: reaching the panel through an SSH tunnel (127.0.0.1/localhost) must not
@@ -399,11 +399,15 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
}); });
}); });
it('round-trips xmux on save and strips the UI-only enableXmux flag', () => { it('round-trips xmux on save, strips enableXmux, and enforces xmux exclusivity', () => {
const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire)); const back = formValuesToWirePayload(rawOutboundToFormValues(xmuxWire));
const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>; const xhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('enableXmux'); expect(xhttp).not.toHaveProperty('enableXmux');
expect(xhttp.xmux).toMatchObject({ maxConcurrency: '11', maxConnections: '1' }); const xmux = xhttp.xmux as Record<string, unknown>;
// xray-core rejects maxConnections + maxConcurrency together; the
// explicit maxConnections wins and maxConcurrency is dropped.
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux).toMatchObject({ maxConnections: '1', hMaxRequestTimes: '1', hMaxReusableSecs: '1' });
}); });
it('drops xmux on save when the toggle is off', () => { it('drops xmux on save when the toggle is off', () => {
@@ -65,6 +65,69 @@ describe('normalizeXhttpForWire stream-one', () => {
expect(out.xmux).toEqual({ maxConcurrency: '16-32' }); expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
expect(out).not.toHaveProperty('scMaxEachPostBytes'); expect(out).not.toHaveProperty('scMaxEachPostBytes');
}); });
it('keeps inbound xmux when enableXmux is on (for the share-link extra)', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',
enableXmux: true,
xmux: { maxConcurrency: '16-32' },
}, 'inbound');
expect(out).not.toHaveProperty('enableXmux');
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
});
it('drops inbound xmux when enableXmux is off', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',
enableXmux: false,
xmux: { maxConcurrency: '16-32' },
}, 'inbound');
expect(out).not.toHaveProperty('enableXmux');
expect(out).not.toHaveProperty('xmux');
});
// xray-core rejects a config with both maxConnections and maxConcurrency.
it('drops maxConcurrency when maxConnections is set (xray-core exclusivity)', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'auto',
enableXmux: true,
xmux: { maxConcurrency: '16-32', maxConnections: 4, hKeepAlivePeriod: 30 },
}, 'inbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux.maxConnections).toBe(4);
expect(xmux.hKeepAlivePeriod).toBe(30);
});
it('keeps maxConcurrency when maxConnections is 0/unset', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xmux: { maxConcurrency: '16-32', maxConnections: 0 },
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux.maxConcurrency).toBe('16-32');
expect(xmux.maxConnections).toBe(0);
});
it('applies xmux exclusivity on the outbound side too', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xmux: { maxConcurrency: '16-32', maxConnections: '8' },
}, 'outbound');
const xmux = out.xmux as Record<string, unknown>;
expect(xmux).not.toHaveProperty('maxConcurrency');
expect(xmux.maxConnections).toBe('8');
});
}); });
describe('normalizeSockoptForWire', () => { describe('normalizeSockoptForWire', () => {
+3 -3
View File
@@ -102,15 +102,15 @@ require (
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.28.0 // indirect golang.org/x/arch v0.28.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/tools v0.46.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect
+6 -6
View File
@@ -250,8 +250,8 @@ golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
@@ -269,8 +269,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w= golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+14 -2
View File
@@ -56,6 +56,18 @@ is_domain() {
[[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1 [[ "$1" =~ ^([A-Za-z0-9](-*[A-Za-z0-9])*\.)+(xn--[a-z0-9]{2,}|[A-Za-z]{2,})$ ]] && return 0 || return 1
} }
# acme.sh's standalone server binds IPv4 by default; --listen-v6 makes it
# v6-only, which breaks HTTP-01 validation when the domain's A record points
# at this host's IPv4 (#4994). Only force IPv6 when the host has no global
# IPv4 address at all.
acme_listen_flag() {
if ip -4 addr show scope global 2> /dev/null | grep -q "inet "; then
echo ""
else
echo "--listen-v6"
fi
}
# Port helpers # Port helpers
is_port_in_use() { is_port_in_use() {
local port="$1" local port="$1"
@@ -292,7 +304,7 @@ setup_ssl_certificate() {
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}" echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1 ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport 80 --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}" echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
@@ -576,7 +588,7 @@ ssl_cert_issue() {
if [[ ${cert_exists} -eq 0 ]]; then if [[ ${cert_exists} -eq 0 ]]; then
# issue the certificate # issue the certificate
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force ~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} $(acme_listen_flag) --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}" echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
+1 -1
View File
@@ -1 +1 @@
3.3.0 3.3.1
+18
View File
@@ -91,6 +91,9 @@ func initModels() error {
if err := pruneOrphanedClientInbounds(); err != nil { if err := pruneOrphanedClientInbounds(); err != nil {
return err return err
} }
if err := normalizeInboundSubSortIndex(); err != nil {
return err
}
if IsPostgres() { if IsPostgres() {
if err := resyncPostgresSequences(db, models); err != nil { if err := resyncPostgresSequences(db, models); err != nil {
log.Printf("Error resyncing postgres sequences: %v", err) log.Printf("Error resyncing postgres sequences: %v", err)
@@ -123,6 +126,21 @@ func pruneOrphanedClientInbounds() error {
return nil return nil
} }
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
// minimum (rows written by builds that defaulted the column to 0, or by nodes
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
func normalizeInboundSubSortIndex() error {
res := db.Exec("UPDATE inbounds SET sub_sort_index = 1 WHERE sub_sort_index < 1")
if res.Error != nil {
log.Printf("Error normalizing inbound sub_sort_index: %v", res.Error)
return res.Error
}
if res.RowsAffected > 0 {
log.Printf("Normalized sub_sort_index on %d inbound(s)", res.RowsAffected)
}
return nil
}
func isIgnorableDuplicateColumnErr(err error, mdl any) bool { func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
if err == nil { if err == nil {
return false return false
+25 -20
View File
@@ -50,6 +50,7 @@ type Inbound struct {
Down int64 `json:"down" form:"down"` // Download traffic in bytes Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty,gte=1" example:"1"` // 1-based sort order of this inbound's links in subscription output only (lower first; ties by id)
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
@@ -57,14 +58,16 @@ type Inbound struct {
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
// Xray configuration fields // Xray configuration fields
Listen string `json:"listen" form:"listen"` Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"` Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"` Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
Settings string `json:"settings" form:"settings"` Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"` Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
ShareAddrStrategy string `json:"shareAddrStrategy" form:"shareAddrStrategy" gorm:"column:share_addr_strategy;default:node" validate:"omitempty,oneof=node listen custom"`
ShareAddr string `json:"shareAddr" form:"shareAddr" gorm:"column:share_addr"`
// OriginNodeGuid is the panelGuid of the node that physically hosts this // OriginNodeGuid is the panelGuid of the node that physically hosts this
// inbound, propagated up across hops (#4983). Empty for an inbound that // inbound, propagated up across hops (#4983). Empty for an inbound that
@@ -445,18 +448,20 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime // endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below. // status fields below.
type Node struct { type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"` Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"` Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"` Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"` Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
BasePath string `json:"basePath" form:"basePath" example:"/"` BasePath string `json:"basePath" form:"basePath" example:"/"`
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"` ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"` Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"` TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"` PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
InboundTags []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
// Guid is the remote panel's stable self-identifier (its panelGuid), // Guid is the remote panel's stable self-identifier (its panelGuid),
// learned from each heartbeat. It is the globally stable node identity used // learned from each heartbeat. It is the globally stable node identity used
+23 -2
View File
@@ -32,6 +32,12 @@ type Instance struct {
FrontingIP string FrontingIP string
FrontingPort int FrontingPort int
FrontingProxyProtocol bool FrontingProxyProtocol bool
// When RouteThroughXray is set, mtg dials Telegram through the loopback
// SOCKS bridge the panel injects into the Xray config at XrayRoutePort, so
// the egress obeys the core's routing rules instead of going out directly.
RouteThroughXray bool
XrayRoutePort int
} }
func (inst Instance) bindTo() string { func (inst Instance) bindTo() string {
@@ -54,6 +60,8 @@ func (inst Instance) fingerprint() string {
inst.FrontingIP, inst.FrontingIP,
strconv.Itoa(inst.FrontingPort), strconv.Itoa(inst.FrontingPort),
strconv.FormatBool(inst.FrontingProxyProtocol), strconv.FormatBool(inst.FrontingProxyProtocol),
strconv.FormatBool(inst.RouteThroughXray),
strconv.Itoa(inst.XrayRoutePort),
}, "|") }, "|")
} }
@@ -117,6 +125,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
Port int `json:"port"` Port int `json:"port"`
ProxyProtocol bool `json:"proxyProtocol"` ProxyProtocol bool `json:"proxyProtocol"`
} `json:"domainFronting"` } `json:"domainFronting"`
RouteThroughXray bool `json:"routeThroughXray"`
RouteXrayPort int `json:"routeXrayPort"`
} }
if err := json.Unmarshal([]byte(settings), &parsed); err != nil { if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return Instance{}, false return Instance{}, false
@@ -136,6 +146,8 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
FrontingIP: parsed.DomainFronting.IP, FrontingIP: parsed.DomainFronting.IP,
FrontingPort: parsed.DomainFronting.Port, FrontingPort: parsed.DomainFronting.Port,
FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol, FrontingProxyProtocol: parsed.DomainFronting.ProxyProtocol,
RouteThroughXray: parsed.RouteThroughXray,
XrayRoutePort: parsed.RouteXrayPort,
}, true }, true
} }
@@ -172,7 +184,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
cur.proc.Stop() cur.proc.Stop()
delete(m.procs, inst.Id) delete(m.procs, inst.Id)
} }
metricsPort, err := freeLocalPort() metricsPort, err := FreeLocalPort()
if err != nil { if err != nil {
return err return err
} }
@@ -307,7 +319,10 @@ func (m *Manager) CollectTraffic() []Traffic {
return out return out
} }
func freeLocalPort() (int, error) { // FreeLocalPort asks the OS for an unused loopback TCP port. It is used both
// for mtg's metrics endpoint and to allocate the per-inbound SOCKS egress
// bridge port persisted into mtproto inbound settings.
func FreeLocalPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0") l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
return 0, err return 0, err
@@ -345,6 +360,12 @@ func renderConfig(inst Instance, metricsPort int) string {
b.WriteString("proxy-protocol = true\n") b.WriteString("proxy-protocol = true\n")
} }
} }
// When the inbound opts into Xray routing, mtg reaches Telegram through the
// loopback SOCKS bridge the panel injects into the running Xray config. mtg
// only supports SOCKS5 upstreams, which is exactly what the bridge exposes.
if inst.RouteThroughXray && inst.XrayRoutePort > 0 {
fmt.Fprintf(&b, "\n[network]\nproxies = [\"socks5://127.0.0.1:%d\"]\n", inst.XrayRoutePort)
}
fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort) fmt.Fprintf(&b, "\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n", metricsPort)
return b.String() return b.String()
} }
+33 -1
View File
@@ -40,7 +40,8 @@ func TestInstanceFromInbound(t *testing.T) {
Protocol: model.MTProto, Protocol: model.MTProto,
Settings: `{"fakeTlsDomain":"example.com","secret":"",` + Settings: `{"fakeTlsDomain":"example.com","secret":"",` +
`"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` + `"debug":true,"proxyProtocolListener":true,"preferIp":"prefer-ipv4",` +
`"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true}}`, `"domainFronting":{"ip":"127.0.0.1","port":9443,"proxyProtocol":true},` +
`"routeThroughXray":true,"routeXrayPort":50000}`,
} }
inst, ok := InstanceFromInbound(ib) inst, ok := InstanceFromInbound(ib)
if !ok { if !ok {
@@ -58,6 +59,9 @@ func TestInstanceFromInbound(t *testing.T) {
if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol { if inst.FrontingIP != "127.0.0.1" || inst.FrontingPort != 9443 || !inst.FrontingProxyProtocol {
t.Fatalf("domain-fronting not parsed: %+v", inst) t.Fatalf("domain-fronting not parsed: %+v", inst)
} }
if !inst.RouteThroughXray || inst.XrayRoutePort != 50000 {
t.Fatalf("xray routing not parsed: %+v", inst)
}
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok { if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
t.Fatal("non-mtproto inbound should not produce an instance") t.Fatal("non-mtproto inbound should not produce an instance")
@@ -108,6 +112,32 @@ func TestRenderConfig(t *testing.T) {
} }
} }
func TestRenderConfigXrayEgress(t *testing.T) {
// Routing through Xray emits a [network] proxies upstream pointing at the
// loopback SOCKS bridge, before the prometheus block.
routed := renderConfig(Instance{
Secret: "ee22", Listen: "0.0.0.0", Port: 443,
RouteThroughXray: true, XrayRoutePort: 50000,
}, 7000)
if !strings.Contains(routed, "[network]") ||
!strings.Contains(routed, `proxies = ["socks5://127.0.0.1:50000"]`) {
t.Fatalf("routed config must emit the SOCKS upstream:\n%s", routed)
}
if strings.Index(routed, "[network]") > strings.Index(routed, "[stats.prometheus]") {
t.Fatalf("[network] must precede [stats.prometheus]:\n%s", routed)
}
// Without the flag (or without a port) the section is omitted.
for _, inst := range []Instance{
{Secret: "ee", Listen: "0.0.0.0", Port: 443},
{Secret: "ee", Listen: "0.0.0.0", Port: 443, RouteThroughXray: true},
} {
if got := renderConfig(inst, 7000); strings.Contains(got, "[network]") {
t.Fatalf("unrouted config must omit [network]:\n%s", got)
}
}
}
func TestFingerprintReactsToOptions(t *testing.T) { func TestFingerprintReactsToOptions(t *testing.T) {
base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443} base := Instance{Secret: "ee", Listen: "0.0.0.0", Port: 443}
for name, mutate := range map[string]func(*Instance){ for name, mutate := range map[string]func(*Instance){
@@ -117,6 +147,8 @@ func TestFingerprintReactsToOptions(t *testing.T) {
"frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" }, "frontingIP": func(i *Instance) { i.FrontingIP = "127.0.0.1" },
"frontingPort": func(i *Instance) { i.FrontingPort = 9443 }, "frontingPort": func(i *Instance) { i.FrontingPort = 9443 },
"frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true }, "frontingProxy": func(i *Instance) { i.FrontingProxyProtocol = true },
"routeXray": func(i *Instance) { i.RouteThroughXray = true },
"routeXrayPort": func(i *Instance) { i.XrayRoutePort = 50000 },
} { } {
changed := base changed := base
mutate(&changed) mutate(&changed)
+48
View File
@@ -69,3 +69,51 @@ func TestBuildURLs_EmptySubId(t *testing.T) {
t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c) t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
} }
} }
// A subscriber arriving via a reverse proxy (subURI configured with full
// HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
// URLs as in the main subURL — not the raw sub-server port 2096.
func TestBuildURLs_DerivesJsonFromConfiguredSubURI(t *testing.T) {
initSubDB(t)
s := &SubService{}
s.PrepareForRequest("sub.example.com")
// Simulate the admin having set subURI (reverse-proxy setup).
database.GetDB().Exec(
"INSERT INTO settings (key, value) VALUES (?, ?)",
"subURI", "https://example.com/sub-xxx/")
subURL, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
if subURL != "https://example.com/sub-xxx/ABC" {
t.Fatalf("subURL = %q", subURL)
}
if jsonURL != "https://example.com/json/ABC" {
t.Fatalf("jsonURL = %q (should derive scheme+host from subURI), want %q", jsonURL, "https://example.com/json/ABC")
}
if clashURL != "https://example.com/clash/ABC" {
t.Fatalf("clashURL = %q (should derive scheme+host from subURI), want %q", clashURL, "https://example.com/clash/ABC")
}
}
// A malformed subURI (no scheme/host) must not leak a broken base into the
// JSON/Clash URLs; BuildURLs should fall back to the request-derived base.
func TestBuildURLs_MalformedSubURIFallsBackToRequestBase(t *testing.T) {
initSubDB(t)
s := &SubService{}
s.PrepareForRequest("sub.example.com")
// A value with no scheme can't yield a usable scheme+host.
database.GetDB().Exec(
"INSERT INTO settings (key, value) VALUES (?, ?)",
"subURI", "example.com/sub-xxx/")
_, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
if jsonURL != "http://sub.example.com:2096/json/ABC" {
t.Fatalf("jsonURL = %q, want fallback to request base %q", jsonURL, "http://sub.example.com:2096/json/ABC")
}
if clashURL != "http://sub.example.com:2096/clash/ABC" {
t.Fatalf("clashURL = %q, want fallback to request base %q", clashURL, "http://sub.example.com:2096/clash/ABC")
}
}
+7 -15
View File
@@ -9,15 +9,12 @@ import (
yaml "github.com/goccy/go-yaml" yaml "github.com/goccy/go-yaml"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
) )
type SubClashService struct { type SubClashService struct {
inboundService service.InboundService enableRouting bool
enableRouting bool clashRules string
clashRules string SubService *SubService
SubService *SubService
} }
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService { func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
@@ -36,19 +33,14 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
for _, inbound := range inbounds { for _, inbound := range inbounds {
clients, err := s.inboundService.GetClients(inbound) clients := s.SubService.matchingClients(inbound, subId)
if err != nil { if len(clients) == 0 {
logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
}
if clients == nil {
continue continue
} }
s.SubService.projectThroughFallbackMaster(inbound) s.SubService.projectThroughFallbackMaster(inbound)
for _, client := range clients { for _, client := range clients {
if client.SubID == subId { seenEmails[client.Email] = struct{}{}
seenEmails[client.Email] = struct{}{} proxies = append(proxies, s.getProxies(inbound, client, host)...)
proxies = append(proxies, s.getProxies(inbound, client, host)...)
}
} }
} }
+14 -13
View File
@@ -8,10 +8,8 @@ import (
"strings" "strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/random" "github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
) )
//go:embed default.json //go:embed default.json
@@ -24,8 +22,7 @@ type SubJsonService struct {
finalMask string finalMask string
mux string mux string
inboundService service.InboundService SubService *SubService
SubService *SubService
} }
// NewSubJsonService creates a new JSON subscription service with the given configuration. // NewSubJsonService creates a new JSON subscription service with the given configuration.
@@ -75,20 +72,15 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
// Prepare Inbounds // Prepare Inbounds
for _, inbound := range inbounds { for _, inbound := range inbounds {
clients, err := s.inboundService.GetClients(inbound) clients := s.SubService.matchingClients(inbound, subId)
if err != nil { if len(clients) == 0 {
logger.Error("SubJsonService - GetClients: Unable to get clients from inbound")
}
if clients == nil {
continue continue
} }
s.SubService.projectThroughFallbackMaster(inbound) s.SubService.projectThroughFallbackMaster(inbound)
for _, client := range clients { for _, client := range clients {
if client.SubID == subId { seenEmails[client.Email] = struct{}{}
seenEmails[client.Email] = struct{}{} configArray = append(configArray, s.getConfig(inbound, client, host)...)
configArray = append(configArray, s.getConfig(inbound, client, host)...)
}
} }
} }
@@ -225,6 +217,15 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
delete(xhttp, "scMaxBufferedPosts") delete(xhttp, "scMaxBufferedPosts")
delete(xhttp, "scStreamUpServerSecs") delete(xhttp, "scStreamUpServerSecs")
delete(xhttp, "serverMaxHeaderBytes") delete(xhttp, "serverMaxHeaderBytes")
// Values matching xray-core's own defaults stay off the wire:
// old panels seeded them into every stored config and the
// literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141).
if v, _ := xhttp["scMaxEachPostBytes"].(string); v == "" || v == "1000000" {
delete(xhttp, "scMaxEachPostBytes")
}
if v, _ := xhttp["scMinPostsIntervalMs"].(string); v == "" || v == "30" {
delete(xhttp, "scMinPostsIntervalMs")
}
} }
} }
return streamSettings return streamSettings
+117 -27
View File
@@ -106,6 +106,36 @@ func listenIsInternalOnly(listen string) bool {
return isLoopbackHost(listen) return isLoopbackHost(listen)
} }
// matchingClients returns the inbound's clients whose SubID equals subId,
// deduplicated by email. settings.clients can accumulate duplicate entries
// for the same client (multi-node sync/import drift, old DBs): SyncInbound
// dedupes the normalized client_inbounds rows on write but never rewrites
// the legacy JSON, and the subscription builders iterate that JSON — so
// without this guard every duplicate became a duplicate profile in the
// output (#5134). Link generation keys purely on (inbound, email), so
// same-email entries are pure duplicates and dropping them is lossless.
func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []model.Client {
clients, err := s.inboundService.GetClients(inbound)
if err != nil {
logger.Error("SubService - GetClients: Unable to get clients from inbound")
return nil
}
var out []model.Client
seen := make(map[string]struct{}, len(clients))
for _, client := range clients {
if client.SubID != subId {
continue
}
key := strings.ToLower(client.Email)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, client)
}
return out
}
// GetSubs retrieves subscription links for a given subscription ID and host. // GetSubs retrieves subscription links for a given subscription ID and host.
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) { func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
s.PrepareForRequest(host) s.PrepareForRequest(host)
@@ -134,23 +164,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
for _, inbound := range inbounds { for _, inbound := range inbounds {
clients, err := s.inboundService.GetClients(inbound) clients := s.matchingClients(inbound, subId)
if err != nil { if len(clients) == 0 {
logger.Error("SubService - GetClients: Unable to get clients from inbound")
}
if clients == nil {
continue continue
} }
s.projectThroughFallbackMaster(inbound) s.projectThroughFallbackMaster(inbound)
for _, client := range clients { for _, client := range clients {
if client.SubID == subId { if client.Enable {
if client.Enable { hasEnabledClient = true
hasEnabledClient = true
}
result = append(result, s.GetLink(inbound, client.Email))
emails = append(emails, client.Email)
seenEmails[client.Email] = struct{}{}
} }
result = append(result, s.GetLink(inbound, client.Email))
emails = append(emails, client.Email)
seenEmails[client.Email] = struct{}{}
} }
} }
@@ -259,7 +284,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
WHERE WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria') inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria')
AND clients.sub_id = ? AND inbounds.enable = ? AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Find(&inbounds).Error )`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -788,23 +813,42 @@ func (s *SubService) loadNodes() {
s.nodesByID = m s.nodesByID = m
} }
// resolveInboundAddress picks the host an external client should connect to: // resolveInboundAddress picks the host an external client should connect to,
// 1. node-managed inbound -> the node's address // honoring the inbound's share address strategy the same way the panel's
// 2. an explicit, client-reachable bind Listen -> that Listen // share/QR link builder does (#5208):
// 3. otherwise the subscriber's request host (s.address) // - "listen": an explicit, client-reachable bind Listen wins, backed by the
// node's address for node-managed inbounds;
// - "custom": the inbound's ShareAddr wins, then node, then listen;
// - "node" (default, and any unknown value): the node's address for
// node-managed inbounds, then a routable Listen — the pre-strategy order.
// //
// A loopback/wildcard bind or a unix-domain-socket listen is a server-side // Every chain ends at the subscriber's request host (s.address). A
// detail and is never advertised; External Proxy remains the way to advertise // loopback/wildcard bind or a unix-domain-socket listen is a server-side
// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and // detail and is never advertised; External Proxy still overrides everything
// the subscription agree. // upstream of this call.
func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string {
var nodeAddr string
if inbound.NodeID != nil && s.nodesByID != nil { if inbound.NodeID != nil && s.nodesByID != nil {
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n.Address != "" { if n, ok := s.nodesByID[*inbound.NodeID]; ok {
return n.Address nodeAddr = n.Address
} }
} }
var listenAddr string
if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) { if listen := inbound.Listen; listen != "" && listen[0] != '@' && listen[0] != '/' && isRoutableHost(listen) {
return listen listenAddr = listen
}
candidates := []string{nodeAddr, listenAddr}
switch inbound.ShareAddrStrategy {
case "listen":
candidates = []string{listenAddr, nodeAddr}
case "custom":
candidates = []string{strings.TrimSpace(inbound.ShareAddr), nodeAddr, listenAddr}
}
for _, c := range candidates {
if c != "" {
return c
}
} }
return s.address return s.address
} }
@@ -1475,6 +1519,19 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if len(extra) > 0 { if len(extra) > 0 {
orders['o'] = extra orders['o'] = extra
} }
// A node-hosted inbound usually shares its remark with the local copy it
// was synced from, so a multi-node subscription would list several
// identically-named entries differing only by address (#5035). Tag such
// entries with the node name unless the admin already put it in the remark.
if inbound.NodeID != nil && s.nodesByID != nil {
if n, ok := s.nodesByID[*inbound.NodeID]; ok && n != nil && n.Name != "" && !strings.Contains(orders['i'], n.Name) {
if orders['i'] != "" {
orders['i'] += "@" + n.Name
} else {
orders['i'] = n.Name
}
}
}
var remark []string var remark []string
for i := 0; i < len(orderChars); i++ { for i := 0; i < len(orderChars); i++ {
@@ -1604,8 +1661,16 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
"uplinkDataPlacement", "uplinkDataKey", "uplinkDataPlacement", "uplinkDataKey",
"scMaxEachPostBytes", "scMinPostsIntervalMs", "scMaxEachPostBytes", "scMinPostsIntervalMs",
} }
// Values matching xray-core's own defaults are redundant on the wire and
// the literal scMinPostsIntervalMs=30 is a known DPI fingerprint (#5141).
// Old panels seeded these defaults into every xhttp inbound, so filter
// them here instead of requiring every stored config to be re-saved.
coreDefaults := map[string]string{
"scMaxEachPostBytes": "1000000",
"scMinPostsIntervalMs": "30",
}
for _, field := range stringFields { for _, field := range stringFields {
if v, ok := xhttp[field].(string); ok && len(v) > 0 { if v, ok := xhttp[field].(string); ok && len(v) > 0 && v != coreDefaults[field] {
extra[field] = v extra[field] = v
} }
} }
@@ -2123,12 +2188,37 @@ func (s *SubService) BuildURLs(subPath, subJsonPath, subClashPath, subId string)
base := s.settingService.BuildSubURIBase(s.address) base := s.settingService.BuildSubURIBase(s.address)
subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId) subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
subJsonURL = s.buildSingleURL(configuredSubJsonURI, base, subJsonPath, subId)
subClashURL = s.buildSingleURL(configuredSubClashURI, base, subClashPath, subId) // When subURI is explicitly configured (reverse-proxy setup), use its
// scheme+host as the base for JSON and Clash URLs so they match the
// reverse-proxy endpoint instead of the raw sub-server port. Fall back
// to the request-derived base if subURI is empty or can't be parsed
// into a scheme+host (e.g. a malformed value with no scheme).
jsonClashBase := base
if configuredSubURI != "" {
if derived := s.extractBaseFromURI(configuredSubURI); derived != "" {
jsonClashBase = derived
}
}
subJsonURL = s.buildSingleURL(configuredSubJsonURI, jsonClashBase, subJsonPath, subId)
subClashURL = s.buildSingleURL(configuredSubClashURI, jsonClashBase, subClashPath, subId)
return subURL, subJsonURL, subClashURL return subURL, subJsonURL, subClashURL
} }
// extractBaseFromURI extracts scheme://host from a configured URI.
// e.g., "https://example.com/sub-xxx/" → "https://example.com".
// Returns "" when the URI is empty or lacks a scheme/host, so callers can
// fall back to the request-derived base instead of emitting a broken value.
func (s *SubService) extractBaseFromURI(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
}
// buildSingleURL constructs a single URL using configured URI or base components // buildSingleURL constructs a single URL using configured URI or base components
func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string { func (s *SubService) buildSingleURL(configuredURI, base, basePath, subId string) string {
if configuredURI != "" { if configuredURI != "" {
+65
View File
@@ -0,0 +1,65 @@
package sub
import (
"fmt"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestGetSubs_DuplicateSettingsClients_Deduped reproduces #5134: multi-node
// sync/import drift can leave the same client twice inside an inbound's
// legacy settings.clients JSON while the normalized client_inbounds table
// stays clean. The subscription output must still contain one profile per
// (inbound, client).
func TestGetSubs_DuplicateSettingsClients_Deduped(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const subId = "sub-dup"
const email = "dup@example.com"
const uuid = "f1b9265f-26a8-4b75-9be2-c64a94b15de1"
db := database.GetDB()
settings := fmt.Sprintf(`{"clients": [
{"id": %q, "email": %q, "subId": %q, "enable": true},
{"id": %q, "email": %q, "subId": %q, "enable": true}
]}`, uuid, email, subId, uuid, email, subId)
ib := &model.Inbound{
UserId: 1,
Tag: "dup-in",
Enable: true,
Port: 42001,
Protocol: model.VLESS,
Settings: settings,
StreamSettings: `{"network": "tcp", "security": "none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound: %v", err)
}
s := NewSubService(false, "-ieo")
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if len(links) != 1 {
t.Fatalf("links = %d, want 1 (duplicate settings.clients entries must collapse)", len(links))
}
if len(emails) != 1 {
t.Fatalf("emails = %d, want 1, got %v", len(emails), emails)
}
}
+79
View File
@@ -0,0 +1,79 @@
package sub
import (
"fmt"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestGetSubs_OrdersBySubSortIndexThenId verifies that subscription output
// lists inbound links ordered by sub_sort_index ASC, breaking ties by id ASC.
// The same query feeds the raw body, the HTML sub page, and the JSON/Clash
// formats, so asserting on GetSubs covers all of them.
func TestGetSubs_OrdersBySubSortIndexThenId(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const subId = "sub-sort"
db := database.GetDB()
seed := []struct {
tag string
port int
subSortIndex int
email string
uuid string
}{
// Created in this order on purpose: without the ORDER BY the links
// would come out s3, s1, s2a, s2b (creation order).
{"sort-3", 42101, 3, "s3@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf001"},
{"sort-1", 42102, 1, "s1@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf002"},
{"sort-2a", 42103, 2, "s2a@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf003"},
{"sort-2b", 42104, 2, "s2b@example.com", "0d68a695-4be1-4d92-a9c3-8c0f1c2cf004"},
}
for _, s := range seed {
settings := fmt.Sprintf(`{"clients": [{"id": %q, "email": %q, "subId": %q, "enable": true}]}`, s.uuid, s.email, subId)
ib := &model.Inbound{
UserId: 1,
Tag: s.tag,
Enable: true,
Port: s.port,
Protocol: model.VLESS,
Settings: settings,
StreamSettings: `{"network": "tcp", "security": "none"}`,
SubSortIndex: s.subSortIndex,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", s.tag, err)
}
client := &model.ClientRecord{Email: s.email, SubID: subId, UUID: s.uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", s.email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", s.email, err)
}
}
s := NewSubService(false, "-ieo")
links, emails, _, _, err := s.GetSubs(subId, "sub.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if len(links) != len(seed) {
t.Fatalf("links = %d, want %d", len(links), len(seed))
}
want := []string{"s1@example.com", "s2a@example.com", "s2b@example.com", "s3@example.com"}
for i, email := range want {
if emails[i] != email {
t.Fatalf("emails order = %v, want %v (sub_sort_index ASC, id ASC)", emails, want)
}
}
}
+62
View File
@@ -127,6 +127,68 @@ func TestResolveInboundAddress(t *testing.T) {
t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost) t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
} }
}) })
// Per-inbound share address strategy (#5208): subscriptions follow the
// same order as the panel's share/QR links.
t.Run("listen strategy prefers the bind over the node address", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "listen"}
if got := s.resolveInboundAddress(ib); got != "203.0.113.7" {
t.Fatalf("listen-strategy address = %q, want the bind 203.0.113.7", got)
}
})
t.Run("listen strategy falls back to node address on a wildcard bind", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0", ShareAddrStrategy: "listen"}
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
t.Fatalf("listen-strategy wildcard address = %q, want node7.example.com", got)
}
})
t.Run("custom strategy uses the share address", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "custom", ShareAddr: "edge.example.com"}
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
t.Fatalf("custom-strategy address = %q, want edge.example.com", got)
}
})
t.Run("custom strategy with empty share address falls back to node", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, ShareAddrStrategy: "custom"}
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
t.Fatalf("custom-strategy fallback address = %q, want node7.example.com", got)
}
})
t.Run("node strategy keeps the pre-strategy order", func(t *testing.T) {
id := 7
s := &SubService{
address: reqHost,
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
}
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "node"}
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
}
})
} }
func TestUnmarshalStreamSettings(t *testing.T) { func TestUnmarshalStreamSettings(t *testing.T) {
+3 -1
View File
@@ -545,9 +545,11 @@ func buildStream(network, security string) map[string]any {
case "httpupgrade": case "httpupgrade":
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}} stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
case "xhttp": case "xhttp":
// No scMaxEachPostBytes/scMinPostsIntervalMs seed: xray-core's own
// defaults apply, and the literal values fingerprint traffic (#5141).
stream["xhttpSettings"] = map[string]any{ stream["xhttpSettings"] = map[string]any{
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{}, "path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000", "xPaddingBytes": "100-1000",
} }
default: default:
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}} stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
+8 -1
View File
@@ -118,7 +118,14 @@ func (a *ClientController) get(c *gin.Context) {
return return
} }
rec.Flow = flow rec.Flow = flow
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil) // Consumed bytes (up+down, including cross-node global overlay) so API
// consumers can pair usage with the client's totalGB quota (#4973).
// Best-effort: a traffic lookup failure must not break the client fetch.
var usedTraffic int64
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
}
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
} }
func (a *ClientController) create(c *gin.Context) { func (a *ClientController) create(c *gin.Context) {
+13
View File
@@ -37,6 +37,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.POST("/test", a.test) g.POST("/test", a.test)
g.POST("/certFingerprint", a.certFingerprint) g.POST("/certFingerprint", a.certFingerprint)
g.POST("/inbounds", a.inbounds)
g.POST("/probe/:id", a.probe) g.POST("/probe/:id", a.probe)
g.POST("/updatePanel", a.updatePanel) g.POST("/updatePanel", a.updatePanel)
g.GET("/history/:id/:metric/:bucket", a.history) g.GET("/history/:id/:metric/:bucket", a.history)
@@ -160,6 +161,18 @@ func (a *NodeController) setEnable(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil) jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
} }
func (a *NodeController) inbounds(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
options, err := a.nodeService.GetRemoteInboundOptions(ctx, n)
jsonObj(c, options, err)
}
func (a *NodeController) test(c *gin.Context) { func (a *NodeController) test(c *gin.Context) {
n := &model.Node{} n := &model.Node{}
if err := c.ShouldBind(n); err != nil { if err := c.ShouldBind(n); err != nil {
+34
View File
@@ -48,6 +48,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.POST("/update", a.updateSetting) g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound) g.POST("/testOutbound", a.testOutbound)
g.POST("/testOutbounds", a.testOutbounds)
g.POST("/balancerStatus", a.balancerStatus) g.POST("/balancerStatus", a.balancerStatus)
g.POST("/balancerOverride", a.balancerOverride) g.POST("/balancerOverride", a.balancerOverride)
g.POST("/routeTest", a.routeTest) g.POST("/routeTest", a.routeTest)
@@ -286,6 +287,39 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
jsonObj(c, result, nil) jsonObj(c, result, nil)
} }
// testOutbounds tests a batch of outbound configurations through one shared
// temp xray instance and returns an array of results in input order.
// Form "outbounds": JSON array of outbound configs (required).
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
// Optional form "mode": "tcp" for fast dial-only probes, anything else
// (default) for real HTTP requests routed through each outbound.
func (a *XraySettingController) testOutbounds(c *gin.Context) {
outboundsJSON := c.PostForm("outbounds")
allOutboundsJSON := c.PostForm("allOutbounds")
mode := c.PostForm("mode")
if outboundsJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
return
}
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, results, nil)
}
// balancerStatus reports the live state (override + strategy picks) of the // balancerStatus reports the live state (override + strategy picks) of the
// balancer tags given as a comma-separated "tags" form field. // balancer tags given as a comma-separated "tags" form field.
func (a *XraySettingController) balancerStatus(c *gin.Context) { func (a *XraySettingController) balancerStatus(c *gin.Context) {
+13
View File
@@ -31,12 +31,16 @@ func (j *MtprotoJob) Run() {
} }
var desired []mtproto.Instance var desired []mtproto.Instance
routedTags := make(map[string]bool)
for _, ib := range inbounds { for _, ib := range inbounds {
if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil { if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
continue continue
} }
if inst, ok := mtproto.InstanceFromInbound(ib); ok { if inst, ok := mtproto.InstanceFromInbound(ib); ok {
desired = append(desired, inst) desired = append(desired, inst)
if inst.RouteThroughXray {
routedTags[inst.Tag] = true
}
} }
} }
@@ -49,6 +53,12 @@ func (j *MtprotoJob) Run() {
} }
traffics := make([]*xray.Traffic, 0, len(deltas)) traffics := make([]*xray.Traffic, 0, len(deltas))
for _, d := range deltas { for _, d := range deltas {
// Routed inbounds egress through the Xray SOCKS bridge, which carries the
// inbound's tag and is metered by xray_traffic_job. Folding mtg's own
// metrics in too would double-count, so skip them here.
if routedTags[d.Tag] {
continue
}
traffics = append(traffics, &xray.Traffic{ traffics = append(traffics, &xray.Traffic{
IsInbound: true, IsInbound: true,
Tag: d.Tag, Tag: d.Tag,
@@ -56,6 +66,9 @@ func (j *MtprotoJob) Run() {
Down: d.Down, Down: d.Down,
}) })
} }
if len(traffics) == 0 {
return
}
if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil { if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
logger.Warning("mtproto job: add traffic failed:", err) logger.Warning("mtproto job: add traffic failed:", err)
} }

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