Compare commits

...

39 Commits

Author SHA1 Message Date
Sanaei 7ef22f94c9 fix(logger): fix data race in InitLogger
Replace the package logger variable with an atomic.Pointer so InitLogger swapping the handle no longer races with concurrent Debug/Info/Warning/Error calls from other goroutines. Also guard fileRotate with a mutex, and add a regression test that reproduces the race under concurrent logging.
2026-09-16 02:29:42 +02:00
Sanaei ec9fbae645 v3.8.5 2026-09-16 02:08:08 +02:00
Sanaei e26cf1d3ed feat(sub): redesign the subscription page around usage, tabs and app imports
The info page was a long key/value table followed by every link and two
app dropdowns, and it rendered left-to-right even for Persian and Arabic.
It now leads with a usage ring, the remaining quota and a stats grid, and
splits the rest into Subscription / Apps / Configs tabs.

- Status tells expired, data-used-up and disabled apart instead of one
  "Inactive", replacing the hard-coded English expiry chip.
- The Apps tab keeps every Android and iOS app with its existing deep
  link, preselects the visitor's platform and adds Windows: Hiddify and
  Clash Verge Rev import directly, v2rayN copies the link.
- fa-IR and ar-EG render right-to-left; URLs, IDs and sizes stay LTR.
- The footer shows the support link and the client refresh interval, so
  subPageContext now carries subUpdates (also in ?format=info).
- Status, days-left and app deep-link logic lives in subPageModel.ts,
  with unit tests pinning the deep links the page already shipped.
2026-09-16 01:55:56 +02:00
Sanaei 01ce2bcecb feat(api-docs): split the API docs page into tabs
The page stacked the WebSocket event cards above every Panel API operation
in one long scroll. The WebSocket events and the 3X-UI Panel API now sit in
separate tabs, and the Panel API shows one OpenAPI tag at a time through
section tabs placed between the Authorize bar and the operations.

The section tabs replace Swagger UI's FilterContainer and wrap the
taggedOperations selector, so all sections share one Swagger instance and
keep authorization and try-it-out state. Swagger's own filter matches tags
by substring ("Settings" would also show "Xray Settings") and does nothing
until set, so the wrapper matches the exact tag and defaults to the first.
Tag names come from the loaded spec rather than importing endpoints.ts,
which would have grown the page chunk from 23 kB to 119 kB.
2026-09-15 23:01:15 +02:00
Sanaei c9e62451e6 fix(outbounds): keep subscription tags on their server when reality params rotate
A subscription outbound's tag must stay bound to the upstream server it
was assigned to for as long as that server stays in the subscription;
balancers and routing rules select by that tag.

The identity used to recognise a server across refreshes included every
query parameter. A 3x-ui upstream picks a random shortId and SNI of a
reality inbound on every request (older releases a random spiderX too),
so no reality link was ever recognised, the stable-tag reservation never
engaged, and every tag was handed out by list position. Removing or
inserting a server then re-pointed existing tags at other servers:
sub-germany carried France, sub-sweden Germany, and Sweden became
sub-sweden-1. The identity now ignores sid, sni and spx when
security=reality, since none of them selects the server. TLS sni still
counts: it can pick the backend behind a shared front.

Two more paths broke the same rule:
- A link repeated in one body (same identity, different remark) shared a
  single link_identities key, so both tags gained a -N suffix on every
  refresh. Repeats are now numbered.
- Links the core rejects were dropped after tagging, so the stored list
  that drives positional reuse was shorter than the parsed one and a
  rotated server behind a dropped link took its neighbour's tag. The
  filter now runs first; a dropped link's warning names its remark
  instead of a tag it never used.

A mapping an older build already swapped stays swapped: its stored
identities no longer match, so positional reuse reproduces it. Deleting
and re-adding the subscription reallocates the tags from the remarks.

Closes #6556
2026-09-15 22:39:59 +02:00
Sanaei 5008906c4c feat(clients): filter the client list by clicking a summary stat card
Each card on the Clients page now toggles its status bucket as the sole
filter, and the Clients card clears it. The bucket filters used to be
wider than the card counts: "active" still included clients near
depletion and "deactive" included disabled clients that had run out, so
a filtered list could disagree with the number on the card. Both filters
now reuse the summary expressions, and a test pins each card's count to
the size of its filtered list.
2026-09-15 22:06:22 +02:00
Sanaei 5fe4f241c1 style(logs): widen the row-count selector in the log modals
At 70px the selector truncated its larger values, so the chosen row
count was hard to read in the panel, Xray and AmneziaWG log modals.
2026-09-15 22:06:21 +02:00
Sanaei e8bab17c2f fix(clients): stop the Edit Client modal showing a stray light scrollbar
The client form body is capped at the viewport and scrolls internally
(49ef1449). Every tab ends with a Form.Item that keeps antd's 24px bottom
margin, so when the fields themselves fit, that empty margin alone pushed
the body past the cap: 752px of content in 740px at a 900px window. The
last item of each tab now drops the margin, so the body scrolls only when
real content overflows.

When it does scroll, the bar was painted light inside the dark modal: the
dark themes set body.dark and data-theme but never color-scheme, which is
what native scrollbars read. applyDom (panel, login and subscription
bundles) and the Storybook decorator now set it on the root element.
2026-09-15 22:04:00 +02:00
Sanaei 14b92fbcff fix(nodes): stop flagging a node on the other update channel as outdated
A node's "update available" tag compares its reported panel version with the
master's latest, and any non-semver side fell back to string inequality. A
dev build reports dev+<sha> (config.GetPanelVersion), so a node moved to the
dev channel from a master on the stable channel kept the tag forever; the
reverse, a stable node under a master on the dev channel, was flagged too and
the tag's default stable update installed nothing new.

A dev label and a release tag carry no order, so the comparison now only
decides within one channel; dev-to-dev still compares commits, which keeps a
node on the current dev-latest commit untagged as config.go intends.
2026-09-15 21:21:36 +02:00
NgaiYeanCoi 1d85ef138e fix(sub): prevent default profile page URL disclosure (#6538)
* fix(sub): prevent default profile page URL disclosure

Add explicit none, builtin, and custom profile page modes.
Preserve existing custom URLs and warn before exposing the built-in page.
Cover mode selection, legacy settings, and subscription response headers.

* fix(subscription): add profile page link options and upgrade notes
2026-09-15 21:13:29 +02:00
Sanaei 3fa44915c1 perf(nodes): keep the node table element across unrelated re-renders
rc-table re-runs every cell renderer whenever the Table re-renders, and
NodeList rebuilt its columns and table props on every render (the relative
time formatter was a fresh function each time). Any re-render of the Nodes
page therefore re-rendered all rows even when no node had changed: about
390ms per re-render for 150 nodes in jsdom.

The formatter is now stable and the table element is memoized on its
inputs, so a re-render that leaves the nodes untouched costs 0.5ms. A
heartbeat push that does change the nodes still re-renders every row.
2026-09-15 21:06:32 +02:00
Sanaei 7fc86f87de perf(inbounds): keep unchanged rows and online sets across websocket pushes
Every client_stats push carries the totals of all inbounds, and
applyClientStatsEvent rebuilt each row it listed, so every push replaced
all rows, re-ran the client rollup (a JSON parse of every inbound's
settings) and re-rendered the whole table even when no number moved. Every
traffic push also built new online and active maps, re-running the same
rollup.

Rows are now rebuilt only when their totals or a client's numbers change,
and the previous maps are kept when a push repeats the same sets. Measured
in jsdom with 450 inbounds of 50 clients each: an unchanged client_stats
push went from 7.9ms to 0.6ms with no row rebuilt, and a repeated traffic
push from 13.5ms to 8.3ms without the rollup.
2026-09-15 21:06:32 +02:00
Sanaei 3c1498d806 fix(ldap): apply LDAP enable, disable and cleanup through the bulk paths
The LDAP sync enabled, disabled and detached clients one at a time. Each
per-client call locked the inbound and pushed to its node under that lock
with a 4s timeout, so users sharing an inbound on a node that answers its
status probe but hangs on client writes queued one push timeout apiece:
five users took 20s in the test, and hundreds of directory users behind a
hung node stretched one run over hours. Each changed email was also queued
once per configured tag, repeating a no-op lookup for every extra tag.

Enable and disable now go through BulkSetEnable, and the cleanup through
one BulkDetach per inbound: each inbound is locked, written and pushed
once, and its push stops at the first failure for the reconcile to finish.
The same five users now cost a single push timeout.
2026-09-15 21:06:32 +02:00
Sanaei d1c4e0261b chore(node): cover the sync tick's online prune from the job package
The traffic sync's call that drops online sets of nodes it no longer
fetches had no test: a job-package test cannot install an xray process,
so online state was invisible there and removing the call passed.

SetXrayProcessForTest installs a test process for tests in other packages,
the same kind of seam as Manager.SetRuntimeOverride. The new job test runs
a real tick with a disabled node and a deleted one and fails without the
call.
2026-09-15 20:39:32 +02:00
Sanaei bc49c1a68f fix(node): release a deleted node's metric series and HTTP client
Deleting a node must free what the master keeps per node in memory.

Delete dropped the node's cpu and mem series but not netUp and netDown,
which the heartbeat records too, so each deleted node leaked two tiered
histories. It now drops every NodeMetricKeys entry.

InvalidateNode, called on node edit, disable and delete, cleared only the
cached Remote. The pooled HTTP client and its transport stayed cached until
a later call for the same node pruned them, which a deleted node never
makes. InvalidateNode now drops those too, outside the manager lock; an
edited node pays one fresh handshake on its next call.
2026-09-15 20:39:32 +02:00
Sanaei 3c8cf35734 perf(node): sync up to 32 nodes at once, like the heartbeat
The traffic sync is scheduled every 5s but synced only 8 nodes at a time,
each needing four to seven sequential requests. Past about 125 nodes 80ms
away a tick outlasted its interval, so dashboard traffic, online clients
and quota enforcement moved at a fraction of the intended cadence.

Measured with 150-300 fake nodes over real HTTP, 80ms latency, a dashboard
connected and client-IP sync on:

  SQLite, 300 nodes        8: 25-30s   16: 14-16s   32: 6-9.5s
  SQLite, 150 (20% slow)   8: 24-27s   16: 13-14s   32: 6-7.5s
  Postgres, 150 nodes      8: 13-18s   16: 7.5-10s  32: 6.5-8.3s

No database-locked, pool or writer-queue errors at any setting, and the
merged inbound and client traffic counts matched. Postgres's one-off
adoption tick is slower at 32 than at 16 (16.5s vs 9.7s) as goroutines
wait on its 25-connection pool; steady ticks are fastest at 32.
2026-09-15 20:22:19 +02:00
Sanaei dea7cd9cc1 fix(traffic): reset due inbounds and clients concurrently
The periodic reset job reset every due inbound, then every due client, one
at a time, and each waited on its node: up to 10s per node inbound, and 4s
per attached node inbound for a client. A few hanging nodes stretched a
single run over hours.

Both loops now run eight at a time. With the per-client fan-out of four
that stays within the 32 concurrent node calls the other node fan-outs use.
2026-09-15 20:07:19 +02:00
Sanaei 56bb876d8d fix(node): send one alert for a burst of node transitions
A master-side network blip flips every node in one heartbeat tick, and
each node published its own node.down, then node.up. A notifier queue holds
64 events and the rate limiter keys on the node name, so with 150 nodes most
alerts were dropped and the rest ran into Telegram and Discord limits.

Past five same-direction transitions in one tick the heartbeat publishes a
single event per direction naming the nodes (the first ten, sorted, then
+N). Smaller ticks keep per-node events with their health data, and the
notifiers already read the node name from Source, so no formatter changed.
2026-09-15 20:07:19 +02:00
Sanaei eb11e8c85a fix(node): fan out operations that call every node
An operation that calls every node has to finish inside the panel's 30s
write timeout. Reset all traffic, UpdatePanels and bulk inbound delete
walked the nodes one at a time, up to 10s per hanging node, so 15 hanging
nodes out of 150 kept each request running for 2m41s while the browser
had already been told it failed.

All three now fan out through fanoutInboundResults, bounded by
nodeFanoutConcurrency (32, the heartbeat's bound), and UpdatePanels keeps
its results in request order. Bulk delete still removes the rows one at a
time, since each rewrites shared routing references, and only fans out the
node pushes that delInbound now hands back.
2026-09-15 20:07:18 +02:00
Sanaei a84bbeab2e fix(node): drop online clients and sub-nodes of nodes no longer synced
What the master derives from a node's reports (online clients, active
inbounds, learned sub-nodes) must live only while that node is still
synced; ClearNodeOnlineClients states it: a downed node must not keep its
clients listed as online.

Only a failed snapshot fetch cleared the online set, and only a failed
probe cleared sub-nodes. A disabled node (both jobs skip it), a node marked
offline before the sync tick reached it, a deleted node, and a node whose
snapshot fetched but failed to merge all kept their clients online in
onlineClients, onlineByGuid and activeInbounds, which the dashboard and a
parent master's /clients/onlines read. Disabled and deleted nodes also kept
their sub-nodes on the Nodes page until the panel restarted.

The traffic sync now keeps online sets only for enabled, online nodes in
its list, the heartbeat keeps sub-nodes only for enabled listed nodes, both
before the empty-list return, and a failed merge clears like a failed
fetch. The sync job's one-line call has no job-level test: that package
cannot install the xray process, so RetainSyncedNodeOnlineClients carries
the tested rule.
2026-09-15 19:27:38 +02:00
Sanaei ea66aa4971 fix(traffic): push depletion changes to nodes off the serial writer
Node I/O on the traffic-accounting path must never stall accounting; the
serial writer states it ("Keep network I/O (node pushes) OUT of fn").

AddTraffic still applied the depletion UpdateInbound for every node
inbound inside the writer closure, one at a time with context.Background.
One hanging node held the single writer for each push, freezing traffic
polls, node snapshot merges and every client edit for the whole wave; a
client shared by 150 nodes expiring could hold it for tens of minutes.
The opt-in restart on client disable then ran node by node on the same
traffic job.

Remote plans now leave the writer and go through nodePushPlan and the 4s
nodePushContext, fanned out like client pushes: an offline or slow node
defers to the reconcile its dirty flag already schedules. The node restart
runs in its own goroutine, since nothing replays or waits on it.

TestTrafficDisableImmediatelyUpdatesNodeRuntime called addTrafficLocked
directly, which pinned the push inside the writer; it now calls AddTraffic
and still requires the push to have landed on return.
2026-09-15 19:04:55 +02:00
BlindMaster24 cfa8350d10 fix(clients): keep a vless reverse client's handler across a re-add (#6558)
* fix(clients): keep a vless reverse client's handler across a re-add

RemoveUser also drops the client's reverse outbound handler, and the account
every live remove/re-add path rebuilt carried no reverse at all: buildUserAccount
read id/flow/testseed/testpre and nothing else. Editing, bulk re-enabling, quota
renewal and adding a client to an existing inbound therefore left a reverse
client able to connect but not to open its tunnel until Xray restarted, with
nothing logged. A traffic reset is the route operators hit most, since a
depleted client is removed and re-added on every renewal.

buildUserAccount now carries the tag (it accepts either the settings JSON object
or a typed client value), and the five account maps those paths build include
the client's reverse. Core chain, read from the pinned xray-core:
AddUserOperation -> User.ToMemoryUser -> vless.Account.AsAccount copies Reverse
(proxy/vless/account.go:24), and GetReverse rebuilds the handler from the stored
account's tag (proxy/vless/inbound/inbound.go:193-205).

Each path has a test that fails without its fix; the account-level test fails on
both input shapes.

* refactor(clients): drop an account map helper nothing calls

Local.AddClient and Local.UpdateUser are only reachable through runtime.Runtime,
and all four call sites of those two methods sit in a node branch, where the
runtime is a *Remote -- Remote.AddUser ignores the map and pushes the inbound
snapshot instead. So the extraction and its test covered a path no deployment
takes, the reverse key it added could never reach a core, and the previous
commit's claim that the node-push paths go through it was wrong.

The four account maps that do reach buildUserAccount are untouched. Reported by
the PR review.
2026-09-15 18:00:54 +03:00
Sanaei af466b6a24 fix(node): push a node only the client IPs it hosts
A master's per-node sync must scope what it sends to the clients that node
serves, so its cost tracks the node and not the fleet. The global-usage
push already did (node_client_traffics by node_id); the 10s client-IP push
sent GetAllInboundClientIps, the whole table, to every node.

Each node's MergeInboundClientIps then created a row for every foreign
email, and its next GET clientIps echoed the whole fleet back. Its IP-limit
job only ever reads rows for its own clients, so none of it was used. With
150 nodes x 150 clients, one IP tick pushed 299 MB and pulled 264 MB, every
node held 22,500 rows instead of 150, and sync ticks grew 3.8s -> 10.2s
even at 1ms latency; the cost grows with the square of the fleet.

Both pushes now share nodeHostedEmails. After the change the same fleet
moves 2.0 MB / 1.8 MB per tick and ticks stay near 3.2s. Nodes upgraded
with foreign rows shed them within 30 minutes via pruneStaleIpRows.
2026-09-15 16:28:31 +02:00
Sanaei 789a03065a chore(docs): bump dependencies and adapt to fumadocs-core 16.15.11
Updates the docs site's dependencies, including the Fumadocs packages,
Next 16.3.5, React 19.3 and three majors: mermaid 12, vitest 5 and
pnpm 12. Two code changes follow from the bump:

- fumadocs-core 16.15.11 makes `llms().index()` return a Promise, so
  the llms.txt route now awaits it; tsc rejected the old synchronous
  call
- lucide-react 1.46 renamed the BookMarked icon to BookBookmark. The
  old name is still exported, but lucideIconsPlugin looks names up in
  lucide's `icons` map, which only has the new one, so the Reference
  section lost its sidebar icon in all four locales. The build only
  printed a warning.

minimumReleaseAgeExclude gains entries for the newly installed
versions.

Checked with typecheck, lint, vitest (106 tests) and a full build: no
plugin warnings, and each locale's rendered /docs page contains the
book-bookmark icon.
2026-09-15 16:00:05 +02:00
Sanaei bc424f0968 fix(xray): stop a lone dns qType 0 from matching every query
The core reads a dns rule's qType as a PortList, which drops a bare numeric
0 (infra/conf/common.go: `if number != 0`), and a rule with no qTypes
matches every query. A stored `"qType": 0` therefore does not target query
type 0: it drops, refuses or hijacks all DNS through that outbound.

A qType the panel writes has to be read by the core as exactly the query
types it names. Four writers broke that:

- DNSOutboundLegacyKeysFix rewrote a lone blockTypes [0] into "qType": 0,
  so "block type 0" became "block everything" on upgrade.
- That seeder shipped in v3.8.0 and is recorded as done, so fixing it does
  not reach installs that already ran it. DNSOutboundQTypeZeroFix spells
  any stored numeric qType 0 as "0" once, protocol id matched like the core.
- The outbound form adapter turned a typed "0" into the number 0.
- The Xray template editor saves raw JSON past that adapter; the save now
  applies the same rewrite.

Each writer is pinned by a test that fails without its part. The rewrite
and the repair compare policies as the pinned core builds them, and the
repair runs through runSeeders over a database whose legacy-keys seeder
already ran, on SQLite and PostgreSQL 16.
2026-09-15 16:00:05 +02:00
BlindMaster24 ac3fc12077 fix(ports): refuse an inbound on a port an AmneziaWG peer forwards (#6554)
* fix(ports): refuse an inbound on a port an AmneziaWG peer forwards

checkForwardedPortsConflict only ever ran from the AmneziaWG save path, and only
in one direction: an AmneziaWG client's forwardedPorts were checked against the
ports other inbounds already hold, while the reverse -- an ordinary inbound
saved onto a port some peer forwards -- had no guard at all. The forward
listener binds that port on every interface in both directions
(amneziawgnet/portfwd.go's attachTCP/attachUDP), so the two listeners want the
same socket: the loser either leaves the peer's forward silently dead or fails
the inbound's listen.

checkPortConflictTx now resolves that owner the same way the relay-slot checks
do -- same host, peers derived from the stored settings with the shared
InstanceFromInbound -- and names the peer in the refusal. Sitting inside
checkPortConflictTx covers both the save and the enable path added in #6549.

TestAddInboundRefusesAPortAnAmneziaWGPeerForwards fails without this -- watched
red, the create is allowed -- and its node-row companion pins the scoping that
keeps a node row legal on a locally forwarded port.

* fix(ports): name only a peer that binds as the owner of a forwarded port

The owner lookup read instance.Peers and ForwardedPortsInclude directly, so a
peer the forward supervisor skips (no email, or no address the tunnel routes
to) was reported as holding a port nothing binds -- refusing a create that is
legal with a message naming a row whose own port is its WireGuard one. It also
repeated the candidate's listen address as the forward's location, though the
forward binds :port on every interface.

Share the supervisor's own gate through amneziawgnet.ForwardedPortOwner, report
the wildcard bind, and propagate a failed owner query instead of reading it as
"no conflict", matching the sibling checks in the same file.

* style(ports): keep the forwarded-key doc block within the 2-line cap

The reworded desiredPortForwardKeys doc ran to three lines, against the rule
this repo sets for committed Go comments.
2026-09-15 16:58:21 +03:00
BlindMaster24 d9c7c76fb0 fix(limit-ip): leave a reverse client out of the temporary disconnect (#6553)
* fix(limit-ip): leave a reverse client out of the temporary disconnect

The LIMIT_IP cycle removes the client and adds it back 100 ms later. For a vless
client carrying a reverse config that is not reversible: RemoveUser calls
RemoveReverse and deletes the client's outbound handler, while the account added
back is built without the reverse field, so the tunnel stays down until Xray
restarts and the core's forward-proxy guard for that client no longer fires
(proxy/vless/inbound/inbound.go:245 and :542-544 at the pinned core). The cycle
now skips such a client and says so, instead of trading a limit violation for a
tunnel that needs a restart to come back.

TestDisconnectClientTemporarilySkipsReverseClient fails without this -- watched
red, the client is removed and re-added -- and asserts the skip is logged rather
than silent.

* style(limit-ip): keep the reverse-client comment within the 2-line cap

The block explaining why a reverse client is skipped was three lines, against
the rule this repo sets for committed Go comments; the same why fits in two.
2026-09-15 16:57:23 +03:00
sdhfsl d440c2b932 fix(panel): accept 2FA codes from adjacent TOTP windows (#6546)
* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
2026-09-15 15:42:30 +03:00
BlindMaster24 e790f46757 fix(xray): restart when a diff strands a client's live session (#6550)
* fix(xray): restart when a diff strands a client's live session

Disabling or deleting a client took it out of the generated config and the
hot path applied that with AlterInbound/RemoveUser, which only drops the
credential (vless, vmess, trojan and shadowsocks all keep the established
session running) -- so the panel showed a disabled client whose connection
kept passing traffic, and the core offers no API to close one session.

A diff that removes a user without re-adding the same email under the same
tag is that case: honour the operator's restart-on-client-disable setting and
let the caller replace the process, which is already how an auto-disabled
client loses its session. An edit re-adds the email and keeps the hot path.

* chore(i18n): cover manual disable and delete in the restart-setting description

The setting now also decides what happens when a client is disabled or deleted
by hand, so the description cannot keep naming only the automatic path. All 13
locales updated in the same commit to keep the wording consistent.

* fix(xray): reach the guard from the manual switch and from every protocol

Round-1 findings on this PR. The guard sat in tryHotApply, but a manual disable
or delete applies through runtime.Runtime and finishes with needRestart false,
so none of the three RestartXray schedulers fired and the predicate was never
reached: the session in #6533 kept flowing. The apply layer now asks for the
restart the setting promises when the client actually leaves the config, on the
single-client update and delete paths and on bulk disable, and only for local
inbounds so a node row cannot make the master restart its own core.

The predicate itself could not fire for shadowsocks or hysteria either, because
RemovedUsers is only produced for the protocols diffInboundUsers will diff. The
diff now also compares settings.clients of an inbound present in both configs,
which is the one shape every account list shares, so those protocols reach the
guard through the inbound instead of through nothing.

TestManualClientDisableHonoursRestartSetting fails without the apply-layer fix
("needRestart = false, want true" with the setting on) and
TestHotDiffDropsUsersOnProtocolsItCannotDiff fails without the diff fix -- both
watched red. The two three-line comments this PR added are back inside the cap.

* docs(i18n): stop scoping restartXrayOnClientDisable to auto-disable

The setting now covers a client disabled or deleted by hand as well, so its
title no longer says "Auto" in all 13 locales, and the docs callouts in en, ru,
zh and fa describe the same behaviour instead of the auto-only one.
2026-09-15 15:39:40 +03:00
BlindMaster24 d089adeeea docs(limit-ip): correct what the temporary disconnect can actually do (#6551)
* docs(limit-ip): correct what the temporary disconnect can actually do

The comment claimed removing and re-adding a user "disconnect[s] all
connections". RemoveUser only clears the core's credential validator in vless,
vmess, trojan, shadowsocks and hysteria alike, so a session already up keeps
running and the fail2ban ban on the logged IP is what ends the traffic. Comment
only: the protocol gate and its test are untouched.

* docs(limit-ip): say what the disconnect cycle really does per protocol
2026-09-15 15:30:55 +03:00
BlindMaster24 4a8fdceed6 perf(nodes): reuse one pooled client per node instead of rebuilding it (#6548)
* perf(nodes): reuse one pooled client per node instead of rebuilding it

The heartbeat probe asks for a client every 5s per node, and for skip, pin and
mtls modes HTTPClientForNode built a client with its own transport each time:
every tick paid a full TCP+TLS handshake per node, which is the CPU a 100-node
fleet reports. Cache the client per node identity, close the previous one when
that identity changes, and raise the idle pool caps above any real fleet size
so a node's connection survives to its next tick.

* perf(nodes): keep one client per node in the pooled cache

Round-1 findings on this PR. The eviction dropped only entries whose key did not
start with the current identity, so every proxy variant of that identity stayed
for the life of the process. That variant is often a fresh loopback port:
withOutboundBridge mints one per call and tears the bridge down on return, so
each operator "test node" or remote-inbounds action added a client whose key can
never be hit again, and a node switched to verify mode orphaned its old entry by
returning before the loop. Replacing that filter with one entry per node bounds
the cache at the fleet size, and the verify-mode return now clears the node too.

TestHTTPClientForNodeKeepsOneClientPerNode fails without this -- watched red,
"2, want 1" -- and pins the verify-mode cleanup on the same cache.

* style(nodes): keep the eviction comment inside the two-line cap
2026-09-15 15:29:49 +03:00
BlindMaster24 574caa63e9 fix(inbounds): check ports when an inbound is enabled, not only when it is saved (#6549)
* fix(inbounds): check ports when an inbound is enabled, not only when it is saved

The save-time guards compare enabled rows, so a row could be created while
another disabled row held its port and only collide once the disabled one was
switched on. Run the same checks before the flag moves: the refusal names the
row that owns the port, the flag is left alone, and tcp/udp coexistence and
node rows keep working.

* docs(inbounds): state the real reason the enable path needs its own check
2026-09-15 15:29:32 +03:00
BlindMaster24 baef3cdd07 fix(xray): refuse a config the running core cannot bind (#6547)
* fix(xray): refuse a config the running core cannot bind

RestartXray stopped a working core before handing it a config whose listens
collide, so the failed bind exited the whole process (main/run.go:94) and the
one-second watchdog retried it in a loop: every protocol down, cause only in
the logs. The save-time port guards cannot cover this -- SetInboundEnable, the
AmneziaWG relay created on the first peer, template and bridge edits all reach
a colliding config with no guard on that path.

Probe the generated config at the single restart funnel instead. Collisions the
running core already serves are excused, so an established setup is never
refused by a static read being wrong about it, and the port-bucketed pass costs
nothing on a clean config.

* fix(xray): surface a refused config and re-key the bind excuse set

Round-1 findings on this PR. Refusing the swap left the running core on its
previous config with nothing but a log line to show for it, so the status
response now carries the reason while the core runs and the overview marks it;
the node list picks the same field up through that response. The excuse set is
keyed on the two listens, the port and the shared transports instead of the tag
pair, so a pair whose listen moves onto the other's address is refused again,
while the same two sockets stay excused however the generator orders them.

TestBindConflicts/excused_pair_whose_listen_changed_into_a_real_collision fails
without the key change -- watched red first.
2026-09-15 15:27:28 +03:00
BlindMaster24 43e64993fc fix(amneziawg): refuse a row's own relay port and keep a disabled row's slot reserved (#6544)
* fix(amneziawg): refuse a WireGuard port that is the row's own relay port

All three relay checks filter themselves out of the candidates with id !=
ignoreId, so nothing ever compared an AmneziaWG row's own WireGuard listen port
with the relay port its own id derives. Saving a row on that exact port left the
embedded device (UDP on the inbound's listen address, amneziawgnet/device.go:137)
and its injected relay (TCP and UDP on 127.0.0.1, amneziawgnet/relay.go:47-61)
bound to the same UDP port, so whichever loses the race dies -- and when the
relay loses it, Xray refuses the whole config and takes every other protocol on
the host with it. The first AmneziaWG inbound on port 65101 was enough to reach
it: id 1 derives exactly that port.

The row now states the rule its three siblings do: it owns the slot its id
derives. A node-hosted row still keeps its own port, since it binds no relay on
this host.

TestAddInbound_AmneziawgRefusesItsOwnRelayPort and
TestUpdateInbound_AmneziawgRefusesItsOwnRelayPort fail without this -- both were
watched red first -- and pin the two separate call sites, AddInbound's post-Save
block and checkPortConflictTx's ignoreId > 0 block.

* fix(amneziawg): keep a disabled row's relay port reserved for port forwards

loadPortConflictContext filtered its query with enable = true, so a client's
ForwardedPorts spec could claim the relay port a disabled AmneziaWG row's id
derives. That row's relay appears with its first client -- a path that runs no
port check -- and when the relay then loses the loopback bind race to the
forward listener, Xray refuses the whole config instead of losing one forward
(#6542 review, arrived with #6540).

The context now loads every local row and gates only the ordinary-port compare on
enable, which is what a disabled row's own port is worth: free. Its relay slot is
not free, which is the rule #6540 already states for the other two guards.

TestCheckForwardedPortsConflict_DisabledAmneziawgRelayPortIsReserved fails
without this -- watched red first -- and passes with it, while
TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort keeps proving that a
disabled inbound's own port stays available.

* fix(amneziawg): re-run the forward guard once a new row has its own ports

normalizeAmneziaWGSettings validates every client's ForwardedPorts before the row
is saved, and loadPortConflictContext then reads the database -- so the new
AmneziaWG row is never a candidate for itself. A client could forward exactly the
relay port the row's own id derives, or its own WireGuard listen port, and the
create was accepted: at runtime the panel's wildcard forward listener and Xray's
127.0.0.1 relay race for the same port, and a lost relay bind makes Xray refuse
the whole generated config (#6544 review, pre-existing).

The post-Save block is the only place the id is known, so it re-runs the guard
there. Both callers now share amneziaWGForwardedPortsConflict, so the collision
message lives in one place instead of two.

TestAddInbound_AmneziawgRefusesAClientForwardingItsOwnRelayPort fails without
this -- watched red first -- and passes with it.

* fix(amneziawg): stop blocking stored forward specs on a disabled row's slot

Round 2 flagged this PR's widening as the one MEDIUM it introduced, and the code
confirms it: UpdateInboundClient carries a stored ForwardedPorts spec forward for
a partial edit (client_inbound_apply.go:763-765) and re-validates it (:772 and
:909), so after an in-place upgrade an edit that never submitted the field -- a
bot enable/expiry toggle -- is refused over a slot the operator did not touch,
for a relay injectAmneziawgnetSocks does not emit while the row is disabled. The
inbound-save path re-validates every stored spec the same way.

The trade does not pay for itself: the slot this reserves is claimable only by a
spec an operator authors onto 65101-65535, while the cost lands on unrelated
operations. The precise fix -- refuse a newly claimed spec rather than a stored
one, and check the enable transition in SetInboundEnable, where the conflict is
actually created -- is larger than the hole, so the slot goes back to a
documented pre-existing item with its own follow-up.

The create-path re-run added in 80eb5712 is unaffected: it reads the settings
submitted in the same request, so it never refuses a stored value, and its test
still passes.
2026-09-15 13:31:07 +03:00
BlindMaster24 d52b598abf fix(amneziawg): reserve the relay port before an AmneziaWG inbound has a peer (#6542)
* test(amneziawg): pin that a peerless inbound still owns its relay port

checkAmneziawgnetSocksConflict skips a candidate whose settings yield no
qualifying peer, and normalizeAmneziaWGSettings writes Clients: [] for a fresh
AmneziaWG inbound -- so a newly created row reserves nothing, an ordinary
inbound can take its derived port, and adding that row's first client then puts
two inbounds on 127.0.0.1:65101. The client paths run no port check.

Expected red on this head; the fix follows.

* fix(amneziawg): reserve the relay port before the first peer is added

checkAmneziawgnetSocksConflict skipped a candidate whose settings yield no
qualifying peer (amneziawg.InstanceFromInbound), and normalizeAmneziaWGSettings
writes Clients: [] for a fresh AmneziaWG inbound. A newly created row therefore
reserved nothing, an ordinary inbound could be saved onto the port that row
derives, and adding its first client generated the relay next to it: two inbounds
on 127.0.0.1:65101, which makes Xray refuse the whole config and take every other
protocol on the host down with it. Nothing re-checked it later either -- only
AddInbound and UpdateInbound run checkPortConflictTx, and the client paths that
create the first peer run no port check at all.

Ownership now follows the row, so the check states the same rule as its two
siblings, which key on protocol and node_id IS NULL alone. The amneziawg import
goes with the guard.

TestCheckPortConflict_AmneziawgnetSocksRelayReservedBeforeTheFirstPeer fails
without this, on a test-only head whose go-test run failed on exactly that test,
and passes with it.

* docs(amneziawg): stop the forward check's doc block claiming every row gets a relay

Round-1 LOW: the block's justification clause read "every one of them gets a
relay inbound", which is false for exactly the rows this change newly reserves
for -- injectAmneziawgnetSocks skips a row with no peer email, and that is the
row whose port must stay reserved. A reader following the cross-reference landed
on the guard this branch removes and read it as the rule.

Replaced by the two facts that are true, which also brings the block under
CLAUDE.md's two-line cap instead of twelve lines over it. The peerless reason
stays where it is load-bearing, in the two-line comment above the candidate loop.
2026-09-15 11:49:18 +03:00
BlindMaster24 2d8d304850 fix(amneziawg): stop a disabled inbound's relay slot from being taken (#6540)
* test(amneziawg): pin that a disabled row still owns its relay slot

checkAmneziawgnetSocksConflict filters enable = true, so a disabled AmneziaWG
row is not a candidate when an ordinary inbound's configured port is validated.
SetInboundEnable then flips the column with no port check, so enabling that row
later puts a second inbound on 127.0.0.1:65101 and Xray refuses the whole config.
Expected red on this head; the fix follows.

* fix(amneziawg): count a disabled inbound as owning its relay slot

The forward port check filtered its candidates with enable = true, so a disabled
AmneziaWG row was invisible when an ordinary inbound's configured port was
validated. Nothing else covered the gap: the relay is not a database row, and
SetInboundEnable flips the column with no port check, so re-enabling that row put
a second inbound on 127.0.0.1:65101 and made Xray refuse its whole config,
taking every other protocol on the host down with it.

A row owns the slot its id derives for as long as the row exists, which is the
rule the reverse-direction check already follows. TestCheckPortConflict_
DisabledAmneziawgStillOwnsItsRelaySlot fails without this, on a test-only head
whose go-test run failed on exactly that test, and passes with it.

* test(amneziawg): drop the disabled-row case that asserts the reversed rule

TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled stated, in its
name and its doc comment, that a disabled AmneziaWG inbound's port must not
block anything -- the rule the parent commit reverses. It also never reached the
predicate it named: its fixture seeds Settings: {}, which
amneziawg.InstanceFromInbound rejects on parsed.Server == nil one statement
before the enable column is read, so it passed with or without the filter.

Leaving it would document both rules for the same operator state with nothing
failing to flag the contradiction. The rule this PR pins is covered for real by
TestCheckPortConflict_DisabledAmneziawgStillOwnsItsRelaySlot, whose fixture
carries a qualifying server block and an enabled peer.
2026-09-15 11:33:02 +03:00
BlindMaster24 a036ddd66f fix(amneziawg): wrap the relay port window instead of refusing ids past it (#6539)
* fix(amneziawg): wrap the relay port window instead of refusing ids past it

An AmneziaWG inbound's loopback relay port is SOCKSBasePort + row id, and
AddInbound refused any id that pushed it past 65535. The inbounds table is
AUTOINCREMENT, so an id is never reused and the counter is only reset when the
table empties: the 435-port window was a lifetime budget, and a database that
had ever created more inbounds could never create another AmneziaWG one --
the reporter's counter sits at 70350, so the protocol never worked there at all
(#6537).

Ids now wrap into the same 435 ports, which leaves every id up to 435 with the
exact port it had, so no existing row, relay or generated config moves.

Wrapping makes the id -> port map non-injective, and nothing compared two
derived relay ports before -- two relays on one port would leave Xray with a
duplicate listen and refuse to start, taking the whole panel's proxy down.
checkAmneziawgnetSocksRelayCollision now refuses a create or an edit whose
derived port another local AmneziaWG row already owns, disabled rows included:
a row owns its slot for good, and enabling it later re-runs no port check.

* test(amneziawg): give each relay-window fixture its own client email

Every fixture built the same client email, and an email is unique across the
whole panel, so AddInbound refused the second create with "Duplicate email"
before either new guard ran -- CI exercised neither the wrap nor the collision
refusal. Each fixture now derives its email from its own tag, which is what the
tag already exists for.

* fix(amneziawg): say relay port in the relay conflict message

A refusal that named the port of the automatic loopback relay read as if the
named inbound listened on an unrelated port -- its own port is the WireGuard
one. portConflictDetail now carries Relay, and both messages that report a
derived relay port say "relay port N"; messages that report a configured port
render byte-for-byte as before.

* test(amneziawg): pin that a node-assigned inbound owns no relay slot

A row adopted from a node carries a NodeID and the protocol it arrived with
(inbound_node.go:737), yet injectAmneziawgnetSocks skips it, so it binds no
loopback relay. The gate this PR added to checkPortConflictTx never looked at
NodeID, so editing such a row can be refused for a slot it does not own.
Expected red on this head; the fix follows.

* fix(amneziawg): skip the relay guards for node-assigned inbounds

Round-2 review finding: the gate this PR added to checkPortConflictTx keyed on
inbound.Protocol alone, so it also ran for a row adopted from a node. Such a row
carries a NodeID and gets no loopback relay -- injectAmneziawgnetSocks skips it
and the desired-instance query is node_id IS NULL -- so it owns no slot and can
collide with nothing, yet editing it was refused with "relay port N ... already
used by inbound '<local>'", naming a port the edited row never binds.

Wrapping made this visible: before it, an adopted id above 435 derived a port
above 65535 that no row could hold, so the pre-existing reverse check under the
same gate could not fire.

Both call sites now require NodeID == nil, matching the local-only predicate the
forward check already used. TestCheckPortConflict_NodeAssignedAmneziawgOwnsNoRelaySlot
fails without this, with the exact false refusal, and passes with it.
2026-09-15 10:07:14 +03:00
BlindMaster24 78ab7a9246 fix(amneziawg): read the outbound pseudo-protocol id like the core (#6531)
* fix(amneziawg): read the outbound pseudo-protocol id like the core

IsAmneziaWGOutbound compared the id exactly while every reader around it does
not: the probe lane already reads the same id with strings.EqualFold
(outbound/probe_http.go, pinned by TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore),
and the core lowercases a protocol id before it resolves the handler.

A template entry spelled "AmneziaWG" therefore stayed unbridged in two paths.
transformAmneziaWGOutbounds skipped it and handed the raw pseudo-protocol to
the core, which answers "unknown config id: amneziawg" -- Xray then fails to
start, since bridging is what makes that entry a socks outbound. The amneziawg
job skipped it too, so the reconcile loop never created the instance and the
outbound silently carried no tunnel.

The exact comparison also made the save path answer two ways for one spelling:
CheckXrayConfig routed the exact match to the panel's own validator and the
case variant to the core's, so the operator was told the core does not know a
protocol the panel implements (probe output, before: `xray core rejects
outbound "t1": infra/conf: unknown config id: amneziawg` for "AmneziaWG" and
`amneziawg outbound "t1": privateKey is required` for "amneziawg"; after: the
panel's own message for both).

Reachable only from a template that did not come through the panel's save,
which rejects the case variant today -- a restored backup, a direct DB edit, a
scripted template, or a legacy DB. That is the same class of data the
UppercaseFreedomFinalRulesFix seeder exists to repair, so the panel already
treats non-lowercase protocol ids as real operator input.

strings.EqualFold is the whole change; the package already imports strings.

* style(service): trim the amneziawg outbound test comment to two lines

The review flagged the three-line block: CLAUDE.md caps a committed Go
comment block at two lines and the test name already carries the what. The
remaining two lines keep the why — the core folds the id's case before
resolving it, so a mixed-case spelling must bridge here too.
2026-09-15 08:23:18 +03:00
BlindMaster24 a810f497e6 fix(xray): read the last two inboundTag protocol ids like the core (#6530)
The core lowercases an outbound's protocol id before it resolves the handler,
so an outbound spelled "Loopback" still is the loopback outbound. Both
readers that keep a loopback outbound's inboundTag in step with the inbound
it names compared the id exactly, so such an outbound was skipped: renaming
or deleting that inbound left settings.inboundTag pointing at a tag that no
longer exists, and traffic returning through the loopback outbound arrives
under a tag no routing rule can match (infra/conf/loopback.go:15 carries the
tag, proxy/loopback/loopback.go:43 uses it as the inbound identity).

The probe lane's "nothing to test here" gate had the same exact comparison,
so a "Freedom"/"Blackhole" outbound reported the vaguer "No testable
endpoint" where the canonical spelling reports "Outbound has no testable
endpoint" — the two spellings took different paths to the same rejection.

Both readers now compare case-insensitively; the outbound package reuses its
existing equalsAnyFold helper rather than adding a second one. The service
reads the config template an operator edits, so a case variant is reachable
there; server.go's GetDefaultLogOutboundTags scans the embedded config.json
instead, whose protocols are canonical by construction, so it is left as is
and no test can tell a case-insensitive read there from an exact one.
2026-09-14 21:18:31 +03:00
173 changed files with 8653 additions and 2548 deletions
+2 -2
View File
@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
export const revalidate = false; export const revalidate = false;
export function GET() { export async function GET() {
return new Response(llms(source).index()); return new Response(await llms(source).index());
} }
+2 -2
View File
@@ -27,8 +27,8 @@ inbounds** at once, with per-client traffic accounting.
| **Comment** | all | Free-text note. | | **Comment** | all | Free-text note. |
<Callout type="info"> <Callout type="info">
Reaching the **traffic** or **expiry** limit disables the client; the panel can Reaching the **traffic** or **expiry** limit disables the client, and a client
restart Xray automatically when clients are auto-disabled disabled or deleted by hand counts too; the panel restarts Xray then
(`restartXrayOnClientDisable`, on by default). (`restartXrayOnClientDisable`, on by default).
</Callout> </Callout>
@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**, - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
**`Announce`** — optional branding shown by some clients. **`Announce`** — optional branding shown by some clients.
### Profile page links and upgrades
In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
subscription clients:
- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
- **Built-in subscription page** (`builtin`): link to the client's built-in page.
- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
the built-in page. After upgrading, an unset mode with an empty or whitespace-only
URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
To restore the built-in link, select **Built-in subscription page** above and
save the settings.
The built-in page exposes subscription URLs and node configurations, including
for Happ encrypted subscriptions. Enable it only if you intend to provide that
access.
### Optional month-end expiry display ### Optional month-end expiry display
Under **Subscription → Information**, **Month-end subscription expiry display** Under **Subscription → Information**, **Month-end subscription expiry display**
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "Reference", "title": "Reference",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+2 -2
View File
@@ -27,8 +27,8 @@ icon: Users
| **Comment** | همه | یادداشت متنی آزاد. | | **Comment** | همه | یادداشت متنی آزاد. |
<Callout type="info"> <Callout type="info">
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ پنل می‌تواند رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ غیرفعال‌سازی یا
هنگام غیرفعال‌شدن خودکار کلاینت‌ها، Xray را به‌صورت خودکار راه‌اندازی مجدد کند حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راه‌اندازی مجدد می‌کند
(`restartXrayOnClientDisable`، به‌صورت پیش‌فرض فعال). (`restartXrayOnClientDisable`، به‌صورت پیش‌فرض فعال).
</Callout> </Callout>
@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) به‌علاوه
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**، - **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
**`Announce`** — برندینگ اختیاری که برخی کلاینت‌ها نمایش می‌دهند. **`Announce`** — برندینگ اختیاری که برخی کلاینت‌ها نمایش می‌دهند.
### لینک صفحه پروفایل
در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
لینک را برای همه کلاینت‌های اشتراک کنترل می‌کند:
- **بدون لینک** (`none`، پیش‌فرض) — هدر `Profile-Web-Page-Url` ارسال نمی‌شود.
- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه می‌شود.
- **وب‌سایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده می‌شود؛ اگر خالی باشد، هدر ارسال نمی‌شود.
**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
خالی یا فقط شامل فاصله باشد، به‌جای لینک خودکار صفحه داخلی، حالت **بدون لینک**
انتخاب می‌شود. آدرس سفارشی غیرخالی قبلی در حالت **وب‌سایت سفارشی** حفظ می‌شود.
برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
را ذخیره کنید. این صفحه آدرس‌های اشتراک و پیکربندی گره‌ها را آشکار می‌کند، حتی
برای اشتراک‌های رمزگذاری‌شده Happ.
## قالب‌های سفارشی صفحه ## قالب‌های سفارشی صفحه
برای برندینگ صفحه‌ی HTML اشتراک، `subThemeDir` را به یک پوشه‌ی حاوی قالب سفارشیِ برای برندینگ صفحه‌ی HTML اشتراک، `subThemeDir` را به یک پوشه‌ی حاوی قالب سفارشیِ
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "مرجع", "title": "مرجع",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+3 -3
View File
@@ -28,9 +28,9 @@ icon: Users
| **Comment** | все | Произвольная текстовая заметка. | | **Comment** | все | Произвольная текстовая заметка. |
<Callout type="info"> <Callout type="info">
Достижение лимита **трафика** или **срока действия** отключает клиента; при Достижение лимита **трафика** или **срока действия** отключает клиента, как и
автоматическом отключении клиентов панель может автоматически перезапускать ручное отключение или удаление; тогда панель перезапускает Xray
Xray (`restartXrayOnClientDisable`, включено по умолчанию). (`restartXrayOnClientDisable`, включено по умолчанию).
</Callout> </Callout>
## Лимиты и контроль IP ## Лимиты и контроль IP
@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**, - **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами. **`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
### Ссылка на страницу профиля
В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
управляет ссылкой для всех клиентов подписки:
- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
адрес сохраняется в режиме **Свой сайт**.
Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
узлов, в том числе для зашифрованных подписок Happ.
## Пользовательские шаблоны страниц ## Пользовательские шаблоны страниц
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "Справочник", "title": "Справочник",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+2 -2
View File
@@ -26,8 +26,8 @@ icon: Users
| **Comment** | 全部 | 自由文本备注。 | | **Comment** | 全部 | 自由文本备注。 |
<Callout type="info"> <Callout type="info">
达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以 达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。 此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
</Callout> </Callout>
## 限制与 IP 控制 ## 限制与 IP 控制
@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。 - **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。 - **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
### 资料页链接与升级说明
在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
## 自定义页面模板 ## 自定义页面模板
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。 将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"title": "参考", "title": "参考",
"icon": "BookMarked", "icon": "BookBookmark",
"pages": ["env-vars", "database", "ports-firewall", "api"] "pages": ["env-vars", "database", "ports-firewall", "api"]
} }
+20 -20
View File
@@ -18,34 +18,34 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"fumadocs-core": "^16.15.5", "fumadocs-core": "^16.15.11",
"fumadocs-docgen": "^3.1.0", "fumadocs-docgen": "^3.1.1",
"fumadocs-mdx": "^15.4.0", "fumadocs-mdx": "^15.4.1",
"fumadocs-openapi": "^11.4.0", "fumadocs-openapi": "^11.4.3",
"fumadocs-ui": "^16.15.5", "fumadocs-ui": "^16.15.11",
"lucide-react": "^1.39.0", "lucide-react": "^1.46.0",
"mermaid": "^11.17.2", "mermaid": "^12.0.0",
"next": "16.3.4", "next": "16.3.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.8", "react": "^19.3.0",
"react-dom": "^19.2.8", "react-dom": "^19.3.0",
"react-qr-code": "^2.2.0", "react-qr-code": "^2.2.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.7.0",
"zbsearch": "4.0.0", "zbsearch": "4.0.0",
"zod": "^4.5.4" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/mdx": "^2.0.14", "@types/mdx": "^2.0.14",
"@types/node": "^26.4.1", "@types/node": "^26.5.1",
"@types/react": "^19.2.18", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.3.0",
"oxfmt": "0.66.0", "oxfmt": "0.68.0",
"oxlint": "1.81.0", "oxlint": "1.83.0",
"postcss": "^8.5.26", "postcss": "^8.5.28",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "^4.1.11" "vitest": "^5.0.1"
}, },
"packageManager": "pnpm@11.25.0" "packageManager": "pnpm@12.4.2"
} }
+1284 -1099
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
- mermaid@11.17.0 - mermaid@11.17.0
- lucide-react@1.33.0 - lucide-react@1.33.0
- postcss@8.5.26 - postcss@8.5.26
- fumadocs-mdx@15.3.0 - fumadocs-mdx@15.3.0 || 15.4.1
- '@fumadocs/api-docs@0.2.7' - '@fumadocs/api-docs@0.2.7 || 0.2.9'
- '@types/node@26.4.1' - '@types/node@26.4.1'
- fumadocs-core@16.15.5 - fumadocs-core@16.15.5 || 16.15.11
- fumadocs-openapi@11.4.0 - fumadocs-openapi@11.4.0 || 11.4.3
- fumadocs-ui@16.15.5 - fumadocs-ui@16.15.5 || 16.15.11
- '@fumadocs/tailwind@0.1.2'
- '@fumari/image-size@0.1.1'
- '@fumari/stf@1.1.1'
- '@vitest/mocker@5.0.1'
- '@vitest/spy@5.0.1'
- fumadocs-docgen@3.1.1
- vitest@5.0.1
+8
View File
@@ -395,6 +395,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -618,6 +621,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -1046,6 +1050,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -1277,6 +1284,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
+1
View File
@@ -22,6 +22,7 @@ export const withTheme: Decorator = (Story, context) => {
useLayoutEffect(() => { useLayoutEffect(() => {
document.body.classList.remove('dark', 'light'); document.body.classList.remove('dark', 'light');
document.body.classList.add(dark ? 'dark' : 'light'); document.body.classList.add(dark ? 'dark' : 'light');
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
}, [dark]); }, [dark]);
return ( return (
+8
View File
@@ -395,6 +395,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -618,6 +621,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -1046,6 +1050,9 @@
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -1277,6 +1284,7 @@
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
+2
View File
@@ -15,6 +15,8 @@ interface SubPageData {
subJsonUrl?: string; subJsonUrl?: string;
subClashUrl?: string; subClashUrl?: string;
subTitle?: string; subTitle?: string;
subSupportUrl?: string;
subUpdates?: number;
links?: string[]; links?: string[];
emails?: string[]; emails?: string[];
datepicker?: 'gregorian' | 'jalalian'; datepicker?: 'gregorian' | 'jalalian';
+2
View File
@@ -115,6 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 1,
"subProfileMode": "",
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "", "subRoutingRules": "",
"subShowIdentityOnAllLinks": false, "subShowIdentityOnAllLinks": false,
@@ -271,6 +272,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 1,
"subProfileMode": "",
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "", "subRoutingRules": "",
"subShowIdentityOnAllLinks": false, "subShowIdentityOnAllLinks": false,
+8
View File
@@ -369,6 +369,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -592,6 +595,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
@@ -1020,6 +1024,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileMode": {
"type": "string"
},
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
@@ -1251,6 +1258,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subListen", "subListen",
"subPath", "subPath",
"subPort", "subPort",
"subProfileMode",
"subProfileUrl", "subProfileUrl",
"subRoutingRules", "subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
+2
View File
@@ -122,6 +122,7 @@ export interface AllSetting {
subListen: string; subListen: string;
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileMode: string;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string; subRoutingRules: string;
subShowIdentityOnAllLinks: boolean; subShowIdentityOnAllLinks: boolean;
@@ -279,6 +280,7 @@ export interface AllSettingView {
subListen: string; subListen: string;
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileMode: string;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string; subRoutingRules: string;
subShowIdentityOnAllLinks: boolean; subShowIdentityOnAllLinks: boolean;
+2
View File
@@ -136,6 +136,7 @@ export const AllSettingSchema = z.object({
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(1).max(65535),
subProfileMode: z.string(),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(), subShowIdentityOnAllLinks: z.boolean(),
@@ -294,6 +295,7 @@ export const AllSettingViewSchema = z.object({
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(1).max(65535),
subProfileMode: z.string(),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(), subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(), subShowIdentityOnAllLinks: z.boolean(),
+2
View File
@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
function applyDom(isDark: boolean, isUltra: boolean) { function applyDom(isDark: boolean, isUltra: boolean) {
document.body.classList.remove('dark', 'light'); document.body.classList.remove('dark', 'light');
document.body.classList.add(isDark ? 'dark' : 'light'); document.body.classList.add(isDark ? 'dark' : 'light');
// Native scrollbars read color-scheme, not the body class.
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
if (isUltra) { if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark'); document.documentElement.setAttribute('data-theme', 'ultra-dark');
} else { } else {
+3
View File
@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
export function isPanelUpdateAvailable(latest: string, current: string): boolean { export function isPanelUpdateAvailable(latest: string, current: string): boolean {
if (!latest || !current) return false; if (!latest || !current) return false;
// A dev+<sha> label and a release tag sit on different channels and carry no
// order, so a node moved to the other channel is not "behind" the master's latest.
if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
const a = parseVersionParts(latest); const a = parseVersionParts(latest);
const b = parseVersionParts(current); const b = parseVersionParts(current);
if (!a || !b) { if (!a || !b) {
@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
const result: Raw = { action }; const result: Raw = { action };
const qType = r.qType.trim(); const qType = r.qType.trim();
if (qType) { if (qType) {
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType; // The core reads a numeric 0 as no qType at all, which matches every query.
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
} }
const domains = r.domain const domains = r.domain
.split(',') .split(',')
+11
View File
@@ -1,4 +1,5 @@
import { ObjectUtil } from '@/utils'; import { ObjectUtil } from '@/utils';
import type { SubProfileMode } from '@/schemas/setting';
export class AllSetting { export class AllSetting {
webListen = ''; webListen = '';
@@ -46,6 +47,7 @@ export class AllSetting {
subClashUserAgentRegex = ''; subClashUserAgentRegex = '';
subTitle = ''; subTitle = '';
subSupportUrl = ''; subSupportUrl = '';
subProfileMode: SubProfileMode = 'none';
subProfileUrl = ''; subProfileUrl = '';
subAnnounce = ''; subAnnounce = '';
subEnableRouting = false; subEnableRouting = false;
@@ -167,6 +169,15 @@ export class AllSetting {
if (data != null) { if (data != null) {
ObjectUtil.cloneProps(this, data); ObjectUtil.cloneProps(this, data);
} }
// Legacy settings with a custom URL retain it until an explicit mode is saved.
if (
typeof data === 'object' &&
data !== null &&
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
this.subProfileUrl.trim() !== ''
) {
this.subProfileMode = 'custom';
}
const cpu = Math.round(Number(this.tgCpu)); const cpu = Math.round(Number(this.tgCpu));
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80; this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
const threshold = Math.round(Number(this.outboundDownThreshold)); const threshold = Math.round(Number(this.outboundDownThreshold));
+2 -3
View File
@@ -45,15 +45,14 @@
} }
.api-docs-page .websocket-events { .api-docs-page .websocket-events {
margin-bottom: 16px;
padding: 20px; padding: 20px;
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--ant-color-border-secondary); border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px; border-radius: 8px;
} }
.api-docs-page .websocket-events h2 { .api-docs-page .swagger-ui .section-tabs {
margin-top: 0; margin-top: 20px;
} }
.api-docs-page .websocket-events pre { .api-docs-page .websocket-events pre {
+104 -31
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd'; import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
import SwaggerUI from 'swagger-ui-react'; import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css'; import 'swagger-ui-react/swagger-ui.css';
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`; const openApiUrl = `${basePath}panel/api/openapi.json`;
const websocketEvents = buildWebSocketEvents(EXAMPLES); const websocketEvents = buildWebSocketEvents(EXAMPLES);
interface TaggedOperations {
keySeq: () => { first: () => string | undefined };
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
}
interface LayoutSelectors {
currentFilter: () => string | false;
}
interface SectionTabsProps {
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
layoutSelectors: LayoutSelectors;
layoutActions: { updateFilter: (tag: string) => void };
}
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
const tags = specSelectors
.tags()
.toJS()
.map((tag) => tag.name);
return (
<div className="wrapper section-tabs">
<Tabs
size="small"
activeKey={layoutSelectors.currentFilter() || tags[0]}
onChange={layoutActions.updateFilter}
items={tags.map((tag) => ({ key: tag, label: tag }))}
/>
</div>
);
}
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
const sectionTabsPlugin = {
statePlugins: {
spec: {
wrapSelectors: {
taggedOperations:
(
select: (...args: unknown[]) => TaggedOperations,
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
) =>
(...args: unknown[]) => {
const operations = select(...args);
const active =
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
return operations.filter((_, tag) => tag === active);
},
},
},
},
components: { FilterContainer: SectionTabs },
};
export default function ApiDocsPage() { export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content className="content-area"> <Layout.Content className="content-area">
<section className="websocket-events" aria-labelledby="websocket-events-title"> <Tabs
<Typography.Title id="websocket-events-title" level={2}> items={[
WebSocket events {
</Typography.Title> key: 'panel-api',
<Typography.Paragraph> label: '3X-UI Panel API',
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '} children: (
upgrade, every server message uses{' '} <div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value <SwaggerUI
is Unix milliseconds. url={openApiUrl}
</Typography.Paragraph> docExpansion="list"
<Row gutter={[12, 12]}> deepLinking={false}
{websocketEvents.map((event) => ( plugins={[sectionTabsPlugin]}
<Col key={event.type} xs={24} sm={12} xl={8}> tryItOutEnabled
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}> persistAuthorization
<Typography.Paragraph>{event.summary}</Typography.Paragraph> />
<pre>{JSON.stringify(event.example, null, 2)}</pre> </div>
</Card> ),
</Col> },
))} {
</Row> key: 'websocket-events',
</section> label: 'WebSocket events',
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}> children: (
<SwaggerUI <section className="websocket-events">
url={openApiUrl} <Typography.Paragraph>
docExpansion="list" After the cookie-authenticated{' '}
deepLinking={false} <Typography.Text code>GET /ws</Typography.Text> upgrade, every server
tryItOutEnabled message uses{' '}
persistAuthorization <Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
/> time value is Unix milliseconds.
</div> </Typography.Paragraph>
<Row gutter={[12, 12]}>
{websocketEvents.map((event) => (
<Col key={event.type} xs={24} sm={12} xl={8}>
<Card
size="small"
title={<Typography.Text code>{event.type}</Typography.Text>}
>
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
<pre>{JSON.stringify(event.example, null, 2)}</pre>
</Card>
</Col>
))}
</Row>
</section>
),
},
]}
/>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</Layout> </Layout>
@@ -0,0 +1,5 @@
/* The body is capped at the viewport and scrolls; a trailing item margin
alone must not push it past the cap and summon a scrollbar. */
.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
margin-bottom: 0;
}
@@ -49,6 +49,7 @@ import type {
} from '@/hooks/useClients'; } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery'; import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client'; import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
import './ClientFormModal.css';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const; const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
@@ -803,6 +804,7 @@ 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
className="client-form-modal"
width={720} width={720}
zIndex={CLIENT_FORM_MODAL_Z_INDEX} zIndex={CLIENT_FORM_MODAL_Z_INDEX}
style={{ top: 20 }} style={{ top: 20 }}
@@ -61,6 +61,23 @@
margin: 0; margin: 0;
} }
.summary-stat {
margin: -4px -8px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease;
}
.summary-stat:hover,
.summary-stat:focus-visible {
background: var(--ant-color-fill-tertiary);
}
.summary-stat.selected {
background: var(--ant-color-primary-bg);
}
.dot { .dot {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
+79 -64
View File
@@ -1,4 +1,5 @@
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation, useSearchParams } from 'react-router'; import { useLocation, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
); );
} }
interface SummaryStatProps {
title: string;
value: number;
prefix: ReactNode;
emails?: string[];
selected?: boolean;
onSelect: () => void;
}
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
const stat = (
<div
role="button"
tabIndex={0}
aria-pressed={selected}
className={selected ? 'summary-stat selected' : 'summary-stat'}
onClick={onSelect}
onKeyDown={activateOnKey(onSelect)}
>
<Statistic title={title} value={String(value)} prefix={prefix} />
</div>
);
if (!emails) return stat;
return (
<Popover
title={title}
open={value ? undefined : false}
content={<ClientEmailList emails={emails} total={value} />}
>
{stat}
</Popover>
);
}
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring'; type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState { interface PersistedFilterState {
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
const someSelected = const someSelected =
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length; selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
const isOnlyBucket = (bucket: string) =>
filters.buckets.length === 1 && filters.buckets[0] === bucket;
// Clicking the card that is already the sole status filter clears it again.
function selectBucket(bucket: string | null) {
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
setFilters({ ...filters, buckets });
}
function clearOneFilter<K extends keyof ClientFilters>(key: K) { function clearOneFilter<K extends keyof ClientFilters>(key: K) {
if (key === 'expiryFrom' || key === 'expiryTo') { if (key === 'expiryFrom' || key === 'expiryTo') {
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined }); setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
<Card size="small" hoverable className="summary-card"> <Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}> <Row gutter={[16, 12]}>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Statistic <SummaryStat
title={t('clients')} title={t('clients')}
value={String(summary.total)} value={summary.total}
prefix={<TeamOutlined />} prefix={<TeamOutlined />}
onSelect={() => selectBucket(null)}
/> />
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('online')} title={t('online')}
open={summary.onlineCount ? undefined : false} value={summary.onlineCount}
content={ emails={summary.online}
<ClientEmailList prefix={<span className="dot dot-blue" />}
emails={summary.online} selected={isOnlyBucket('online')}
total={summary.onlineCount} onSelect={() => selectBucket('online')}
/> />
}
>
<Statistic
title={t('online')}
value={String(summary.onlineCount)}
prefix={<span className="dot dot-blue" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('depleted')} title={t('depleted')}
open={summary.depletedCount ? undefined : false} value={summary.depletedCount}
content={ emails={summary.depleted}
<ClientEmailList prefix={<span className="dot dot-red" />}
emails={summary.depleted} selected={isOnlyBucket('depleted')}
total={summary.depletedCount} onSelect={() => selectBucket('depleted')}
/> />
}
>
<Statistic
title={t('depleted')}
value={String(summary.depletedCount)}
prefix={<span className="dot dot-red" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('depletingSoon')} title={t('depletingSoon')}
open={summary.expiringCount ? undefined : false} value={summary.expiringCount}
content={ emails={summary.expiring}
<ClientEmailList prefix={<span className="dot dot-orange" />}
emails={summary.expiring} selected={isOnlyBucket('expiring')}
total={summary.expiringCount} onSelect={() => selectBucket('expiring')}
/> />
}
>
<Statistic
title={t('depletingSoon')}
value={String(summary.expiringCount)}
prefix={<span className="dot dot-orange" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Popover <SummaryStat
title={t('disabled')} title={t('disabled')}
open={summary.deactiveCount ? undefined : false} value={summary.deactiveCount}
content={ emails={summary.deactive}
<ClientEmailList prefix={<span className="dot dot-gray" />}
emails={summary.deactive} selected={isOnlyBucket('deactive')}
total={summary.deactiveCount} onSelect={() => selectBucket('deactive')}
/> />
}
>
<Statistic
title={t('disabled')}
value={String(summary.deactiveCount)}
prefix={<span className="dot dot-gray" />}
/>
</Popover>
</Col> </Col>
<Col xs={12} sm={8} md={4}> <Col xs={12} sm={8} md={4}>
<Statistic <SummaryStat
title={t('subscription.active')} title={t('subscription.active')}
value={String(summary.active)} value={summary.active}
prefix={<span className="dot dot-green" />} prefix={<span className="dot dot-green" />}
selected={isOnlyBucket('active')}
onSelect={() => selectBucket('active')}
/> />
</Col> </Col>
</Row> </Row>
+1 -1
View File
@@ -28,7 +28,7 @@
.qr-panel-canvas .qr-code { .qr-panel-canvas .qr-code {
cursor: pointer; cursor: pointer;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 8px;
line-height: 0; line-height: 0;
} }
+1 -1
View File
@@ -141,7 +141,7 @@ export default function QrPanel({
value={value} value={value}
size={size} size={size}
errorLevel="L" errorLevel="L"
marginSize={4} marginSize={2}
type="svg" type="svg"
bordered={false} bordered={false}
color="#000000" color="#000000"
+36 -5
View File
@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
return map; return map;
} }
// Most pushes repeat the previous online sets; handing back a new Map anyway
// re-ran the client rollup over every inbound on each traffic event.
function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
if (a.size !== b.size) return false;
for (const [key, set] of b) {
const prev = a.get(key);
if (!prev || prev.size !== set.size) return false;
for (const value of set) if (!prev.has(value)) return false;
}
return true;
}
async function fetchLastOnlineMap(): Promise<Record<string, number>> { async function fetchLastOnlineMap(): Promise<Record<string, number>> {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }); const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline'); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
@@ -440,10 +452,12 @@ export function useInbounds() {
setOnlineClients(p.onlineClients); setOnlineClients(p.onlineClients);
} }
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') { if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid)); const next = toGuidOnlineMap(p.onlineByGuid);
setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
} }
if (p.activeInbounds && typeof p.activeInbounds === 'object') { if (p.activeInbounds && typeof p.activeInbounds === 'object') {
setActiveByGuid(toGuidOnlineMap(p.activeInbounds)); const next = toGuidOnlineMap(p.activeInbounds);
setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
} }
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') { if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! })); setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
@@ -537,8 +551,7 @@ export function useInbounds() {
? stats.map((stat) => { ? stats.map((stat) => {
const su = byEmail.get(stat.email); const su = byEmail.get(stat.email);
if (!su) return stat; if (!su) return stat;
statsTouched = true; const merged = {
return {
...stat, ...stat,
up: typeof su.up === 'number' ? su.up : stat.up, up: typeof su.up === 'number' ? su.up : stat.up,
down: typeof su.down === 'number' ? su.down : stat.down, down: typeof su.down === 'number' ? su.down : stat.down,
@@ -546,9 +559,27 @@ export function useInbounds() {
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime, expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable, enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
} as ClientStats; } as ClientStats;
if (
merged.up === stat.up &&
merged.down === stat.down &&
merged.total === stat.total &&
merged.expiryTime === stat.expiryTime &&
merged.enable === stat.enable
) {
return stat;
}
statsTouched = true;
return merged;
}) })
: null; : null;
if (!upd && !statsTouched) return ib; // Every push lists all inbounds' totals, so only a row whose numbers moved counts.
const inboundMoved =
!!upd &&
((typeof upd.up === 'number' && upd.up !== ib.up) ||
(typeof upd.down === 'number' && upd.down !== ib.down) ||
(typeof upd.total === 'number' && upd.total !== ib.total) ||
(typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
if (!inboundMoved && !statsTouched) return ib;
touched = true; touched = true;
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance; const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
if (upd) { if (upd) {
@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
<Select <Select
value={rows} value={rows}
size="small" size="small"
style={{ width: 70 }} style={{ width: 100 }}
onChange={setRows} onChange={setRows}
options={[ options={[
{ value: '20', label: '20' }, { value: '20', label: '20' },
+1 -1
View File
@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Select <Select
value={rows} value={rows}
size="small" size="small"
style={{ width: 70 }} style={{ width: 100 }}
onChange={setRows} onChange={setRows}
options={[ options={[
{ value: '20', label: '20' }, { value: '20', label: '20' },
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
return ( return (
<div className="ov-bar"> <div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? ( {status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}> <Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill} {statePill}
</Tooltip> </Tooltip>
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
statePill statePill
)} )}
{status.xray.state === 'running' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
<Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
</Tooltip>
) : null}
{updateAvailable ? ( {updateAvailable ? (
<Tag <Tag
className="ov-update-tag" className="ov-update-tag"
+1 -1
View File
@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Select <Select
value={rows} value={rows}
size="small" size="small"
style={{ width: 70 }} style={{ width: 100 }}
onChange={setRows} onChange={setRows}
options={[ options={[
{ value: '20', label: '20' }, { value: '20', label: '20' },
+56 -42
View File
@@ -145,17 +145,22 @@ function formatUptime(secs?: number): string {
return `${mins}m`; return `${mins}m`;
} }
// Stable per language: the columns memo depends on it, and a fresh function each
// render rebuilt every column, re-rendering all rows on each heartbeat push.
function useRelativeTime() { function useRelativeTime() {
const { t } = useTranslation(); const { t } = useTranslation();
return (unixSeconds?: number) => { return useMemo(
if (!unixSeconds) return t('pages.nodes.never'); () => (unixSeconds?: number) => {
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds)); if (!unixSeconds) return t('pages.nodes.never');
if (diffSec < 5) return t('pages.nodes.justNow'); const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
if (diffSec < 60) return `${diffSec}s`; if (diffSec < 5) return t('pages.nodes.justNow');
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`; if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`; if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
return `${Math.floor(diffSec / 86400)}d`; if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
}; return `${Math.floor(diffSec / 86400)}d`;
},
[t],
);
} }
export default function NodeList({ export default function NodeList({
@@ -530,6 +535,47 @@ export default function NodeList({
], ],
); );
// rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
// same element until its inputs change rather than re-rendering all rows each time.
const nodeTable = useMemo(
() => (
<Table<NodeRow>
dataSource={dataSource}
columns={columns}
pagination={false}
loading={loading}
scroll={{ x: 'max-content' }}
size="middle"
rowKey="key"
rowSelection={
dataSource.length > 1
? {
selectedRowKeys: selectedIds,
onChange: (keys) =>
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({
disabled: !!record.transitive || !isUpdateEligible(record),
}),
}
: undefined
}
locale={{
emptyText: (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
),
[dataSource, columns, loading, selectedIds, onSelectionChange, t],
);
return ( return (
<Card size="small" hoverable> <Card size="small" hoverable>
<div className="toolbar"> <div className="toolbar">
@@ -806,39 +852,7 @@ export default function NodeList({
</Modal> </Modal>
</> </>
) : ( ) : (
<Table<NodeRow> nodeTable
dataSource={dataSource}
columns={columns}
pagination={false}
loading={loading}
scroll={{ x: 'max-content' }}
size="middle"
rowKey="key"
rowSelection={
dataSource.length > 1
? {
selectedRowKeys: selectedIds,
onChange: (keys) =>
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({
disabled: !!record.transitive || !isUpdateEligible(record),
}),
}
: undefined
}
locale={{
emptyText: (
<div className="card-empty">
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
)} )}
</Card> </Card>
); );
@@ -1,4 +1,4 @@
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd'; import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
import { import {
BranchesOutlined, BranchesOutlined,
CompassOutlined, CompassOutlined,
@@ -11,6 +11,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import type { SubProfileMode } from '@/schemas/setting';
import { onNumber } from '@/utils/onNumber'; import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form'; import { RemarkTemplateField } from '@/components/form';
@@ -279,16 +280,44 @@ export default function SubscriptionGeneralTab({
</SettingListItem> </SettingListItem>
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.subProfileUrl')} title={t('pages.settings.subProfileMode')}
description={t('pages.settings.subProfileUrlDesc')} description={t('pages.settings.subProfileModeDesc')}
> >
<RemarkTemplateField <Select<SubProfileMode>
value={allSetting.subProfileUrl} id="sub-profile-mode"
placeholder="https://example.com" aria-label={t('pages.settings.subProfileMode')}
onChange={(v) => updateSetting({ subProfileUrl: v })} value={allSetting.subProfileMode}
metadataOnly style={{ width: '100%' }}
onChange={(value) => updateSetting({ subProfileMode: value })}
options={[
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
]}
/> />
</SettingListItem> </SettingListItem>
{allSetting.subProfileMode === 'builtin' ? (
<Alert
type="warning"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subProfileBuiltinWarning')}
/>
) : null}
{allSetting.subProfileMode === 'custom' ? (
<SettingListItem
paddings="small"
title={t('pages.settings.subProfileUrl')}
description={t('pages.settings.subProfileUrlDesc')}
>
<RemarkTemplateField
value={allSetting.subProfileUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subProfileUrl: v })}
metadataOnly
/>
</SettingListItem>
) : null}
<SettingListItem <SettingListItem
paddings="small" paddings="small"
title={t('pages.settings.subAnnounce')} title={t('pages.settings.subAnnounce')}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Segmented } from 'antd';
import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
import { APP_ICONS } from './appIcons';
import type { AppPlatform, SubApp } from './subPageModel';
interface SubAppsTabProps {
apps: Record<AppPlatform, SubApp[]>;
initialPlatform: AppPlatform;
onOpen: (url: string) => void;
}
const PLATFORM_OPTIONS = [
{ value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
{ value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
];
function AppIcon({ name }: { name: string }) {
const icon = APP_ICONS[name];
if (!icon) {
return (
<span className="sub-app-mark" aria-hidden="true">
{name.charAt(0)}
</span>
);
}
if (icon.tinted) {
const mask = `url("${icon.src}")`;
return (
<span className="sub-app-mark" aria-hidden="true">
<span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
</span>
);
}
return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
}
export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
const { t } = useTranslation();
const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
return (
<div className="sub-apps">
<Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
<div className="sub-app-grid">
{apps[platform].map((app) => (
<div key={app.name} className="sub-row">
<AppIcon name={app.name} />
<span className="sub-app-name">{app.name}</span>
<Button type="primary" size="small" onClick={() => onOpen(app.url)}>
{t('add')}
</Button>
</div>
))}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import ConfigBlock from '@/components/clients/ConfigBlock';
import {
amneziawgConfigFromLink,
isPostQuantumLink,
wireguardConfigFromLink,
} from '@/lib/xray/inbound-link';
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
import SubQrButton from './SubQrButton';
interface SubConfigsTabProps {
links: string[];
onCopy: (value: string, toast?: string) => void;
}
export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
const { t } = useTranslation();
return (
<div className="sub-rows">
<div className="sub-configs-bar">
<Button
icon={<CopyOutlined />}
onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
>
{t('subscription.copyAllConfigs')}
</Button>
</div>
{links.map((link, idx) => {
const parts = parseLinkParts(link);
const rowTitle = parts?.remark || `Link ${idx + 1}`;
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
const isAmneziawgLink = link.startsWith('vpn://');
return (
<Fragment key={link}>
<div className="sub-row">
{parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
<span className="sub-row-title" dir="auto" title={rowTitle}>
{rowTitle}
</span>
<div className="sub-row-actions">
<Button
icon={<CopyOutlined />}
onClick={() => onCopy(link)}
aria-label={t('copy')}
title={t('copy')}
/>
{!isPostQuantumLink(link) && (
<SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
)}
</div>
</div>
{isWireguardLink && (
<ConfigBlock
label={t('pages.clients.wireguardConfig')}
text={wireguardConfigFromLink(link, rowTitle)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="cyan"
/>
)}
{isAmneziawgLink && (
<ConfigBlock
label={t('pages.clients.amneziaWgConfig')}
text={amneziawgConfigFromLink(link)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="purple"
/>
)}
</Fragment>
);
})}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Menu, Popover, Space } from 'antd';
import {
MoonFilled,
MoonOutlined,
SunOutlined,
TranslationOutlined,
WifiOutlined,
} from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
interface SubHeaderProps {
title: string;
sId: string;
email: string;
lang: string;
onLangChange: (lang: string) => void;
}
export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const cycleTheme = () => {
pauseAnimationsUntilLeave('sub-theme-cycle');
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
};
const langMenuItems = useMemo(
() =>
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
(l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
),
}),
),
[],
);
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
const initial = Array.from(title)[0]?.toUpperCase();
return (
<header className="sub-header">
<div className="sub-brand">
<span className="sub-brand-mark" aria-hidden="true">
{initial ?? <WifiOutlined />}
</span>
<div className="sub-brand-text">
<div className="sub-brand-title" dir="auto">
{title || t('subscription.title')}
</div>
<div className="sub-brand-id">
<bdi>{email ? `${sId} - ${email}` : sId}</bdi>
</div>
</div>
</div>
<div className="sub-toolbar">
<Button
id="sub-theme-cycle"
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('menu.theme')}
title={t('menu.theme')}
icon={themeIcon}
onClick={cycleTheme}
/>
<Popover
rootClassName={isDark ? 'dark' : 'light'}
placement="bottomRight"
trigger="click"
styles={{ content: { padding: 4 } }}
content={
<Menu
mode="vertical"
selectable
selectedKeys={[lang]}
items={langMenuItems}
onClick={({ key }) => onLangChange(key)}
style={{ border: 'none', minWidth: 160 }}
/>
}
>
<Button
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('pages.settings.language')}
icon={<TranslationOutlined />}
/>
</Popover>
</div>
</header>
);
}
+127
View File
@@ -0,0 +1,127 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Progress, Tag, theme } from 'antd';
import { IntlUtil } from '@/utils';
import type { CalendarKind } from '@/utils';
import { usagePercent } from './subPageModel';
import type { SubStatus } from './subPageModel';
interface SubHeroProps {
status: SubStatus;
daysLeft: number | null;
usedByte: number;
totalByte: number;
expireMs: number;
lastOnlineMs: number;
download: string;
upload: string;
used: string;
total: string;
remained: string;
datepicker: CalendarKind;
lang: string;
}
const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
active: { color: 'green', label: 'subscription.active' },
unlimited: { color: 'purple', label: 'subscription.unlimited' },
expired: { color: 'red', label: 'subscription.expired' },
depleted: { color: 'red', label: 'subscription.depleted' },
disabled: { color: 'red', label: 'subscription.inactive' },
};
// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
function splitSize(label: string): [string, string] {
const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
return match ? [match[1], match[2]] : [label, ''];
}
export default function SubHero({
status,
daysLeft,
usedByte,
totalByte,
expireMs,
lastOnlineMs,
download,
upload,
used,
total,
remained,
datepicker,
lang,
}: SubHeroProps) {
const { t } = useTranslation();
const { token } = theme.useToken();
const hasQuota = totalByte > 0;
const healthy = status === 'active' || status === 'unlimited';
const pct = usagePercent(usedByte, totalByte);
const ringColor =
!healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
const [amount, unit] = splitSize(hasQuota ? remained : used);
const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
const statusTag = STATUS_TAGS[status];
const stats: { key: string; label: string; value: ReactNode }[] = [
{ key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
{
key: 'expiry',
label: t('subscription.expiry'),
value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
},
{
key: 'status',
label: t('subscription.status'),
value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
},
{ key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
{ key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
{ key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
{
key: 'lastOnline',
label: t('lastOnline'),
value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
},
];
return (
<section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
<Progress
type="circle"
className="sub-ring"
percent={pct}
status="normal"
size={156}
strokeColor={ringColor}
format={() => (
<span className="sub-ring-center">
<span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
<span className="sub-ring-label">
{hasQuota ? t('usage') : t('subscription.unlimited')}
</span>
</span>
)}
/>
<div className="sub-hero-summary">
<div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
<bdi className="sub-big">
<span className="sub-big-num">{amount}</span>
{unit && <span className="sub-big-unit">{unit}</span>}
</bdi>
<div className="sub-muted">
{hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
</div>
<dl className="sub-stats">
{stats.map((stat) => (
<div key={stat.key} className="sub-stat">
<dt className="sub-label">{stat.label}</dt>
<dd className="sub-stat-value">{stat.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useTranslation } from 'react-i18next';
import { Button, QRCode, Tag } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import SubQrButton from './SubQrButton';
interface SubLinksTabProps {
subUrl: string;
subJsonUrl: string;
subClashUrl: string;
onCopy: (value: string) => void;
}
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
const { t } = useTranslation();
const subLabel = t('pages.settings.subSettings');
const rows = [
{ kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
{
kind: 'JSON',
color: 'purple',
url: subJsonUrl,
title: `${subLabel} JSON`,
downloadable: true,
},
{ kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
].filter((row) => row.url);
return (
<div className="sub-rows">
{rows.map((row) => (
<div key={row.kind} className="sub-row">
<Tag color={row.color} className="sub-row-tag">
{row.kind}
</Tag>
<div className="sub-row-main">
<a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
{row.title}
</a>
<div className="sub-row-url" dir="ltr" title={row.url}>
{row.url}
</div>
</div>
<div className="sub-row-actions">
{row.downloadable && (
<Button
href={appendRawView(row.url)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
)}
<Button
icon={<CopyOutlined />}
onClick={() => onCopy(row.url)}
aria-label={t('copy')}
title={t('copy')}
/>
<SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
</div>
</div>
))}
{subUrl && (
<div className="sub-qr-card">
<div className="sub-qr-code">
<QRCode
value={subUrl}
size={112}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
<div>
<div className="sub-qr-title">{t('subscription.scanTitle')}</div>
<div className="sub-muted">{t('subscription.scanHint')}</div>
</div>
</div>
)}
</div>
);
}
+667 -74
View File
@@ -1,18 +1,78 @@
.subscription-page { .subscription-page {
--bg-page: #e6e8ec; /* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
--bg-card: #ffffff; so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
--sub-grad-from: #8b5cf6;
--sub-grad-to: #06b6d4;
--sub-ink-from: #6d28d9;
--sub-ink-to: #0e7490;
--sub-accent: #7c3aed;
--bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
--sub-card-bg: rgba(255, 255, 255, 0.72);
--sub-card-border: rgba(255, 255, 255, 0.7);
--sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
--sub-card-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 0.75),
rgba(255, 255, 255, 0) 42%,
rgba(124, 58, 237, 0.22) 88%
);
--sub-blob-1: rgba(139, 92, 246, 0.55);
--sub-blob-2: rgba(6, 182, 212, 0.45);
--sub-grid: rgba(124, 58, 237, 0.06);
--sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
--sub-tile-bg: rgba(124, 58, 237, 0.05);
--sub-tile-border: rgba(124, 58, 237, 0.12);
--sub-row-bg: rgba(124, 58, 237, 0.045);
--sub-row-border: rgba(124, 58, 237, 0.11);
--sub-row-bg-hover: rgba(124, 58, 237, 0.09);
--sub-row-border-hover: rgba(124, 58, 237, 0.3);
--sub-row-glow: rgba(124, 58, 237, 0.28);
--sub-glass-bg: rgba(255, 255, 255, 0.6);
position: relative;
min-height: 100vh; min-height: 100vh;
background: var(--bg-page); background: var(--bg-page);
} }
.subscription-page.is-dark { .subscription-page.is-dark {
--bg-page: #1a1b1f; --sub-grad-from: #a78bfa;
--bg-card: #23252b; --sub-grad-to: #22d3ee;
--sub-ink-from: #c4b5fd;
--sub-ink-to: #67e8f9;
--sub-accent: #a78bfa;
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
--sub-card-bg: rgba(35, 37, 43, 0.62);
--sub-card-border: rgba(255, 255, 255, 0.08);
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
--sub-card-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 0.16),
rgba(255, 255, 255, 0) 42%,
rgba(167, 139, 250, 0.4) 88%
);
--sub-blob-1: rgba(139, 92, 246, 0.4);
--sub-blob-2: rgba(34, 211, 238, 0.26);
--sub-grid: rgba(255, 255, 255, 0.035);
--sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
--sub-tile-bg: rgba(167, 139, 250, 0.07);
--sub-tile-border: rgba(167, 139, 250, 0.14);
--sub-row-bg: rgba(167, 139, 250, 0.06);
--sub-row-border: rgba(167, 139, 250, 0.12);
--sub-row-bg-hover: rgba(167, 139, 250, 0.12);
--sub-row-border-hover: rgba(167, 139, 250, 0.35);
--sub-row-glow: rgba(139, 92, 246, 0.45);
--sub-glass-bg: rgba(255, 255, 255, 0.06);
} }
.subscription-page.is-dark.is-ultra { .subscription-page.is-dark.is-ultra {
--bg-page: #000; --bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
--bg-card: #101013; --sub-card-bg: rgba(16, 16, 19, 0.68);
--sub-card-border: rgba(255, 255, 255, 0.055);
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
--sub-blob-1: rgba(139, 92, 246, 0.22);
--sub-blob-2: rgba(34, 211, 238, 0.14);
--sub-grid: rgba(255, 255, 255, 0.022);
--sub-glass-bg: rgba(255, 255, 255, 0.04);
} }
.subscription-page .ant-layout, .subscription-page .ant-layout,
@@ -20,104 +80,204 @@
background: transparent; background: transparent;
} }
.subscription-page .content { /* aurora backdrop */
padding: 24px 12px; .sub-aurora {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
} }
.subscription-card { .sub-aurora-grid {
margin-top: 8px; position: absolute;
inset: 0;
background-image:
linear-gradient(var(--sub-grid) 1px, transparent 1px),
linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
background-size: 48px 48px;
background-position: center;
-webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
} }
.qr-tag { .sub-aurora::before,
width: 100%; .sub-aurora::after {
text-align: center; content: '';
margin: 0; position: absolute;
width: 70vmax;
height: 70vmax;
max-width: 820px;
max-height: 820px;
border-radius: 50%;
filter: blur(80px);
will-change: transform;
} }
.info-table { .sub-aurora::before {
margin-top: 4px; top: -22vmax;
left: -16vmax;
background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
animation: sub-blob-a 28s ease-in-out infinite alternate;
} }
.links-section { .sub-aurora::after {
display: flex; bottom: -24vmax;
flex-direction: column; right: -18vmax;
gap: 8px; background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
animation: sub-blob-b 34s ease-in-out infinite alternate;
} }
.sub-link-anchor { @keyframes sub-blob-a {
color: inherit; 0% {
text-decoration: none; transform: translate(0, 0) scale(1);
}
100% {
transform: translate(16vw, 14vh) scale(1.18);
}
} }
.sub-link-anchor:hover { @keyframes sub-blob-b {
text-decoration: underline; 0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-14vw, -12vh) scale(1.15);
}
} }
.sub-link-row { .sub-content {
position: relative;
z-index: 1;
padding: 32px 16px;
}
.sub-card {
max-width: 880px;
margin: 0 auto;
}
.subscription-page .sub-card {
position: relative;
border-radius: 20px;
border: 1px solid var(--sub-card-border);
background: var(--sub-card-bg);
box-shadow: var(--sub-card-shadow);
-webkit-backdrop-filter: blur(24px) saturate(180%);
backdrop-filter: blur(24px) saturate(180%);
}
/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
.subscription-page .sub-card::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
padding: 1px;
background: var(--sub-card-sheen);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.sub-card > .ant-card-body {
position: relative;
z-index: 1;
padding: 28px;
}
.sub-label,
.sub-muted {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-muted {
font-size: 13px;
}
/* Gradient hairline shared by the header, the stats grid and the footer. */
.sub-header::after,
.sub-stats::before,
.sub-footer::before {
content: '';
position: absolute;
inset-inline: 0;
height: 1px;
background: var(--sub-hairline);
opacity: 0.7;
}
/* header */
.sub-header {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
padding: 8px 12px; gap: 12px;
border-radius: 10px; padding-bottom: 20px;
background: rgba(0, 0, 0, 0.03); margin-bottom: 24px;
border: 1px solid rgba(0, 0, 0, 0.08);
transition:
background 120ms ease,
border-color 120ms ease;
} }
.sub-link-row:hover { .sub-header::after {
background: rgba(0, 0, 0, 0.05); bottom: 0;
border-color: rgba(0, 0, 0, 0.14);
} }
.is-dark .sub-link-row { .sub-brand {
background: rgba(0, 0, 0, 0.2); display: flex;
border-color: rgba(255, 255, 255, 0.1); align-items: center;
} gap: 12px;
.is-dark .sub-link-row:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.sub-link-tag {
margin: 0;
flex-shrink: 0;
font-weight: 600;
letter-spacing: 0.3px;
}
.sub-link-title {
flex: 1;
min-width: 0; min-width: 0;
font-size: 13px; }
.sub-brand-mark {
width: 44px;
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 13px;
background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
box-shadow: 0 8px 20px -8px var(--sub-row-glow);
color: #fff;
font-size: 20px;
font-weight: 600;
}
.sub-brand-text {
min-width: 0;
}
.sub-brand-title,
.sub-brand-id {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.sub-link-actions { .sub-brand-title {
font-size: 18px;
font-weight: 600;
line-height: 1.3;
color: var(--ant-color-text);
}
.sub-brand-id {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-toolbar {
display: flex; display: flex;
gap: 4px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.sub-link-qr-popover {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.apps-row {
margin-top: 24px;
}
.app-col {
text-align: center;
}
.toolbar-btn { .toolbar-btn {
width: 40px; width: 40px;
height: 40px; height: 40px;
@@ -129,3 +289,436 @@
.toolbar-btn .anticon { .toolbar-btn .anticon {
font-size: 18px; font-size: 18px;
} }
.subscription-page .toolbar-btn {
border-color: var(--sub-row-border);
background: var(--sub-glass-bg);
color: var(--sub-accent);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.subscription-page .toolbar-btn:hover {
border-color: var(--sub-row-border-hover);
background: var(--sub-row-bg-hover);
color: var(--sub-accent);
}
.sub-announce {
margin-bottom: 24px;
}
/* usage hero */
.sub-hero {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 32px;
align-items: center;
}
.sub-ring .ant-progress-text {
color: var(--ant-color-text);
}
.sub-ring-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
line-height: 1.1;
}
.sub-ring-value {
font-size: 26px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sub-ring-label {
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-big {
display: inline-flex;
align-items: baseline;
gap: 6px;
margin: 2px 0;
line-height: 1.1;
}
.sub-big-num {
font-size: 44px;
font-weight: 700;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--sub-ink-from);
}
.sub-big-unit {
font-size: 18px;
color: var(--ant-color-text-secondary);
}
.sub-hero.is-alert .sub-big-num {
background: none;
-webkit-text-fill-color: var(--ant-color-error);
color: var(--ant-color-error);
}
.sub-stats {
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 20px 0 0;
padding-top: 20px;
}
.sub-stats::before {
top: 0;
}
.sub-stat {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--sub-tile-border);
background: var(--sub-tile-bg);
}
.sub-stat-value {
margin: 2px 0 0;
font-size: 14px;
font-weight: 600;
color: var(--ant-color-text);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.sub-stat-value .ant-tag {
margin: 0;
}
/* tabs */
.sub-tabs {
margin-top: 28px;
}
.sub-tabs.ant-tabs .ant-tabs-ink-bar {
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
}
.sub-tab-count {
margin-inline-start: 6px;
padding: 0 7px;
border-radius: 10px;
font-size: 12px;
background: var(--sub-tile-bg);
border: 1px solid var(--sub-tile-border);
color: var(--sub-accent);
}
.sub-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.sub-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
background: var(--sub-row-bg);
border: 1px solid var(--sub-row-border);
transition:
background 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.sub-row:hover {
background: var(--sub-row-bg-hover);
border-color: var(--sub-row-border-hover);
box-shadow: 0 8px 20px -14px var(--sub-row-glow);
transform: translateY(-1px);
}
.sub-row-tag {
margin: 0;
flex-shrink: 0;
font-weight: 600;
letter-spacing: 0.3px;
}
.sub-row-main {
flex: 1;
min-width: 0;
}
.sub-row-title {
display: block;
font-size: 14px;
color: var(--ant-color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-row > .sub-row-title {
flex: 1;
min-width: 0;
font-size: 13px;
}
a.sub-row-title:hover {
color: var(--sub-accent);
}
.sub-row-url {
font-size: 12px;
color: var(--ant-color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
[dir='rtl'] .sub-row-url,
[dir='rtl'] .sub-row > .sub-row-title {
text-align: right;
}
.sub-row-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.sub-qr-modal .ant-modal-title {
font-size: 20px;
font-weight: 600;
}
.sub-qr-modal .ant-modal-close {
width: 36px;
height: 36px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 50%;
}
.sub-qr-modal-hint {
margin: 2px 0 20px;
}
.sub-qr-modal-code {
width: fit-content;
margin: 0 auto 20px;
border-radius: 8px;
background: #fff;
line-height: 0;
}
.sub-qr-modal-code canvas {
display: block;
}
.sub-qr-modal-link {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
}
.sub-qr-modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.sub-qr-modal-actions > .ant-btn:first-child {
flex: 1;
}
.sub-qr-card {
display: flex;
align-items: center;
gap: 16px;
margin-top: 8px;
padding: 16px;
border-radius: 14px;
border: 1px dashed var(--sub-row-border-hover);
background: var(--sub-row-bg);
}
.sub-qr-code {
flex-shrink: 0;
padding: 6px;
border-radius: 8px;
background: #fff;
line-height: 0;
}
.sub-qr-title {
margin-bottom: 4px;
font-size: 15px;
font-weight: 600;
color: var(--ant-color-text);
}
/* apps */
.sub-apps {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.sub-app-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.sub-app-mark {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
border: 1px solid var(--sub-tile-border);
background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
color: var(--sub-accent);
font-weight: 600;
}
.sub-app-glyph {
width: 22px;
height: 22px;
background: currentColor;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.sub-app-logo {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 9px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--sub-tile-border);
}
.sub-app-name {
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--ant-color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* configs */
.sub-configs-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
/* footer */
.sub-footer {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 16px;
margin-top: 28px;
padding-top: 16px;
font-size: 12px;
color: var(--ant-color-text-tertiary);
}
.sub-footer::before {
top: 0;
}
.sub-footer > span,
.sub-footer > a {
display: inline-flex;
align-items: center;
gap: 6px;
}
@media (prefers-reduced-motion: reduce) {
.sub-aurora::before,
.sub-aurora::after {
animation: none;
}
.sub-row:hover {
transform: none;
}
}
@media (max-width: 576px) {
.sub-content {
padding: 16px 8px;
}
.sub-card > .ant-card-body {
padding: 16px;
}
/* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
.sub-aurora::before {
animation: none;
}
.sub-aurora::after {
display: none;
}
.sub-hero {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.sub-ring {
justify-self: center;
}
.sub-big-num {
font-size: 36px;
}
.sub-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sub-app-grid {
grid-template-columns: minmax(0, 1fr);
}
.sub-tabs .ant-tabs-tab-icon {
display: none;
}
.sub-qr-card {
display: none;
}
}
+163 -586
View File
@@ -1,98 +1,89 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
import type { TabsProps } from 'antd';
import { import {
Alert, AppstoreOutlined,
Button, ClockCircleOutlined,
Card, CustomerServiceOutlined,
Col, LinkOutlined,
ConfigProvider, UnorderedListOutlined,
Descriptions,
Divider,
Dropdown,
Layout,
Menu,
message,
Popover,
QRCode,
Row,
Space,
Tag,
Tooltip,
} from 'antd';
import {
AndroidOutlined,
AppleOutlined,
CopyOutlined,
DownOutlined,
DownloadOutlined,
MoonFilled,
MoonOutlined,
QrcodeOutlined,
SunOutlined,
TranslationOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils'; import { ClipboardManager, LanguageManager } from '@/utils';
import {
amneziawgConfigFromLink,
isPostQuantumLink,
wireguardConfigFromLink,
} from '@/lib/xray/inbound-link';
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
import ConfigBlock from '@/components/clients/ConfigBlock';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import SubAppsTab from './SubAppsTab';
import SubUsageSummary from './SubUsageSummary'; import SubConfigsTab from './SubConfigsTab';
import SubHeader from './SubHeader';
import SubHero from './SubHero';
import SubLinksTab from './SubLinksTab';
import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
import './SubPage.css'; import './SubPage.css';
const QR_SIZE = 240;
const subData = window.__SUB_PAGE_DATA__ || {}; const subData = window.__SUB_PAGE_DATA__ || {};
const sId = subData.sId || ''; const sId = subData.sId || '';
const enabled = !!subData.enabled;
const download = subData.download || '0';
const upload = subData.upload || '0';
const total = subData.total || '∞';
const used = subData.used || '0';
const remained = subData.remained || '';
const totalByte = Number(subData.totalByte || 0);
const expireMs = Number(subData.expire || 0) * 1000;
const lastOnlineMs = Number(subData.lastOnline || 0);
const subUrl = subData.subUrl || ''; const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || ''; const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || ''; const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || ''; const subTitle = subData.subTitle || '';
const subSupportUrl = subData.subSupportUrl || '';
const updateHours = Number(subData.subUpdates || 0);
const announce = subData.announce || '';
const links: string[] = Array.isArray(subData.links) ? subData.links : []; const links: string[] = Array.isArray(subData.links) ? subData.links : [];
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : []; const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', '); const totalByte = Number(subData.totalByte || 0);
const datepicker = subData.datepicker || 'gregorian'; const usedByte =
const announce = subData.announce || ''; Number(subData.usedByte || 0) ||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
const expireMs = Number(subData.expire || 0) * 1000;
const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
const loadedAt = Date.now();
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`; const heroData = {
status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
daysLeft: daysUntil(expireMs, loadedAt),
usedByte,
totalByte,
expireMs,
lastOnlineMs: Number(subData.lastOnline || 0),
download: subData.download || '0',
upload: subData.upload || '0',
used: subData.used || '0',
total: subData.total || '∞',
remained: subData.remained || '',
datepicker: subData.datepicker || 'gregorian',
};
const isUnlimited = totalByte <= 0 && expireMs === 0; const apps = buildSubApps({ subUrl, sId, subTitle });
const isActive = (() => { const initialPlatform = detectPlatform(navigator.userAgent);
if (!enabled) return false; const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
if (totalByte > 0) {
const usedByteCalc = // The sub page runs its own violet accent, so every antd control on it picks the
Number(subData.usedByte || 0) || // hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0); const ACCENT = {
if (usedByteCalc >= totalByte) return false; light: {
} primary: '#7c3aed',
if (expireMs > 0 && Date.now() >= expireMs) return false; hover: '#8b5cf6',
return true; active: '#6d28d9',
})(); rail: 'rgba(124, 58, 237, 0.16)',
},
dark: {
primary: '#a78bfa',
hover: '#c4b5fd',
active: '#8b5cf6',
rail: 'rgba(167, 139, 250, 0.18)',
},
};
export default function SubPage() { export default function SubPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { useEffect(() => {
setMessageInstance(messageApi); setMessageInstance(messageApi);
}, [messageApi]); }, [messageApi]);
const { isMobile } = useMediaQuery(576);
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription')); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
const onLangChange = useCallback((next: string) => { const onLangChange = useCallback((next: string) => {
@@ -100,538 +91,124 @@ export default function SubPage() {
LanguageManager.setLanguage(next, 'subscription'); LanguageManager.setLanguage(next, 'subscription');
}, []); }, []);
const cycleTheme = useCallback(() => {
pauseAnimationsUntilLeave('sub-theme-cycle');
if (!isDark) {
toggleTheme();
if (isUltra) toggleUltra();
} else if (!isUltra) {
toggleUltra();
} else {
toggleUltra();
toggleTheme();
}
}, [isDark, isUltra, toggleTheme, toggleUltra]);
const copy = useCallback( const copy = useCallback(
async (value: string) => { async (value: string, toast?: string) => {
if (!value) return; if (!value) return;
const ok = await ClipboardManager.copyText(value); const ok = await ClipboardManager.copyText(value);
if (ok) messageApi.success(t('copied')); if (ok) messageApi.success(toast ?? t('copied'));
}, },
[t, messageApi], [t, messageApi],
); );
const copyAll = useCallback(async () => {
if (links.length === 0) return;
const allLinks = links.join('\n');
const ok = await ClipboardManager.copyText(allLinks);
if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
}, [t, messageApi]);
const open = useCallback((url: string) => { const open = useCallback((url: string) => {
if (!url) return; if (url) window.open(url, '_blank');
window.open(url, '_blank');
}, []); }, []);
const shadowrocketUrl = useMemo(() => { const tabs = useMemo(() => {
if (!subUrl) return ''; const items: NonNullable<TabsProps['items']> = [];
const separator = subUrl.includes('?') ? '&' : '?'; if (subUrl || subJsonUrl || subClashUrl) {
const rawUrl = subUrl + separator + 'flag=shadowrocket'; items.push({
const base64Url = btoa(rawUrl); key: 'subscription',
const remark = encodeURIComponent(subTitle || sId || 'Subscription'); icon: <LinkOutlined />,
return `shadowrocket://add/sub://${base64Url}?remark=${remark}`; label: t('subscription.tabLinks'),
}, []); children: (
<SubLinksTab
const v2boxUrl = useMemo( subUrl={subUrl}
() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`, subJsonUrl={subJsonUrl}
[], subClashUrl={subClashUrl}
); onCopy={copy}
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
const pageClass = useMemo(() => {
const classes = ['subscription-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
const descriptionsItems = useMemo(() => {
const items = [
{ key: 'subId', label: t('subscription.subId'), children: sId },
...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
{
key: 'status',
label: t('subscription.status'),
children: !enabled ? (
<Tag color="red">{t('subscription.inactive')}</Tag>
) : isUnlimited ? (
<Tag color="purple">{t('subscription.unlimited')}</Tag>
) : (
<Tag color={isActive ? 'green' : 'red'}>
{isActive ? t('subscription.active') : t('subscription.inactive')}
</Tag>
),
},
{ key: 'down', label: t('subscription.downloaded'), children: download },
{ key: 'up', label: t('subscription.uploaded'), children: upload },
{ key: 'used', label: t('usage'), children: used },
{ key: 'total', label: t('subscription.totalQuota'), children: total },
];
if (totalByte > 0) {
items.push({ key: 'remained', label: t('remained'), children: remained });
}
items.push({
key: 'lastOnline',
label: t('lastOnline'),
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
});
items.push({
key: 'expiry',
label: t('subscription.expiry'),
children:
expireMs === 0
? t('subscription.noExpiry')
: IntlUtil.formatDate(expireMs, datepicker, lang),
});
return items;
}, [t, lang]);
const androidMenuItems = useMemo(
() => [
{
key: 'android-v2box',
label: 'V2Box',
onClick: () =>
open(
`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
),
},
{
key: 'android-v2rayng',
label: 'V2RayNG',
onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
},
{ key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
],
[copy, open],
);
const iosMenuItems = useMemo(
() => [
{ key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
{ key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
{ key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
{ key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
],
[copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
);
const langMenuItems = useMemo(
() =>
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
(l) => ({
key: l.value,
label: (
<Space size={8}>
<span aria-hidden="true">{l.icon}</span>
<span>{l.name}</span>
</Space>
),
}),
),
[],
);
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
const cardTitle = (
<Space>
<span>{t('subscription.title')}</span>
<Tag>{sId}</Tag>
</Space>
);
const cardExtra = (
<Space size={8} align="center">
<Button
shape="circle"
size="large"
className="toolbar-btn"
aria-label={t('menu.theme')}
title={t('menu.theme')}
icon={themeIcon}
onClick={cycleTheme}
/>
<Popover
rootClassName={isDark ? 'dark' : 'light'}
placement="bottomRight"
trigger="click"
styles={{ content: { padding: 4 } }}
content={
<Menu
mode="vertical"
selectable
selectedKeys={[lang]}
items={langMenuItems}
onClick={({ key }) => onLangChange(key)}
style={{ border: 'none', minWidth: 160 }}
/> />
} ),
> });
<Button }
shape="circle" if (subUrl) {
size="large" items.push({
className="toolbar-btn" key: 'apps',
aria-label={t('pages.settings.language')} icon: <AppstoreOutlined />,
icon={<TranslationOutlined />} label: t('subscription.tabApps'),
/> children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
</Popover> });
</Space> }
); if (links.length > 0) {
items.push({
key: 'configs',
icon: <UnorderedListOutlined />,
label: (
<>
{t('subscription.tabConfigs')}
<span className="sub-tab-count">{links.length}</span>
</>
),
children: <SubConfigsTab links={links} onCopy={copy} />,
});
}
return items;
}, [t, copy, open]);
const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
.filter(Boolean)
.join(' ');
const themeConfig = useMemo(() => {
const accent = isDark ? ACCENT.dark : ACCENT.light;
const primary = {
colorPrimary: accent.primary,
colorPrimaryHover: accent.hover,
colorPrimaryActive: accent.active,
};
return {
...antdThemeConfig,
token: {
...antdThemeConfig.token,
...primary,
colorLink: accent.primary,
colorInfo: accent.primary,
},
components: {
...antdThemeConfig.components,
Button: { ...antdThemeConfig.components?.Button, ...primary },
Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
},
};
}, [antdThemeConfig, isDark]);
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={themeConfig} direction={direction}>
{messageContextHolder} {messageContextHolder}
<Layout className={pageClass}> <Layout className={pageClass} dir={direction}>
<Layout.Content className="content"> <div className="sub-aurora" aria-hidden="true">
<Row justify="center"> <span className="sub-aurora-grid" />
<Col xs={24} sm={22} md={18} lg={14} xl={12}> </div>
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}> <Layout.Content className="sub-content">
{announce && ( <Card className="sub-card">
<Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} /> <SubHeader
title={subTitle}
sId={sId}
email={clientEmail}
lang={lang}
onLangChange={onLangChange}
/>
{announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
<SubHero {...heroData} lang={lang} />
{tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
{(updateHours > 0 || subSupportUrl) && (
<footer className="sub-footer">
{updateHours > 0 && (
<span>
<ClockCircleOutlined />
{t('subscription.updateInterval', { hours: updateHours })}
</span>
)} )}
<Descriptions {subSupportUrl && (
bordered <a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
column={1} <CustomerServiceOutlined />
size="small" {t('subscription.support')}
className="info-table" </a>
items={descriptionsItems}
/>
<SubUsageSummary
usedByte={
Number(subData.usedByte || 0) ||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
}
totalByte={totalByte}
usedLabel={used}
totalLabel={total}
remainedLabel={remained}
expireMs={expireMs}
isActive={isActive}
/>
{(subUrl || subJsonUrl || subClashUrl) && (
<>
<Divider>{t('subscription.title')}</Divider>
<div className="links-section">
{subUrl && (
<div className="sub-link-row">
<Tag color="green" className="sub-link-tag">
SUB
</Tag>
<a
href={subUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="green" className="qr-tag">
{t('pages.settings.subSettings')}
</Tag>
<QRCode
value={subUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
{subJsonUrl && (
<div className="sub-link-row">
<Tag color="purple" className="sub-link-tag">
JSON
</Tag>
<a
href={subJsonUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subJsonUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subJsonUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subJsonUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="purple" className="qr-tag">
{t('pages.settings.subSettings')} JSON
</Tag>
<QRCode
value={subJsonUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
{subClashUrl && (
<div className="sub-link-row">
<Tooltip title="Clash / Mihomo">
<Tag color="gold" className="sub-link-tag">
CLASH
</Tag>
</Tooltip>
<a
href={subClashUrl}
target="_blank"
rel="noopener noreferrer"
className="sub-link-title sub-link-anchor"
title={subClashUrl}
>
{sId}
</a>
<div className="sub-link-actions">
<Button
size="small"
href={appendRawView(subClashUrl)}
target="_blank"
rel="noopener noreferrer"
icon={<DownloadOutlined />}
aria-label={t('download')}
title={t('download')}
/>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(subClashUrl)}
aria-label={t('copy')}
title={t('copy')}
/>
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag color="gold" className="qr-tag">
Clash / Mihomo
</Tag>
<QRCode
value={subClashUrl}
size={QR_SIZE}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
</div>
</div>
)}
</div>
</>
)} )}
</footer>
{links.length > 0 && ( )}
<> </Card>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
<div className="links-section">
<div className="sub-link-row">
<span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
<div className="sub-link-actions">
<Button
size="small"
icon={<CopyOutlined />}
onClick={copyAll}
aria-label={t('subscription.copyAllConfigs')}
title={t('subscription.copyAllConfigs')}
/>
</div>
</div>
{links.map((link, idx) => {
const parts = parseLinkParts(link);
const fallback = `Link ${idx + 1}`;
const rowTitle = parts?.remark || fallback;
const qrLabel = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
const isWireguardLink =
link.startsWith('wireguard://') || link.startsWith('wg://');
const isAmneziawgLink = link.startsWith('vpn://');
return (
<Fragment key={link}>
<div className="sub-link-row">
{parts ? (
<LinkTags parts={parts} />
) : (
<Tag className="sub-link-tag">LINK</Tag>
)}
<span className="sub-link-title" title={rowTitle}>
{rowTitle}
</span>
<div className="sub-link-actions">
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copy(link)}
aria-label={t('copy')}
title={t('copy')}
/>
{canQr && (
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={
<div className="sub-link-qr-popover">
<Tag className="qr-tag">{qrLabel}</Tag>
<QRCode
value={link}
size={220}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
}
>
<Button
size="small"
icon={<QrcodeOutlined />}
aria-label="QR"
title="QR"
/>
</Popover>
)}
</div>
</div>
{isWireguardLink && (
<ConfigBlock
label={t('pages.clients.wireguardConfig')}
text={wireguardConfigFromLink(link, rowTitle)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="cyan"
/>
)}
{isAmneziawgLink && (
<ConfigBlock
label={t('pages.clients.amneziaWgConfig')}
text={amneziawgConfigFromLink(link)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="purple"
/>
)}
</Fragment>
);
})}
</div>
</>
)}
<Row gutter={[8, 8]} justify="center" className="apps-row">
<Col xs={24} sm={12} className="app-col">
<Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
<Button block={isMobile} size="large" type="primary">
<AndroidOutlined /> Android <DownOutlined />
</Button>
</Dropdown>
</Col>
<Col xs={24} sm={12} className="app-col">
<Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
<Button block={isMobile} size="large" type="primary">
<AppleOutlined /> iOS <DownOutlined />
</Button>
</Dropdown>
</Col>
</Row>
</Card>
</Col>
</Row>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</ConfigProvider> </ConfigProvider>
+69
View File
@@ -0,0 +1,69 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, QRCode } from 'antd';
import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
interface SubQrButtonProps {
value: string;
label: string;
onCopy: (value: string) => void;
}
export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const qrRef = useRef<HTMLDivElement>(null);
const saveQr = () => {
const canvas = qrRef.current?.querySelector('canvas');
if (!canvas) return;
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = `${label || 'qrcode'}.png`;
link.click();
};
return (
<>
<Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} />
<Modal
open={open}
onCancel={() => setOpen(false)}
footer={null}
width={440}
centered
destroyOnHidden
rootClassName="sub-qr-modal"
title={t('subscription.qrTitle')}
>
<p className="sub-muted sub-qr-modal-hint">{t('subscription.qrHint')}</p>
<div ref={qrRef} className="sub-qr-modal-code">
<QRCode
value={value}
size={240}
type="canvas"
marginSize={2}
bordered={false}
color="#000000"
bgColor="#ffffff"
/>
</div>
<Input.TextArea
className="sub-qr-modal-link"
value={value}
readOnly
dir="ltr"
autoSize={{ minRows: 2, maxRows: 5 }}
/>
<div className="sub-qr-modal-actions">
<Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}>
{t('copy')}
</Button>
<Button size="large" icon={<DownloadOutlined />} onClick={saveQr}>
{t('subscription.saveQr')}
</Button>
</div>
</Modal>
</>
);
}
@@ -1,87 +0,0 @@
.usage-summary {
margin-top: 12px;
padding: 14px 16px;
background: var(--ant-color-fill-alter);
border: 1px solid var(--ant-color-border-secondary);
border-radius: 12px;
}
.usage-summary.is-inactive {
opacity: 0.7;
border-color: var(--ant-color-error-border);
}
.usage-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.usage-summary-labels {
display: flex;
align-items: baseline;
gap: 6px;
font-variant-numeric: tabular-nums;
min-width: 0;
}
.usage-summary-used {
font-size: 18px;
font-weight: 700;
color: var(--ant-color-text);
}
.usage-summary-sep {
color: var(--ant-color-text-quaternary);
font-size: 16px;
}
.usage-summary-total {
font-size: 14px;
color: var(--ant-color-text-secondary);
font-weight: 500;
}
.usage-summary-chips {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.usage-summary-chips .ant-tag {
margin: 0;
}
.usage-summary-bar.ant-progress {
margin-bottom: 6px;
}
.usage-summary-bar .ant-progress-outer {
padding-inline-end: 0;
}
.usage-summary-bar .ant-progress-inner {
background: var(--ant-color-fill-secondary);
}
.usage-summary-foot {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--ant-color-text-tertiary);
font-variant-numeric: tabular-nums;
min-height: 16px;
}
.usage-summary-remained::before {
content: '';
}
.usage-summary-pct {
font-weight: 600;
color: var(--ant-color-text-secondary);
}
@@ -1,96 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Progress, Tag } from 'antd';
import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import './SubUsageSummary.css';
interface SubUsageSummaryProps {
usedByte: number;
totalByte: number;
usedLabel: string;
totalLabel: string;
remainedLabel: string;
expireMs: number;
isActive: boolean;
}
function pickStrokeColor(pct: number): { from: string; to: string } {
if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
return { from: '#5fc983', to: '#36b37e' };
}
function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
if (expireMs <= 0) return null;
const diff = expireMs - Date.now();
if (diff <= 0) return { label: 'Expired', color: 'red' };
const days = Math.floor(diff / 86400000);
if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
const hours = Math.max(1, Math.floor(diff / 3600000));
return { label: `${hours}h`, color: 'orange' };
}
export default function SubUsageSummary({
usedByte,
totalByte,
usedLabel,
totalLabel,
remainedLabel,
expireMs,
isActive,
}: SubUsageSummaryProps) {
const { t } = useTranslation();
const pct = useMemo(() => {
if (totalByte <= 0) return 0;
const v = (usedByte / totalByte) * 100;
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(100, v));
}, [usedByte, totalByte]);
const expiry = formatExpiryChip(expireMs);
const isUnlimited = totalByte <= 0;
const stroke = pickStrokeColor(pct);
return (
<div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
<div className="usage-summary-head">
<div className="usage-summary-labels">
<span className="usage-summary-used">{usedLabel}</span>
<span className="usage-summary-sep">/</span>
<span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
</div>
<div className="usage-summary-chips">
{isUnlimited && (
<Tag color="purple" icon={<ThunderboltOutlined />}>
{t('subscription.unlimited')}
</Tag>
)}
{expiry && (
<Tag color={expiry.color} icon={<ClockCircleOutlined />}>
{expiry.label}
</Tag>
)}
</div>
</div>
{!isUnlimited && (
<Progress
percent={pct}
showInfo={false}
strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
railColor="var(--ant-color-fill-secondary)"
strokeWidth={10}
className="usage-summary-bar"
/>
)}
<div className="usage-summary-foot">
{!isUnlimited && (
<>
<span className="usage-summary-remained">{remainedLabel}</span>
<span className="usage-summary-pct">{pct.toFixed(1)}%</span>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,25 @@
# Subscription page app icons
## Line icons — Arcticons (CC BY-SA 4.0)
`happ.svg`, `sing-box.svg`, `v2rayng.svg` and `v2raytun.svg` come from
[Arcticons](https://github.com/Arcticons-Team/Arcticons) by Donnnno and the
Arcticons contributors (as listed on svgicons.com), licensed under
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
Changes from the originals: the stroke colour is `currentColor` so the page can
tint them to its theme, the stroke width is raised from 1 to 2 so they stay
legible at tile size, and the style classes and ids were removed. These modified
files are distributed under CC BY-SA 4.0 as well.
## App icons
The remaining files are each app's own icon, downscaled to 96 px, used only to
identify the app a button opens. They remain the trademarks of their owners.
| File | Source |
| ------------------- | ------------------------------------------------------------------------- |
| `shadowrocket.webp` | App Store listing [id932747118](https://apps.apple.com/app/id932747118) |
| `streisand.webp` | App Store listing [id6450534064](https://apps.apple.com/app/id6450534064) |
| `v2box.webp` | App Store listing [id6446814690](https://apps.apple.com/app/id6446814690) |
| `incy.webp` | App Store listing [id6756943388](https://apps.apple.com/app/id6756943388) |
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="11.9387 18.636 11.2805 19.2322 13.2164 6.3148 21.6618 6.3148 21.082 10.1846"/><polyline points="20.2585 28.1999 18.113 42.5 9.6674 42.5 10.3198 38.1088"/><polyline points="28.1728 36.5585 27.2773 42.5 35.7228 42.5 37.9603 27.5876 36.0158 29.2999"/><polyline points="21.0144 27.3598 27.8555 27.3598 26.2122 38.3731 36.0158 29.2999 39.1998 8.0038 29.2593 17.99 28.8029 21.0714 27.2773 21.0714 26.4784 21.8658"/><polygon points="26.4784 21.8658 20.2243 28.1543 18.9689 28.1543 18.752 29.6379 8.8002 39.6355 11.9387 18.636 21.7423 9.5743 19.9047 21.8658 26.4784 21.8658"/><polyline points="38.0794 9.1294 38.6261 5.5 30.1808 5.5 28.1701 18.9112 29.2593 17.99"/></svg>

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M40.7241,15.6976l-15.4896-10.8095c-.7416-.5175-1.7273-.5175-2.4689,0L7.2759,15.6976c-.9141.6379-.9141,1.9909,0,2.6288l15.4896,10.8095c.7416.5175,1.7273.5175,2.4689,0l15.4896-10.8095c.9141-.6379.9141-1.9909,0-2.6288Z"/><path d="M41.4096,17.012v13.976c0,.4977-.2285.9954-.6855,1.3144l-15.4896,10.8095c-.7416.5175-1.7273.5175-2.4689,0l-15.4896-10.8095c-.457-.3189-.6855-.8167-.6855-1.3144h0s0-13.976,0-13.976"/><line x1="24" y1="29.524" x2="24" y2="43.5"/><path d="M11.8734,12.4893l18.3951,13.1335v3.8047c0,.6869.7673,1.0951,1.3369.7111l4.3991-2.9651c.2961-.1996.4735-.5332.4735-.8903v-4.9939l-18.3951-13.1335"/></svg>

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m9.6185,41.4866V10.6142h-4.1185v-4.0722h10.2641v14.3936c.6957-.6404,1.2872-1.1794,1.8728-1.7248,3.5152-3.2745,7.0291-6.5504,10.5432-9.826.9416-.8777,1.8767-1.7624,2.8301-2.6271.1437-.1303.3712-.2375.5603-.2382,3.5668-.0136,10.9295,0,10.9295,0-10.9555,11.6366-21.8724,23.2736-32.8815,34.9672Z"/></svg>

After

Width:  |  Height:  |  Size: 468 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="5.5" y="5.5" width="37" height="37" rx="4" ry="4"/><polyline points="28.9436 11.2023 20.4652 36.7977 11.9867 11.2023"/><path d="M28.3091,29.0207c0-2.3774,2.1537-4.2518,4.6165-3.7785,1.6155.3105,2.9055,1.7076,3.0663,3.3447.1195,1.2178-.2658,2.4194-1.1069,3.1576-1.5583,1.3675-6.5759,5.053-6.5759,5.053h7.7042"/></svg>

After

Width:  |  Height:  |  Size: 484 B

+20
View File
@@ -0,0 +1,20 @@
import happ from './app-icons/happ.svg';
import incy from './app-icons/incy.webp';
import shadowrocket from './app-icons/shadowrocket.webp';
import singBox from './app-icons/sing-box.svg';
import streisand from './app-icons/streisand.webp';
import v2box from './app-icons/v2box.webp';
import v2rayng from './app-icons/v2rayng.svg';
import v2raytun from './app-icons/v2raytun.svg';
// Tinted entries are Arcticons line art drawn in the theme colour; the rest are full-colour app icons.
export const APP_ICONS: Record<string, { src: string; tinted: boolean }> = {
V2Box: { src: v2box, tinted: false },
V2RayNG: { src: v2rayng, tinted: true },
'Sing-box': { src: singBox, tinted: true },
V2RayTun: { src: v2raytun, tinted: true },
Happ: { src: happ, tinted: true },
Incy: { src: incy, tinted: false },
Shadowrocket: { src: shadowrocket, tinted: false },
Streisand: { src: streisand, tinted: false },
};
+93
View File
@@ -0,0 +1,93 @@
const DAY_MS = 86_400_000;
export type SubStatus = 'active' | 'unlimited' | 'expired' | 'depleted' | 'disabled';
export interface SubUsage {
enabled: boolean;
usedByte: number;
totalByte: number;
expireMs: number;
}
export function resolveSubStatus(sub: SubUsage, now: number): SubStatus {
if (!sub.enabled) return 'disabled';
if (sub.expireMs > 0 && now >= sub.expireMs) return 'expired';
if (sub.totalByte > 0 && sub.usedByte >= sub.totalByte) return 'depleted';
if (sub.totalByte <= 0 && sub.expireMs === 0) return 'unlimited';
return 'active';
}
export function daysUntil(expireMs: number, now: number): number | null {
if (expireMs <= 0) return null;
return Math.max(0, Math.ceil((expireMs - now) / DAY_MS));
}
export function usagePercent(usedByte: number, totalByte: number): number {
if (totalByte <= 0) return 0;
const pct = (usedByte / totalByte) * 100;
return Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
}
export type AppPlatform = 'android' | 'ios';
export function detectPlatform(userAgent: string): AppPlatform {
// iPadOS sends a Macintosh UA, and App Store clients also run on Apple-silicon Macs.
if (/iphone|ipad|ipod|macintosh/i.test(userAgent)) return 'ios';
return 'android';
}
export interface SubApp {
name: string;
url: string;
}
export interface SubAppSource {
subUrl: string;
sId: string;
subTitle: string;
}
export function buildSubApps({
subUrl,
sId,
subTitle,
}: SubAppSource): Record<AppPlatform, SubApp[]> {
const encSub = encodeURIComponent(subUrl);
const profileName = encodeURIComponent(subTitle || sId);
const v2box = {
name: 'V2Box',
url: `v2box://install-sub?url=${encSub}&name=${encodeURIComponent(sId)}`,
};
const singBox = {
name: 'Sing-box',
url: `sing-box://import-remote-profile?url=${encSub}#${profileName}`,
};
const v2raytun = { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` };
const happ = { name: 'Happ', url: `happ://add/${subUrl}` };
const incy = { name: 'Incy', url: `incy://add/${subUrl}` };
const rocketSource = `${subUrl}${subUrl.includes('?') ? '&' : '?'}flag=shadowrocket`;
const rocketRemark = encodeURIComponent(subTitle || sId || 'Subscription');
return {
android: [
v2box,
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
singBox,
v2raytun,
happ,
incy,
],
ios: [
{
name: 'Shadowrocket',
url: `shadowrocket://add/sub://${btoa(rocketSource)}?remark=${rocketRemark}`,
},
v2box,
{ name: 'Streisand', url: `streisand://import/${encSub}` },
v2raytun,
happ,
incy,
],
};
}
+4
View File
@@ -4,6 +4,9 @@ const port = z.number().int().min(1).max(65535);
const nonNegativeInt = z.number().int().min(0); const nonNegativeInt = z.number().int().min(0);
const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash'); const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
export const SubProfileModeSchema = z.enum(['none', 'builtin', 'custom']);
export type SubProfileMode = z.infer<typeof SubProfileModeSchema>;
export const AllSettingSchema = z export const AllSettingSchema = z
.object({ .object({
webListen: z.string().optional(), webListen: z.string().optional(),
@@ -50,6 +53,7 @@ export const AllSettingSchema = z
subClashUserAgentRegex: z.string().max(2048).optional(), subClashUserAgentRegex: z.string().max(2048).optional(),
subTitle: z.string().optional(), subTitle: z.string().optional(),
subSupportUrl: z.string().optional(), subSupportUrl: z.string().optional(),
subProfileMode: SubProfileModeSchema.optional(),
subProfileUrl: z.string().optional(), subProfileUrl: z.string().optional(),
subAnnounce: z.string().optional(), subAnnounce: z.string().optional(),
subEnableRouting: z.boolean().optional(), subEnableRouting: z.boolean().optional(),
@@ -0,0 +1,107 @@
import type { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { describe, expect, it } from 'vitest';
import { keys } from '@/api/queryKeys';
import { useInbounds } from '@/pages/inbounds/useInbounds';
import { makeTestQueryClient } from './test-utils';
function seedInbounds() {
const rows = [1, 2].map((id) => ({
id,
protocol: 'vless',
tag: `in-${id}`,
enable: true,
up: 10,
down: 20,
total: 0,
expiryTime: 0,
settings: JSON.stringify({ clients: [{ email: `c${id}@x`, enable: true }] }),
clientStats: [
{ email: `c${id}@x`, up: 1, down: 2, total: 0, expiryTime: 0, enable: true, inboundId: id },
],
}));
const queryClient = makeTestQueryClient();
queryClient.setQueryData(keys.inbounds.slim(), rows);
queryClient.setQueryData(keys.clients.onlines(), []);
queryClient.setQueryData(keys.clients.onlinesByGuid(), {});
queryClient.setQueryData(keys.clients.activeInbounds(), {});
queryClient.setQueryData(keys.clients.lastOnline(), {});
queryClient.setQueryData(keys.settings.defaults(), {});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { rows, wrapper };
}
async function renderInbounds() {
const { rows, wrapper } = seedInbounds();
const hook = renderHook(() => useInbounds(), { wrapper });
await waitFor(() => expect(hook.result.current.dbInbounds).toHaveLength(2));
return { rows, result: hook.result };
}
// Every client_stats push carries all inbounds' totals, so rebuilding a row whether or
// not its numbers moved re-ran the client rollup and the whole table on each push.
describe('inbound websocket merges keep unchanged state', () => {
it('keeps rows and the client rollup when a client_stats push changes nothing', async () => {
const { rows, result } = await renderInbounds();
const before = result.current.dbInbounds;
const rollup = result.current.clientCount;
act(() =>
result.current.applyClientStatsEvent({
inbounds: rows.map((r) => ({
id: r.id,
up: r.up,
down: r.down,
total: r.total,
enable: r.enable,
})),
clients: [{ email: 'c1@x', up: 1, down: 2, total: 0, expiryTime: 0, enable: true }],
}),
);
expect(result.current.dbInbounds).toBe(before);
expect(result.current.clientCount).toBe(rollup);
});
it('still rebuilds exactly the rows whose numbers moved', async () => {
const { result } = await renderInbounds();
const before = result.current.dbInbounds;
act(() =>
result.current.applyClientStatsEvent({
inbounds: [
{ id: 1, up: 99, down: 20, total: 0, enable: true },
{ id: 2, up: 10, down: 20, total: 0, enable: true },
],
clients: [{ email: 'c2@x', up: 5, down: 2, total: 0, expiryTime: 0, enable: true }],
}),
);
const [first, second] = result.current.dbInbounds;
expect(first).not.toBe(before[0]);
expect(first.up).toBe(99);
expect(second).not.toBe(before[1]);
expect(second.clientStats?.[0]?.up).toBe(5);
});
it('keeps the client rollup when a traffic push repeats the same online sets', async () => {
const { result } = await renderInbounds();
const push = () =>
result.current.applyTrafficEvent({
onlineClients: ['c1@x'],
onlineByGuid: { 'node:1': ['c1@x'] },
activeInbounds: { 'node:1': ['in-1'] },
});
act(push);
const rollup = result.current.clientCount;
act(push);
expect(result.current.clientCount).toBe(rollup);
});
});
@@ -0,0 +1,68 @@
import type { ReactNode } from 'react';
import { render } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '@/hooks/useTheme';
import NodeList from '@/pages/nodes/NodeList';
import type { NodeRecord } from '@/schemas/node';
import { makeTestQueryClient } from './test-utils';
const updateChecks = vi.hoisted(() => ({ count: 0 }));
vi.mock('@/lib/panel-version', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/panel-version')>();
return {
...actual,
isPanelUpdateAvailable: (...args: Parameters<typeof actual.isPanelUpdateAvailable>) => {
updateChecks.count++;
return actual.isPanelUpdateAvailable(...args);
},
};
});
// Every heartbeat push re-rendered all rows, unchanged ones too: the columns and
// table props were rebuilt on each render, so every cell re-ran its renderer.
describe('NodeList re-render', () => {
it('leaves the rows alone when its parent re-renders with the same nodes', () => {
const queryClient = makeTestQueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
);
const nodes: NodeRecord[] = [1, 2, 3].map((id) => ({
id,
name: `node-${id}`,
guid: `g${id}`,
transitive: false,
enable: true,
status: 'online',
panelVersion: '3.0.0',
}));
const noop = () => {};
const props = {
nodes,
isMobile: false,
latestVersion: '3.0.1',
selectedIds: [] as number[],
onSelectionChange: noop,
onAdd: noop,
onMtls: noop,
onEdit: noop,
onDelete: noop,
onProbe: noop,
onToggleEnable: noop,
onUpdateNode: noop,
onUpdateSelected: noop,
};
const view = render(<NodeList {...props} />, { wrapper });
expect(updateChecks.count).toBeGreaterThan(0);
updateChecks.count = 0;
view.rerender(<NodeList {...props} />);
expect(updateChecks.count).toBe(0);
});
});
@@ -336,6 +336,19 @@ describe('outbound-form-adapter: round-trip', () => {
expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 }); expect(rules[1]).toEqual({ action: 'return', qType: 28, domain: ['blocked.com'], rCode: 3 });
}); });
it('dns rules keep qType 0 a string, since the core reads a numeric 0 as every query', () => {
const back = formValuesToWirePayload(
rawOutboundToFormValues({
protocol: 'dns',
settings: { rules: [{ action: 'drop', qType: 0 }] },
}),
);
const rules = (back.settings as Record<string, unknown>).rules as Array<
Record<string, unknown>
>;
expect(rules[0]).toEqual({ action: 'drop', qType: '0' });
});
it('dns rules read the legacy qtype wire key for back-compat', () => { it('dns rules read the legacy qtype wire key for back-compat', () => {
const wire = { const wire = {
protocol: 'dns', protocol: 'dns',
+7
View File
@@ -30,6 +30,13 @@ describe('isPanelUpdateAvailable', () => {
expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true); expect(isPanelUpdateAvailable('nightly-2', 'nightly-1')).toBe(true);
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false); expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
}); });
it('compares dev builds by commit and never across channels', () => {
expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+0f0f0f0f')).toBe(true);
expect(isPanelUpdateAvailable('dev+1a2b3c4d', 'dev+1a2b3c4d')).toBe(false);
expect(isPanelUpdateAvailable('v3.5.0', 'dev+1a2b3c4d')).toBe(false);
expect(isPanelUpdateAvailable('dev+1a2b3c4d', '3.5.0')).toBe(false);
});
}); });
describe('formatPanelVersion', () => { describe('formatPanelVersion', () => {
+4 -4
View File
@@ -81,9 +81,9 @@ describe('QrPanel dense AmneziaWG config', () => {
const completeQr = qrGeometry(complete); const completeQr = qrGeometry(complete);
const shorterQr = qrGeometry(withoutDisableCookies); const shorterQr = qrGeometry(withoutDisableCookies);
expect(completeQr.viewBox).toBe('0 0 105 105'); expect(completeQr.viewBox).toBe('0 0 101 101');
expect(shorterQr.viewBox).toBe('0 0 101 101'); expect(shorterQr.viewBox).toBe('0 0 97 97');
expect(completeQr.foreground).toMatch(/^M4 4h7/); expect(completeQr.foreground).toMatch(/^M2 2h7/);
expect(shorterQr.foreground).toMatch(/^M4 4h7/); expect(shorterQr.foreground).toMatch(/^M2 2h7/);
}); });
}); });
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema } from '@/schemas/setting';
describe('subscription profile mode', () => {
it.each([undefined, {}, { subProfileUrl: '' }, { subProfileUrl: ' ' }])(
'defaults to no link without a legacy URL: %j',
(data) => {
expect(new AllSetting(data).subProfileMode).toBe('none');
},
);
it('preserves a legacy custom URL when its mode is missing', () => {
const setting = new AllSetting({ subProfileUrl: 'https://example.com/profile' });
expect(setting.subProfileMode).toBe('custom');
expect(setting.subProfileUrl).toBe('https://example.com/profile');
});
it.each(['none', 'builtin', 'custom'])(
'honors the explicit %s mode with a stored URL',
(mode) => {
const result = AllSettingSchema.safeParse({
subProfileMode: mode,
subProfileUrl: 'https://example.com/profile',
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(new AllSetting(result.data).subProfileMode).toBe(mode);
},
);
it.each(['auto', '', null, true])('rejects an invalid mode: %j', (mode) => {
expect(AllSettingSchema.safeParse({ subProfileMode: mode }).success).toBe(false);
});
});
+30 -2
View File
@@ -1,8 +1,8 @@
import { render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest'; import { afterEach, expect, test } from 'vitest';
import { withTheme } from '../../.storybook/preview'; import { withTheme } from '../../.storybook/preview';
import { ThemeProvider } from '@/hooks/useTheme'; import { ThemeProvider, useTheme } from '@/hooks/useTheme';
function Story() { function Story() {
return <div>Story</div>; return <div>Story</div>;
@@ -14,9 +14,37 @@ function StorybookTheme({ theme }: { theme: 'light' | 'dark' }) {
> as Parameters<typeof withTheme>[1]); > as Parameters<typeof withTheme>[1]);
} }
function ThemeToggle() {
const { toggleTheme } = useTheme();
return <button onClick={toggleTheme}>toggle</button>;
}
afterEach(() => { afterEach(() => {
document.body.className = ''; document.body.className = '';
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
document.documentElement.style.colorScheme = '';
});
// Without color-scheme the browser paints native scrollbars light inside dark
// modals, e.g. the Edit Client body.
test('native scrollbars follow the panel theme', () => {
const { getByRole } = render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
expect(document.documentElement.style.colorScheme).toBe('dark');
fireEvent.click(getByRole('button'));
expect(document.documentElement.style.colorScheme).toBe('light');
});
test('native scrollbars follow the Storybook theme', () => {
const { rerender } = render(<StorybookTheme theme="light" />);
expect(document.documentElement.style.colorScheme).toBe('light');
rerender(<StorybookTheme theme="dark" />);
expect(document.documentElement.style.colorScheme).toBe('dark');
}); });
test('preserves unrelated body classes when applying the Storybook theme', () => { test('preserves unrelated body classes when applying the Storybook theme', () => {
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { APP_ICONS } from '@/pages/sub/appIcons';
import { buildSubApps } from '@/pages/sub/subPageModel';
describe('APP_ICONS', () => {
it('has an icon for every app the subscription page offers on every platform', () => {
const apps = buildSubApps({
subUrl: 'https://sub.example.com/sub/abc',
sId: 'abc',
subTitle: '',
});
const names = [...new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)))];
expect(names.filter((name) => !APP_ICONS[name]?.src)).toEqual([]);
});
it('carries no icon for an app the page no longer offers', () => {
const apps = buildSubApps({
subUrl: 'https://sub.example.com/sub/abc',
sId: 'abc',
subTitle: '',
});
const offered = new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)));
expect(Object.keys(APP_ICONS).filter((name) => !offered.has(name))).toEqual([]);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import {
buildSubApps,
daysUntil,
detectPlatform,
resolveSubStatus,
usagePercent,
} from '@/pages/sub/subPageModel';
const DAY = 86_400_000;
const NOW = Date.UTC(2026, 8, 15, 12, 0, 0);
describe('resolveSubStatus', () => {
const base = { enabled: true, usedByte: 10, totalByte: 100, expireMs: NOW + DAY };
it.each([
[
'disabled wins over expiry and quota',
{ ...base, enabled: false, expireMs: NOW - DAY },
'disabled',
],
['expired at the expiry instant', { ...base, expireMs: NOW }, 'expired'],
['expired beats depleted', { ...base, usedByte: 100, expireMs: NOW - DAY }, 'expired'],
['depleted once usage reaches the quota', { ...base, usedByte: 100 }, 'depleted'],
[
'unlimited with neither quota nor expiry',
{ ...base, totalByte: 0, expireMs: 0 },
'unlimited',
],
['active without quota but with a future expiry', { ...base, totalByte: 0 }, 'active'],
['active inside quota and before expiry', base, 'active'],
] as const)('%s', (_name, input, want) => {
expect(resolveSubStatus(input, NOW)).toBe(want);
});
});
describe('daysUntil', () => {
it('is null for a subscription that never expires', () => {
expect(daysUntil(0, NOW)).toBeNull();
});
it('rounds the last partial day up to 1', () => {
expect(daysUntil(NOW + 3 * 3_600_000, NOW)).toBe(1);
});
it('counts whole days', () => {
expect(daysUntil(NOW + 23 * DAY, NOW)).toBe(23);
});
it('stays at 0 after expiry', () => {
expect(daysUntil(NOW - DAY, NOW)).toBe(0);
});
});
describe('usagePercent', () => {
it('is 0 without a quota instead of NaN or Infinity', () => {
expect(usagePercent(5_000, 0)).toBe(0);
});
it('clamps an over-quota client to 100', () => {
expect(usagePercent(150, 100)).toBe(100);
});
});
describe('detectPlatform', () => {
it.each([
[
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36',
'android',
],
[
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
'ios',
],
[
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15',
'ios',
],
[
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36',
'android',
],
['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'android'],
])('%s -> %s', (ua, want) => {
expect(detectPlatform(ua)).toBe(want);
});
});
describe('buildSubApps', () => {
const subUrl = 'https://sub.example.com/sub/abc';
const encSub = encodeURIComponent(subUrl);
const sub = { subUrl, sId: 'abc', subTitle: 'Nova Net' };
it('offers Android and iOS app lists only', () => {
expect(Object.keys(buildSubApps(sub))).toEqual(['android', 'ios']);
});
it('gives every Android app a one-tap import link', () => {
expect(buildSubApps(sub).android).toEqual([
{ name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
{ name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
{ name: 'Sing-box', url: `sing-box://import-remote-profile?url=${encSub}#Nova%20Net` },
{ name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
{ name: 'Happ', url: `happ://add/${subUrl}` },
{ name: 'Incy', url: `incy://add/${subUrl}` },
]);
});
it('gives every iOS app a one-tap import link', () => {
const rocket = Buffer.from(`${subUrl}?flag=shadowrocket`).toString('base64');
expect(buildSubApps(sub).ios).toEqual([
{ name: 'Shadowrocket', url: `shadowrocket://add/sub://${rocket}?remark=Nova%20Net` },
{ name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
{ name: 'Streisand', url: `streisand://import/${encSub}` },
{ name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
{ name: 'Happ', url: `happ://add/${subUrl}` },
{ name: 'Incy', url: `incy://add/${subUrl}` },
]);
});
it('names the sing-box profile after the subscription id when there is no title', () => {
expect(buildSubApps({ ...sub, subTitle: '' }).android[2].url).toBe(
`sing-box://import-remote-profile?url=${encSub}#abc`,
);
});
it('appends flag=shadowrocket with & when the subscription URL already has a query', () => {
const withQuery = { ...sub, subUrl: `${subUrl}?token=1` };
const rocket = Buffer.from(`${subUrl}?token=1&flag=shadowrocket`).toString('base64');
expect(buildSubApps(withQuery).ios[0].url).toBe(
`shadowrocket://add/sub://${rocket}?remark=Nova%20Net`,
);
});
});
@@ -1,10 +1,29 @@
import { useState } from 'react';
import { fireEvent, screen } from '@testing-library/react'; import { fireEvent, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router'; import { MemoryRouter, useLocation } from 'react-router';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { AllSetting } from '@/models/setting'; import { AllSetting } from '@/models/setting';
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab'; import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
import { renderWithProviders } from './test-utils'; import { chooseSelectOption, renderWithProviders } from './test-utils';
function ProfileSettingsHarness({ initial }: { initial?: unknown }) {
const [allSetting, setAllSetting] = useState(() => new AllSetting(initial));
return (
<>
<SubscriptionGeneralTab
allSetting={allSetting}
updateSetting={(patch) =>
setAllSetting((current) => new AllSetting({ ...current, ...patch }))
}
/>
<output data-testid="profile-settings">
{allSetting.subProfileMode}|{allSetting.subProfileUrl}
</output>
</>
);
}
function LocationProbe() { function LocationProbe() {
const location = useLocation(); const location = useLocation();
@@ -18,6 +37,60 @@ function LocationProbe() {
} }
describe('SubscriptionGeneralTab', () => { describe('SubscriptionGeneralTab', () => {
it('switches profile modes without losing the custom URL and warns only for the built-in page', () => {
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
const editedUrl = 'https://example.com/account/{{SUB_ID}}';
const warning =
'This page exposes subscription URLs and node configurations, including for Happ encrypted subscriptions.';
renderWithProviders(
<MemoryRouter initialEntries={['/settings#subscription']}>
<ProfileSettingsHarness initial={{ subProfileMode: 'none', subProfileUrl: storedUrl }} />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${storedUrl}`);
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
expect(screen.queryByText(warning)).toBeNull();
chooseSelectOption('sub-profile-mode', 'Built-in subscription page');
expect(screen.getByTestId('profile-settings').textContent).toBe(`builtin|${storedUrl}`);
expect(screen.getByRole('alert').textContent).toContain(warning);
expect(screen.queryByDisplayValue(storedUrl)).toBeNull();
chooseSelectOption('sub-profile-mode', 'Custom website');
expect(screen.queryByText(warning)).toBeNull();
fireEvent.change(screen.getByDisplayValue(storedUrl), { target: { value: editedUrl } });
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
chooseSelectOption('sub-profile-mode', 'No link');
expect(screen.getByTestId('profile-settings').textContent).toBe(`none|${editedUrl}`);
expect(screen.queryByDisplayValue(editedUrl)).toBeNull();
expect(screen.queryByText(warning)).toBeNull();
chooseSelectOption('sub-profile-mode', 'Custom website');
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${editedUrl}`);
expect(screen.getByDisplayValue(editedUrl)).toBeTruthy();
});
it('opens a legacy custom profile URL with custom mode selected', () => {
const storedUrl = 'https://example.com/profile/{{SUB_ID}}';
renderWithProviders(
<MemoryRouter initialEntries={['/settings#subscription']}>
<ProfileSettingsHarness initial={{ subProfileUrl: storedUrl }} />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('tab', { name: /Profile/ }));
expect(screen.getByRole('combobox', { name: 'Profile page' })).toBeTruthy();
expect(screen.getByText('Custom website')).toBeTruthy();
expect(screen.getByDisplayValue(storedUrl)).toBeTruthy();
expect(screen.getByTestId('profile-settings').textContent).toBe(`custom|${storedUrl}`);
});
it('keeps the stored subscription port when the field is cleared', () => { it('keeps the stored subscription port when the field is cleared', () => {
const updateSetting = vi.fn(); const updateSetting = vi.fn();
+1 -1
View File
@@ -112,7 +112,7 @@ func IsAmneziaWGOutbound(raw []byte) bool {
if err := json.Unmarshal(raw, &probe); err != nil { if err := json.Unmarshal(raw, &probe); err != nil {
return false return false
} }
return probe.Protocol == "amneziawg" return strings.EqualFold(probe.Protocol, "amneziawg")
} }
// outboundSettingsOf extracts the nested "settings" block from a raw // outboundSettingsOf extracts the nested "settings" block from a raw
+27 -7
View File
@@ -134,14 +134,11 @@ func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
return out return out
} }
// desiredPortForwardKeys returns the full set of listener keys inst wants // forwardingPeers is the one gate a host listener comes from: no email, port
// right now: one tcpForward and one udpForward key per port in every peer's // spec and resolvable target (see desiredPeerTargets), no socket.
// ForwardedPorts spec, for every peer that also has a resolvable target func forwardingPeers(inst amneziawg.Instance) []amneziawg.Peer {
// (see desiredPeerTargets) -- a key never exists without a target, so
// Reconcile can always resolve one for any key it opens.
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
out := map[portForwardKey]struct{}{}
targets := desiredPeerTargets(inst) targets := desiredPeerTargets(inst)
out := make([]amneziawg.Peer, 0, len(inst.Peers))
for _, p := range inst.Peers { for _, p := range inst.Peers {
if p.Email == "" || p.ForwardedPorts == "" { if p.Email == "" || p.ForwardedPorts == "" {
continue continue
@@ -149,6 +146,16 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
if _, ok := targets[p.Email]; !ok { if _, ok := targets[p.Email]; !ok {
continue continue
} }
out = append(out, p)
}
return out
}
// desiredPortForwardKeys returns every listener key inst wants right now: one
// tcpForward and one udpForward per forwarded port of the forwarding peers.
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
out := map[portForwardKey]struct{}{}
for _, p := range forwardingPeers(inst) {
for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) { for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{} out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{} out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
@@ -157,6 +164,19 @@ func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{}
return out return out
} }
// ForwardedPortOwner names the peer Reconcile opens a listener on port for --
// the same peers and expansion as desiredPortForwardKeys, never a silent one.
func ForwardedPortOwner(inst amneziawg.Instance, port int) (string, bool) {
for _, p := range forwardingPeers(inst) {
for _, candidate := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
if candidate == port {
return p.Email, true
}
}
}
return "", false
}
// Reconcile brings the supervisor's open listeners in line with what inst // Reconcile brings the supervisor's open listeners in line with what inst
// currently wants: closes anything no longer desired, opens anything newly // currently wants: closes anything no longer desired, opens anything newly
// desired, leaves everything else untouched. Never returns an error -- // desired, leaves everything else untouched. Never returns an error --
+5 -2
View File
@@ -11,10 +11,13 @@ import (
// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings). // own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
const SOCKSBasePort = 65100 const SOCKSBasePort = 65100
// relayPortSlots is how many ids fit above SOCKSBasePort before wrapping.
const relayPortSlots = 65535 - SOCKSBasePort
// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from // SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
// its id, so config generation and the dialing relay never need to negotiate. // its id, wrapping ids past relayPortSlots so no id ever lacks a port.
func SOCKSPortForInbound(inboundID int) int { func SOCKSPortForInbound(inboundID int) int {
return SOCKSBasePort + inboundID return SOCKSBasePort + 1 + (inboundID-1)%relayPortSlots
} }
var ( var (
@@ -0,0 +1,26 @@
package amneziawgnet
import "testing"
// An inbound id past the slot count used to be refused outright, which capped a
// database at 435 AmneziaWG inbounds for its whole life (#6537).
func TestSOCKSPortForInboundKeepsEverySlotInsideTheWindow(t *testing.T) {
t.Run("no id derives a port outside the window", func(t *testing.T) {
for _, id := range []int{1, 2, 434, 435, 436, 437, 870, 871, 6537, 70350, 1_000_000} {
port := SOCKSPortForInbound(id)
if port < SOCKSBasePort+1 || port > 65535 {
t.Errorf("id %d derives relay port %d, outside %d..65535", id, port, SOCKSBasePort+1)
}
}
})
// Every id the old formula reached must keep its exact port, or upgrading
// moves a running relay. Ids 1..435 also leave SOCKSBasePort itself unused.
t.Run("ids up to the slot count keep the port they always had", func(t *testing.T) {
for id := 1; id <= 435; id++ {
if got, want := SOCKSPortForInbound(id), SOCKSBasePort+id; got != want {
t.Errorf("id %d moved from relay port %d to %d", id, want, got)
}
}
})
}
+1 -1
View File
@@ -1 +1 @@
3.8.0 3.8.5
+76 -3
View File
@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
} }
if empty && isUsersEmpty { if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
for _, name := range seeders { for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err return err
@@ -1383,6 +1383,12 @@ func runSeeders(isUsersEmpty bool) error {
} }
} }
if !slices.Contains(seedersHistory, "DNSOutboundQTypeZeroFix") {
if err := migrateDNSOutboundQTypeZero(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") { if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
if err := seedNodeInboundsAdopted(); err != nil { if err := seedNodeInboundsAdopted(); err != nil {
return err return err
@@ -1920,9 +1926,10 @@ func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
return append(rules, fallback) return append(rules, fallback)
} }
// dnsQTypeValue keeps a lone qType a number, the way the core marshals one. // dnsQTypeValue keeps a lone qType a number the way the core marshals one, except
// 0: the core drops a numeric 0, and a rule with no qTypes matches every query.
func dnsQTypeValue(blockTypes []int) any { func dnsQTypeValue(blockTypes []int) any {
if len(blockTypes) == 1 { if len(blockTypes) == 1 && blockTypes[0] != 0 {
return blockTypes[0] return blockTypes[0]
} }
parts := make([]string, 0, len(blockTypes)) parts := make([]string, 0, len(blockTypes))
@@ -1932,6 +1939,72 @@ func dnsQTypeValue(blockTypes []int) any {
return strings.Join(parts, ",") return strings.Join(parts, ",")
} }
// migrateDNSOutboundQTypeZero repairs the numeric qType 0 that 3.8.0's legacy-keys
// seeder stored, which that seeder's own history row keeps it from revisiting.
func migrateDNSOutboundQTypeZero() error {
var setting model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
}
if err != nil {
return err
}
updated, changed, rErr := RewriteDNSOutboundQTypeZero(setting.Value)
if rErr != nil {
log.Printf("DNSOutboundQTypeZeroFix: skip (invalid xrayTemplateConfig json): %v", rErr)
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
}
return db.Transaction(func(tx *gorm.DB) error {
if changed {
if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
Update("value", updated).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
})
}
// RewriteDNSOutboundQTypeZero spells a dns rule's numeric qType 0 as "0", the one
// form the core reads as query type 0 rather than as every query.
func RewriteDNSOutboundQTypeZero(raw string) (string, bool, error) {
if strings.TrimSpace(raw) == "" {
return raw, false, nil
}
var cfg map[string]any
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return raw, false, err
}
outbounds, _ := cfg["outbounds"].([]any)
changed := false
for _, ob := range outbounds {
obj, _ := ob.(map[string]any)
if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
continue
}
settings, _ := obj["settings"].(map[string]any)
rules, _ := settings["rules"].([]any)
for _, r := range rules {
rule, _ := r.(map[string]any)
if qType, ok := rule["qType"].(float64); ok && qType == 0 {
rule["qType"] = "0"
changed = true
}
}
}
if !changed {
return raw, false, nil
}
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return raw, false, err
}
return string(out), true, nil
}
func normalizeSettingPaths() error { func normalizeSettingPaths() error {
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"} pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
var rows []model.Setting var rows []model.Setting
@@ -0,0 +1,99 @@
package database
import (
"encoding/json"
"strings"
"testing"
"github.com/xtls/xray-core/infra/conf"
"github.com/xtls/xray-core/proxy/dns"
"google.golang.org/protobuf/proto"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// The core drops a lone numeric qType 0 from its PortList, and a rule with no
// qTypes matches every query, so blockTypes [0] must not be written that way.
func TestRewriteDNSOutboundLegacyKeysKeepsQTypeZeroPolicy(t *testing.T) {
for _, mode := range []string{"skip", "reject"} {
t.Run(mode, func(t *testing.T) {
legacy := `{"protocol":"dns","tag":"dns-out","settings":{"nonIPQuery":"` + mode + `","blockTypes":[0]}}`
updated, changed, err := rewriteDNSOutboundLegacyKeys(`{"outbounds":[` + legacy + `]}`)
if err != nil || !changed {
t.Fatalf("rewrite: changed=%v err=%v", changed, err)
}
got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, updated))
if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
t.Fatalf("rewritten policy = %v, want the legacy policy %v", got, want)
}
})
}
}
// Installs that already ran the legacy-keys seeder store the match-all rule, and
// that seeder never runs again, so the repair has to reach them on its own.
func TestSeedersRepairStoredDNSQTypeZero(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := InitDB(config.GetDBPath()); err != nil {
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
t.Skipf("sqlite needs cgo: %v", err)
}
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
// The legacy-keys seeder matched the protocol id without case, so it wrote both.
for _, protocol := range []string{"dns", "DNS"} {
t.Run(protocol, func(t *testing.T) {
legacy := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"nonIPQuery":"drop","blockTypes":[0]}}`
stored := `{"protocol":"` + protocol + `","tag":"dns-out","settings":{"rules":[{"action":"drop","qType":0},{"action":"hijack","qType":"1,28"},{"action":"drop"}]}}`
seedDNSOutboundTemplate(t, `{"outbounds":[`+stored+`]}`)
if err := db.Where("seeder_name = ?", "DNSOutboundQTypeZeroFix").
Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := runSeeders(false); err != nil {
t.Fatalf("runSeeders: %v", err)
}
got := coreDNSOutboundPolicy(t, firstTemplateOutbound(t, storedDNSOutboundTemplate(t)))
if want := coreDNSOutboundPolicy(t, []byte(legacy)); !proto.Equal(got, want) {
t.Fatalf("repaired policy = %v, want the legacy policy %v", got, want)
}
})
}
}
func firstTemplateOutbound(t *testing.T, template string) []byte {
t.Helper()
var cfg struct {
Outbounds []json.RawMessage `json:"outbounds"`
}
if err := json.Unmarshal([]byte(template), &cfg); err != nil || len(cfg.Outbounds) == 0 {
t.Fatalf("template has no outbound (%v): %s", err, template)
}
return cfg.Outbounds[0]
}
func coreDNSOutboundPolicy(t *testing.T, raw []byte) *dns.Config {
t.Helper()
var outbound conf.OutboundDetourConfig
if err := json.Unmarshal(raw, &outbound); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
handler, err := outbound.Build()
if err != nil {
t.Fatalf("core build: %v", err)
}
instance, err := handler.ProxySettings.GetInstance()
if err != nil {
t.Fatalf("core settings: %v", err)
}
cfg, ok := instance.(*dns.Config)
if !ok {
t.Fatalf("core settings type = %T, want *dns.Config", instance)
}
return cfg
}
+32 -17
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/op/go-logging" "github.com/op/go-logging"
@@ -30,10 +31,13 @@ const (
) )
var ( var (
// Initialized to a usable default so logging never nil-derefs before InitLogger // InitLogger swaps the handle while other goroutines are logging, so it is
// runs — the "migrate" and "setting" CLI subcommands log without calling it. // published atomically — a plain assignment is an unsafe publication.
logger = logging.MustGetLogger("x-ui") logger atomic.Pointer[logging.Logger]
fileRotate *lumberjack.Logger // nil when file backend disabled
// fileRotateMu guards fileRotate against a concurrent InitLogger/CloseLogger.
fileRotateMu sync.Mutex
fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval; // logBuffer maintains recent log entries in memory for web UI retrieval;
// logBufferMu guards it — written from many goroutines, read by the web UI. // logBufferMu guards it — written from many goroutines, read by the web UI.
@@ -45,6 +49,12 @@ var (
} }
) )
// A usable default so logging never nil-derefs before InitLogger runs — the
// "migrate" and "setting" CLI subcommands log without calling it.
func init() {
logger.Store(logging.MustGetLogger("x-ui"))
}
// InitLogger initializes dual logging backends: console/syslog and file. // InitLogger initializes dual logging backends: console/syslog and file.
// Console logging uses the specified level, file logging always uses DEBUG level. // Console logging uses the specified level, file logging always uses DEBUG level.
func InitLogger(level logging.Level) { func InitLogger(level logging.Level) {
@@ -66,7 +76,7 @@ func InitLogger(level logging.Level) {
multiBackend := logging.MultiLogger(backends...) multiBackend := logging.MultiLogger(backends...)
newLogger.SetBackend(multiBackend) newLogger.SetBackend(multiBackend)
logger = newLogger logger.Store(newLogger)
} }
// initDefaultBackend creates the console/syslog logging backend. // initDefaultBackend creates the console/syslog logging backend.
@@ -104,7 +114,7 @@ func initFileBackend() logging.Backend {
} }
logPath := filepath.Join(logDir, logFileName) logPath := filepath.Join(logDir, logFileName)
fileRotate = &lumberjack.Logger{ rotate := &lumberjack.Logger{
Filename: logPath, Filename: logPath,
MaxSize: maxLogFileMB, MaxSize: maxLogFileMB,
MaxBackups: maxLogBackups, MaxBackups: maxLogBackups,
@@ -112,8 +122,11 @@ func initFileBackend() logging.Backend {
LocalTime: true, LocalTime: true,
Compress: compressRotated, Compress: compressRotated,
} }
fileRotateMu.Lock()
fileRotate = rotate
fileRotateMu.Unlock()
backend := logging.NewLogBackend(fileRotate, "", 0) backend := logging.NewLogBackend(rotate, "", 0)
return logging.NewBackendFormatter(backend, newFormatter(true)) return logging.NewBackendFormatter(backend, newFormatter(true))
} }
@@ -129,6 +142,8 @@ func newFormatter(withTime bool) logging.Formatter {
// CloseLogger closes the rotating log writer and cleans up resources. // CloseLogger closes the rotating log writer and cleans up resources.
// Should be called during application shutdown. // Should be called during application shutdown.
func CloseLogger() { func CloseLogger() {
fileRotateMu.Lock()
defer fileRotateMu.Unlock()
if fileRotate != nil { if fileRotate != nil {
_ = fileRotate.Close() _ = fileRotate.Close()
fileRotate = nil fileRotate = nil
@@ -137,61 +152,61 @@ func CloseLogger() {
// Debug logs a debug message and adds it to the log buffer. // Debug logs a debug message and adds it to the log buffer.
func Debug(args ...any) { func Debug(args ...any) {
logger.Debug(args...) logger.Load().Debug(args...)
addToBuffer("DEBUG", fmt.Sprint(args...)) addToBuffer("DEBUG", fmt.Sprint(args...))
} }
// Debugf logs a formatted debug message and adds it to the log buffer. // Debugf logs a formatted debug message and adds it to the log buffer.
func Debugf(format string, args ...any) { func Debugf(format string, args ...any) {
logger.Debugf(format, args...) logger.Load().Debugf(format, args...)
addToBuffer("DEBUG", fmt.Sprintf(format, args...)) addToBuffer("DEBUG", fmt.Sprintf(format, args...))
} }
// Info logs an info message and adds it to the log buffer. // Info logs an info message and adds it to the log buffer.
func Info(args ...any) { func Info(args ...any) {
logger.Info(args...) logger.Load().Info(args...)
addToBuffer("INFO", fmt.Sprint(args...)) addToBuffer("INFO", fmt.Sprint(args...))
} }
// Infof logs a formatted info message and adds it to the log buffer. // Infof logs a formatted info message and adds it to the log buffer.
func Infof(format string, args ...any) { func Infof(format string, args ...any) {
logger.Infof(format, args...) logger.Load().Infof(format, args...)
addToBuffer("INFO", fmt.Sprintf(format, args...)) addToBuffer("INFO", fmt.Sprintf(format, args...))
} }
// Notice logs a notice message and adds it to the log buffer. // Notice logs a notice message and adds it to the log buffer.
func Notice(args ...any) { func Notice(args ...any) {
logger.Notice(args...) logger.Load().Notice(args...)
addToBuffer("NOTICE", fmt.Sprint(args...)) addToBuffer("NOTICE", fmt.Sprint(args...))
} }
// Noticef logs a formatted notice message and adds it to the log buffer. // Noticef logs a formatted notice message and adds it to the log buffer.
func Noticef(format string, args ...any) { func Noticef(format string, args ...any) {
logger.Noticef(format, args...) logger.Load().Noticef(format, args...)
addToBuffer("NOTICE", fmt.Sprintf(format, args...)) addToBuffer("NOTICE", fmt.Sprintf(format, args...))
} }
// Warning logs a warning message and adds it to the log buffer. // Warning logs a warning message and adds it to the log buffer.
func Warning(args ...any) { func Warning(args ...any) {
logger.Warning(args...) logger.Load().Warning(args...)
addToBuffer("WARNING", fmt.Sprint(args...)) addToBuffer("WARNING", fmt.Sprint(args...))
} }
// Warningf logs a formatted warning message and adds it to the log buffer. // Warningf logs a formatted warning message and adds it to the log buffer.
func Warningf(format string, args ...any) { func Warningf(format string, args ...any) {
logger.Warningf(format, args...) logger.Load().Warningf(format, args...)
addToBuffer("WARNING", fmt.Sprintf(format, args...)) addToBuffer("WARNING", fmt.Sprintf(format, args...))
} }
// Error logs an error message and adds it to the log buffer. // Error logs an error message and adds it to the log buffer.
func Error(args ...any) { func Error(args ...any) {
logger.Error(args...) logger.Load().Error(args...)
addToBuffer("ERROR", fmt.Sprint(args...)) addToBuffer("ERROR", fmt.Sprint(args...))
} }
// Errorf logs a formatted error message and adds it to the log buffer. // Errorf logs a formatted error message and adds it to the log buffer.
func Errorf(format string, args ...any) { func Errorf(format string, args ...any) {
logger.Errorf(format, args...) logger.Load().Errorf(format, args...)
addToBuffer("ERROR", fmt.Sprintf(format, args...)) addToBuffer("ERROR", fmt.Sprintf(format, args...))
} }
+30
View File
@@ -2,7 +2,10 @@ package logger
import ( import (
"fmt" "fmt"
"sync"
"testing" "testing"
golog "github.com/op/go-logging"
) )
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract. // TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
@@ -28,3 +31,30 @@ func TestGetLogs_ReturnsAtMostC(t *testing.T) {
} }
} }
} }
// InitLogger replaces the package logger while other goroutines are already
// logging — CI caught that as a data race between InitLogger and Warningf.
func TestInitLoggerConcurrentWithLogging(t *testing.T) {
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
stop := make(chan struct{})
var logging sync.WaitGroup
logging.Add(1)
go func() {
defer logging.Done()
for {
select {
case <-stop:
return
default:
Warningf("concurrent %s", "log")
}
}
}()
for range 10 {
InitLogger(golog.CRITICAL)
}
close(stop)
logging.Wait()
}
+20 -6
View File
@@ -13,6 +13,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -53,6 +54,7 @@ type cachedSubTemplate struct {
type SUBController struct { type SUBController struct {
subTitle string subTitle string
subSupportUrl string subSupportUrl string
subProfileMode string
subProfileUrl string subProfileUrl string
subAnnounce string subAnnounce string
subEnableRouting bool subEnableRouting bool
@@ -115,6 +117,7 @@ type subControllerConfig struct {
subTitle string subTitle string
subSupportURL string subSupportURL string
subProfileMode string
subProfileURL string subProfileURL string
subAnnounce string subAnnounce string
subEnableRouting bool subEnableRouting bool
@@ -224,6 +227,10 @@ func WithSUBProfileURL(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subProfileURL = value } return func(config *subControllerConfig) { config.subProfileURL = value }
} }
func WithSUBProfileMode(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subProfileMode = value }
}
func WithSUBAnnounce(value string) SUBControllerOption { func WithSUBAnnounce(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subAnnounce = value } return func(config *subControllerConfig) { config.subAnnounce = value }
} }
@@ -260,6 +267,7 @@ func defaultSUBControllerConfig() subControllerConfig {
subEncrypt: true, subEncrypt: true,
remarkTemplate: service.DefaultRemarkTemplate, remarkTemplate: service.DefaultRemarkTemplate,
updateInterval: "12", updateInterval: "12",
subProfileMode: service.SubProfileModeNone,
} }
} }
@@ -277,6 +285,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
a := &SUBController{ a := &SUBController{
subTitle: config.subTitle, subTitle: config.subTitle,
subSupportUrl: config.subSupportURL, subSupportUrl: config.subSupportURL,
subProfileMode: config.subProfileMode,
subProfileUrl: config.subProfileURL, subProfileUrl: config.subProfileURL,
subAnnounce: config.subAnnounce, subAnnounce: config.subAnnounce,
subEnableRouting: config.subEnableRouting, subEnableRouting: config.subEnableRouting,
@@ -485,8 +494,7 @@ func (a *SUBController) subs(c *gin.Context) {
// Add headers // Add headers
header := subReq.subscriptionUserinfo(traffic) header := subReq.subscriptionUserinfo(traffic)
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, builtinProfileURL(c, scheme, hostWithPort))
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" { if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
@@ -653,6 +661,8 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
if datepicker == "" { if datepicker == "" {
datepicker = "gregorian" datepicker = "gregorian"
} }
subUpdates, _ := a.settingService.GetSubUpdates()
updateHours, _ := strconv.Atoi(subUpdates)
return map[string]any{ return map[string]any{
"sId": page.SId, "sId": page.SId,
@@ -673,6 +683,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
"subClashUrl": page.SubClashUrl, "subClashUrl": page.SubClashUrl,
"subTitle": page.SubTitle, "subTitle": page.SubTitle,
"subSupportUrl": page.SubSupportUrl, "subSupportUrl": page.SubSupportUrl,
"subUpdates": updateHours,
"links": page.Result, "links": page.Result,
"emails": page.Emails, "emails": page.Emails,
"datepicker": datepicker, "datepicker": datepicker,
@@ -818,14 +829,13 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
if len(jsonSub) == 0 && header == "" { if len(jsonSub) == 0 && header == "" {
return false return false
} }
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
var subReq *SubService var subReq *SubService
metadata := a.metadataForSubRequest(func() *SubService { metadata := a.metadataForSubRequest(func() *SubService {
if subReq == nil { if subReq == nil {
subReq = a.subService.ForRequest(host) subReq = a.subService.ForRequest(host)
} }
return subReq return subReq
}, subId, profileURL) }, subId, builtinProfileURL(c, scheme, hostWithPort))
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if rawDownload { if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`) c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
@@ -887,14 +897,13 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
if len(clashSub) == 0 && header == "" { if len(clashSub) == 0 && header == "" {
return false return false
} }
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
var subReq *SubService var subReq *SubService
metadata := a.metadataForSubRequest(func() *SubService { metadata := a.metadataForSubRequest(func() *SubService {
if subReq == nil { if subReq == nil {
subReq = a.subService.ForRequest(host) subReq = a.subService.ForRequest(host)
} }
return subReq return subReq
}, subId, profileURL) }, subId, builtinProfileURL(c, scheme, hostWithPort))
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings) a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if rawDownload { if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`) c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
@@ -906,6 +915,11 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool, legacy
return true return true
} }
func builtinProfileURL(c *gin.Context, scheme, hostWithPort string) string {
// Drop download/format selectors so the opt-in link always opens the HTML page.
return fmt.Sprintf("%s://%s%s?html=1", scheme, hostWithPort, c.Request.URL.EscapedPath())
}
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders( func (a *SUBController) ApplyCommonHeaders(
c *gin.Context, c *gin.Context,
+3
View File
@@ -76,6 +76,9 @@ func TestSubInfoEndpoint_ServesStatusJSONEvenForBrowsers(t *testing.T) {
t.Fatalf("info payload missing %q; body=%s", key, w.Body.String()) t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
} }
} }
if info["subUpdates"] != float64(12) {
t.Fatalf("subUpdates = %v, want the default 12-hour interval", info["subUpdates"])
}
} }
func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) { func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {
+12 -8
View File
@@ -10,6 +10,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"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/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
) )
type subPlaceholderData struct { type subPlaceholderData struct {
@@ -76,10 +77,17 @@ func subMetadataUsesPlaceholders(values ...string) bool {
return false return false
} }
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata { func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID, builtinURL string) renderedSubMetadata {
profileURL := ""
switch a.subProfileMode {
case service.SubProfileModeBuiltin:
profileURL = builtinURL
case service.SubProfileModeCustom:
profileURL = strings.TrimSpace(a.subProfileUrl)
}
var context remarkContext var context remarkContext
var hasContext bool var hasContext bool
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) { if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, profileURL, a.subAnnounce) {
var err error var err error
subReq := getSubReq() subReq := getSubReq()
context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID) context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
@@ -87,12 +95,8 @@ func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subI
logger.Warning("sub: load template contexts for subscription metadata:", err) logger.Warning("sub: load template contexts for subscription metadata:", err)
} }
} }
profileURL := a.subProfileUrl // Disabled modes ignore the retained custom URL and never fall back to the request URL.
if profileURL == "" { profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
profileURL = fallbackProfileURL
} else {
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
}
data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext} data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
return renderedSubMetadata{ return renderedSubMetadata{
Title: renderSubPlaceholders(a.subTitle, data), Title: renderSubPlaceholders(a.subTitle, data),
+72 -11
View File
@@ -1,10 +1,15 @@
package sub package sub
import ( import (
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"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/web/service"
) )
func TestRenderSubPlaceholders(t *testing.T) { func TestRenderSubPlaceholders(t *testing.T) {
@@ -68,20 +73,75 @@ func TestRenderSubPlaceholders(t *testing.T) {
} }
} }
func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) { func TestMetadataForSubRequestOmitsUnconfiguredProfileURL(t *testing.T) {
a := &SUBController{ a := &SUBController{
subTitle: "isVPN", subTitle: "isVPN",
subSupportUrl: "https://support.example/", subSupportUrl: "https://support.example/",
} }
fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
metadata := a.metadataForSubRequest(func() *SubService { metadata := a.metadataForSubRequest(func() *SubService {
t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders") t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
return nil return nil
}, "sub-123", fallback) }, "sub-123", "https://sub.example/sub-123")
if metadata.ProfileURL != fallback { if metadata.ProfileURL != "" {
t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback) t.Fatalf("ProfileURL = %q, want no link when unconfigured", metadata.ProfileURL)
}
}
func TestSubscriptionProfileURLRequiresExplicitConfiguration(t *testing.T) {
gin.SetMode(gin.TestMode)
initSubDB(t)
seedInfoEndpointSub(t, "profile-sub", "profile@example.com")
for _, config := range []struct {
name, profileURL, want string
}{
{name: "empty"},
{name: "whitespace", profileURL: " "},
{name: "explicit", profileURL: "https://portal.example.com/account", want: "https://portal.example.com/account"},
{name: "template", profileURL: "https://portal.example.com/account?sub={{SUB_ID}}", want: "https://portal.example.com/account?sub=profile-sub"},
} {
t.Run(config.name, func(t *testing.T) {
for _, client := range []struct {
name, userAgent string
happAutoDetect bool
}{
{name: "Happ", userAgent: "Happ/3.22.0 (Android)", happAutoDetect: true},
{name: "Happ without auto-detection", userAgent: "Happ/3.22.0 (Android)"},
{name: "standard", userAgent: "v2rayNG/1.8.5", happAutoDetect: true},
} {
t.Run(client.name, func(t *testing.T) {
// All formats must honor the opt-in, independently of Happ customization.
router := gin.New()
NewSUBController(router.Group("/"),
WithSUBJsonEnabled(true), WithSUBClashEnabled(true),
WithSUBProfileURL(config.profileURL),
WithSUBProfileMode(service.SubProfileModeCustom),
WithSUBHappConfig(HappConfig{AutoDetect: client.happAutoDetect}),
)
for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Host = "sub.example.com"
req.Header.Set("User-Agent", client.userAgent)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Profile-Web-Page-Url"); got != config.want {
t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, config.want)
}
if config.want == "" {
if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
t.Fatal("unconfigured profile header must be omitted")
}
}
})
}
})
}
})
} }
} }
@@ -110,12 +170,13 @@ func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
} }
a := &SUBController{ a := &SUBController{
subTitle: "isVPN — {{EMAIL}}", subTitle: "isVPN — {{EMAIL}}",
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}", subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
subProfileUrl: "https://profile.example/account/{{ID}}", subProfileUrl: "https://profile.example/account/{{ID}}",
subAnnounce: "Subscription {{SUB_ID}}", subProfileMode: service.SubProfileModeCustom,
subAnnounce: "Subscription {{SUB_ID}}",
} }
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}") metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://sub.example/sub-123")
if metadata.Title != "isVPN — john doe@example.com" { if metadata.Title != "isVPN — john doe@example.com" {
t.Fatalf("Title = %q", metadata.Title) t.Fatalf("Title = %q", metadata.Title)
+97
View File
@@ -0,0 +1,97 @@
package sub
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestSubscriptionProfileModesFromSavedSettings(t *testing.T) {
oldFS, oldMode := distFS, gin.Mode()
oldWriter, oldErrorWriter := gin.DefaultWriter, gin.DefaultErrorWriter
SetDistFS(testDistFS)
t.Cleanup(func() {
SetDistFS(oldFS)
gin.SetMode(oldMode)
gin.DefaultWriter, gin.DefaultErrorWriter = oldWriter, oldErrorWriter
})
for _, config := range []struct {
name, mode, profileURL, want string
}{
{name: "new installation"},
{name: "legacy whitespace", profileURL: " "},
{name: "legacy custom", profileURL: "https://portal.example/account", want: "https://portal.example/account"},
{name: "none retains custom", mode: "none", profileURL: "https://portal.example/account"},
{name: "builtin", mode: "builtin", profileURL: "https://portal.example/account"},
{name: "custom", mode: "custom", profileURL: "https://portal.example/?sub={{SUB_ID}}", want: "https://portal.example/?sub=profile-sub"},
{name: "empty custom", mode: "custom"},
{name: "invalid mode", mode: "invalid", profileURL: "https://portal.example/account"},
} {
t.Run(config.name, func(t *testing.T) {
initSubDB(t)
seedInfoEndpointSub(t, "profile-sub", "profile@example.com")
settings := []model.Setting{
{Key: "subPath", Value: "/sub/"},
{Key: "subJsonPath", Value: "/json/"},
{Key: "subClashPath", Value: "/clash/"},
{Key: "subJsonEnable", Value: "true"},
{Key: "subClashEnable", Value: "true"},
{Key: "subProfileUrl", Value: config.profileURL},
}
if config.mode != "" {
settings = append(settings, model.Setting{Key: "subProfileMode", Value: config.mode})
}
for _, setting := range settings {
if err := database.GetDB().Where("key = ?", setting.Key).Delete(&model.Setting{}).Error; err != nil {
t.Fatal(err)
}
if err := database.GetDB().Create(&setting).Error; err != nil {
t.Fatal(err)
}
}
router, err := (&Server{}).initRouter()
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/sub/profile-sub", "/json/profile-sub", "/clash/profile-sub", "/json/profile-sub?view=raw", "/clash/profile-sub?view=raw", "/mihomo/profile-sub"} {
for _, userAgent := range []string{"Happ/3.22.0 (Android)", "v2rayNG/1.8.5"} {
t.Run(path+"/"+userAgent, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://sub.example.com:8443"+path, nil)
req.Header.Set("User-Agent", userAgent)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", resp.Code, resp.Body.String())
}
want := config.want
if config.mode == "builtin" {
want = "https://sub.example.com:8443" + req.URL.EscapedPath() + "?html=1"
}
if got := resp.Header().Get("Profile-Web-Page-Url"); got != want {
t.Fatalf("Profile-Web-Page-Url = %q, want %q", got, want)
}
if want == "" {
if _, present := resp.Header()["Profile-Web-Page-Url"]; present {
t.Fatal("disabled profile header must be absent")
}
}
if config.mode == "builtin" {
// The restored link must open the page, even when copied from a raw download.
page := httptest.NewRecorder()
router.ServeHTTP(page, httptest.NewRequest(http.MethodGet, want, nil))
if page.Code != http.StatusOK || !strings.Contains(page.Header().Get("Content-Type"), "text/html") {
t.Fatalf("builtin link did not serve HTML: status=%d, type=%q", page.Code, page.Header().Get("Content-Type"))
}
}
})
}
}
})
}
}
+5
View File
@@ -194,6 +194,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
if err != nil { if err != nil {
SubProfileUrl = "" SubProfileUrl = ""
} }
SubProfileMode, err := s.settingService.GetSubProfileMode()
if err != nil {
SubProfileMode = service.SubProfileModeNone
}
SubAnnounce, err := s.settingService.GetSubAnnounce() SubAnnounce, err := s.settingService.GetSubAnnounce()
if err != nil { if err != nil {
@@ -329,6 +333,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
WithSUBTitle(SubTitle), WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl), WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl), WithSUBProfileURL(SubProfileUrl),
WithSUBProfileMode(SubProfileMode),
WithSUBAnnounce(SubAnnounce), WithSUBAnnounce(SubAnnounce),
WithSUBEnableRouting(SubEnableRouting), WithSUBEnableRouting(SubEnableRouting),
WithSUBRoutingRules(SubRoutingRules), WithSUBRoutingRules(SubRoutingRules),
+16 -1
View File
@@ -48,6 +48,7 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
lines := splitLines(text) lines := splitLines(text)
var outbounds []Outbound var outbounds []Outbound
var identities []string var identities []string
seen := map[string]int{}
for _, ln := range lines { for _, ln := range lines {
ln = strings.TrimSpace(ln) ln = strings.TrimSpace(ln)
@@ -59,8 +60,14 @@ func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
// Ignore unparseable lines (comments, unsupported protocols, etc.) // Ignore unparseable lines (comments, unsupported protocols, etc.)
continue continue
} }
identity := res.Identity
// A repeated identity would share one stored tag, shifting both tags on every refresh.
if n := seen[res.Identity]; n > 0 {
identity = fmt.Sprintf("%s#%d", res.Identity, n)
}
seen[res.Identity]++
outbounds = append(outbounds, res.Outbound) outbounds = append(outbounds, res.Outbound)
identities = append(identities, res.Identity) identities = append(identities, identity)
} }
return outbounds, identities, nil return outbounds, identities, nil
} }
@@ -1047,10 +1054,18 @@ func firstParam(p url.Values, keys ...string) string {
return "" return ""
} }
// realityPerRequestParams are picked per request by subscription servers (3x-ui randomizes
// sid/sni, older releases spx too), so they must not split one server into new identities.
var realityPerRequestParams = map[string]bool{"sid": true, "sni": true, "spx": true}
func canonicalQuery(p url.Values) string { func canonicalQuery(p url.Values) string {
// Sort keys for stable identity // Sort keys for stable identity
reality := p.Get("security") == "reality"
keys := make([]string, 0, len(p)) keys := make([]string, 0, len(p))
for k := range p { for k := range p {
if reality && realityPerRequestParams[k] {
continue
}
keys = append(keys, k) keys = append(keys, k)
} }
// simple sort // simple sort
+11
View File
@@ -24,6 +24,17 @@ func TestParseVmessLink(t *testing.T) {
} }
} }
func TestLinkIdentityKeepsTLSServerName(t *testing.T) {
a, errA := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=a.example.com#node")
b, errB := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=b.example.com#node")
if errA != nil || errB != nil {
t.Fatalf("parse vless: %v, %v", errA, errB)
}
if a.Identity == b.Identity {
t.Fatalf("TLS links for different SNIs share identity %q", a.Identity)
}
}
func TestParseVlessLink(t *testing.T) { func TestParseVlessLink(t *testing.T) {
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1" link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
res, err := ParseLink(link) res, err := ParseLink(link)
+22
View File
@@ -0,0 +1,22 @@
package totp
import (
"time"
"github.com/xlzd/gotp"
)
// SkewWindows is how many 30s steps around now VerifyWithSkew accepts.
// Standard TOTP clock-drift tolerance, see MHSanaei/3x-ui#6535.
const SkewWindows = 1
// VerifyWithSkew accepts the code for the current step plus/minus SkewWindows.
func VerifyWithSkew(secret, code string, now time.Time) bool {
totp := gotp.NewDefaultTOTP(secret)
for i := -SkewWindows; i <= SkewWindows; i++ {
if totp.AtTime(now.Add(time.Duration(i*30)*time.Second)) == code {
return true
}
}
return false
}
+34
View File
@@ -0,0 +1,34 @@
package totp
import (
"testing"
"time"
"github.com/xlzd/gotp"
)
func TestVerifyWithSkew(t *testing.T) {
secret := "JBSWY3DPEHPK3PXP"
totp := gotp.NewDefaultTOTP(secret)
// Anchor mid-window so a step boundary can't fall between sampling and verify.
now := time.Unix((time.Now().Unix()/30)*30+15, 0).UTC()
if !VerifyWithSkew(secret, totp.AtTime(now), now) {
t.Fatal("current window code should verify")
}
if !VerifyWithSkew(secret, totp.AtTime(now.Add(-30*time.Second)), now) {
t.Fatal("previous window code should verify (clock skew)")
}
if !VerifyWithSkew(secret, totp.AtTime(now.Add(30*time.Second)), now) {
t.Fatal("next window code should verify (clock skew)")
}
if VerifyWithSkew(secret, totp.AtTime(now.Add(-60*time.Second)), now) {
t.Fatal("code two windows old should not verify")
}
if VerifyWithSkew(secret, totp.AtTime(now.Add(60*time.Second)), now) {
t.Fatal("code two windows ahead should not verify")
}
if VerifyWithSkew(secret, "000000", now) {
t.Fatal("wrong code should not verify")
}
}
+1
View File
@@ -94,6 +94,7 @@ type AllSetting struct {
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"` SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
SubTitle string `json:"subTitle" form:"subTitle"` SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileMode string `json:"subProfileMode" form:"subProfileMode"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
+15 -3
View File
@@ -612,7 +612,8 @@ func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPW
return actionable return actionable
} }
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections // disconnectClientTemporarily drops a client's credential for a moment, so new
// handshakes are refused; the fail2ban ban is what ends live traffic.
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
var xrayAPI xray.XrayAPI var xrayAPI xray.XrayAPI
apiPort := j.resolveXrayAPIPort() apiPort := j.resolveXrayAPIPort()
@@ -626,11 +627,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
// Find the client config // Find the client config
var clientConfig map[string]any var clientConfig map[string]any
var reverseClient bool
for _, client := range clients { for _, client := range clients {
if client.Email == clientEmail { if client.Email == clientEmail {
// Convert client to map for API // Convert client to map for API
clientBytes, _ := json.Marshal(client) clientBytes, _ := json.Marshal(client)
_ = json.Unmarshal(clientBytes, &clientConfig) _ = json.Unmarshal(clientBytes, &clientConfig)
reverseClient = client.Reverse != nil
break break
} }
} }
@@ -650,6 +653,13 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
return return
} }
// RemoveUser drops a reverse client's outbound handler and the re-add below
// cannot restore it, so its tunnel would stay down until Xray restarts.
if reverseClient {
logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail)
return
}
// For Shadowsocks, ensure the required "cipher" field is present by // For Shadowsocks, ensure the required "cipher" field is present by
// reading it from the inbound settings (e.g., settings["method"]). // reading it from the inbound settings (e.g., settings["method"]).
if string(inbound.Protocol) == "shadowsocks" { if string(inbound.Protocol) == "shadowsocks" {
@@ -663,14 +673,16 @@ func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, c
} }
} }
// Remove user to disconnect all connections // The core's RemoveUser clears its validator: a session already up keeps
// running, except a reverse vless client, which is skipped above.
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
if err != nil { if err != nil {
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err) logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
return return
} }
// Wait a moment for disconnection to take effect // Nothing is pending here: AlterInbound applies the removal inline, so this
// only widens the window in which new handshakes fail.
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
// Re-add user to allow new connections // Re-add user to allow new connections
@@ -0,0 +1,147 @@
package job
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// ldapHungNodeInbound seeds an online node whose client writes hang until the gate
// opens, plus one inbound on it holding the given enabled clients.
func ldapHungNodeInbound(t *testing.T, emails []string) (*resetGate, *model.Inbound) {
t.Helper()
initLdapJobDB(t)
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(nil) })
gate := &resetGate{release: make(chan struct{})}
const tag = "ldap-node-in"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "inbounds/list") {
_, _ = w.Write([]byte(`{"success":true,"obj":[{"id":1,"tag":"` + tag + `"}]}`))
return
}
if r.Method == http.MethodPost {
gate.entered.Add(1)
select {
case <-r.Context().Done():
case <-gate.release:
}
}
_, _ = w.Write([]byte(`{"success":true}`))
}))
t.Cleanup(srv.Close)
t.Cleanup(gate.open)
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
portNum, _ := strconv.Atoi(port)
db := database.GetDB()
node := &model.Node{
Name: "ldap-node", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
Enable: true, Status: "online", AllowPrivateAddress: true, TlsVerifyMode: "verify",
}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
clients := make([]model.Client, 0, len(emails))
for i, email := range emails {
clients = append(clients, model.Client{Email: email, ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), Enable: true})
}
settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
ib := &model.Inbound{
UserId: 1, Enable: true, Port: 47200, Protocol: model.VLESS, NodeID: &node.Id,
Tag: tag, Settings: string(settings), StreamSettings: `{"network":"tcp"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
for _, c := range clients {
rec := model.ClientRecord{Email: c.Email, UUID: c.ID, Enable: true}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("link client: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}).Error; err != nil {
t.Fatalf("create client traffic: %v", err)
}
}
return gate, ib
}
func inboundClientEnables(t *testing.T, inboundID int) map[string]bool {
t.Helper()
var ib model.Inbound
if err := database.GetDB().First(&ib, inboundID).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
t.Fatalf("parse settings: %v", err)
}
out := make(map[string]bool, len(settings.Clients))
for _, c := range settings.Clients {
out[c.Email] = c.Enable
}
return out
}
// Each LDAP user was disabled on its own, and every per-user push held the inbound
// lock for the push timeout, so users sharing a hung node inbound queued behind it.
func TestLdapBatchSetEnableDoesNotQueueUsersOnHungNode(t *testing.T) {
emails := []string{"u1@ldap", "u2@ldap", "u3@ldap", "u4@ldap", "u5@ldap"}
gate, ib := ldapHungNodeInbound(t, emails)
done := make(chan struct{})
go func() {
defer close(done)
NewLdapSyncJob().batchSetEnable(emails, false)
}()
t.Cleanup(func() { gate.open(); <-done })
select {
case <-done:
case <-time.After(9 * time.Second):
t.Fatalf("disabling %d LDAP users on one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
}
for email, enabled := range inboundClientEnables(t, ib.Id) {
if enabled {
t.Errorf("client %s still enabled after the LDAP disable", email)
}
}
}
// Clients missing from LDAP were detached one at a time, each waiting out the push
// timeout on a hung node inbound, so a directory cleanup could run for hours.
func TestLdapDeleteDoesNotQueueClientsOnHungNode(t *testing.T) {
emails := []string{"gone1@ldap", "gone2@ldap", "gone3@ldap", "gone4@ldap", "gone5@ldap"}
gate, ib := ldapHungNodeInbound(t, emails)
done := make(chan struct{})
go func() {
defer close(done)
NewLdapSyncJob().deleteClientsNotInLDAP(ib.Tag, map[string]struct{}{})
}()
t.Cleanup(func() { gate.open(); <-done })
select {
case <-done:
case <-time.After(9 * time.Second):
t.Fatalf("detaching %d clients from one hung node inbound took over 9s (%d pushes started)", len(emails), gate.entered.Load())
}
if left := inboundClientEnables(t, ib.Id); len(left) != 0 {
t.Errorf("clients still on the inbound after the LDAP cleanup: %v", left)
}
}
+40 -51
View File
@@ -131,8 +131,7 @@ func (j *LdapSyncJob) Run() {
} }
clientsToCreate := []model.Client{} clientsToCreate := []model.Client{}
clientsToEnable := map[string][]string{} // tag -> []email var clientsToEnable, clientsToDisable []string
clientsToDisable := map[string][]string{} // tag -> []email
for email, allowed := range flags { for email, allowed := range flags {
existing := allClients[email] existing := allClients[email]
@@ -142,24 +141,21 @@ func (j *LdapSyncJob) Run() {
} }
continue continue
} }
for _, tag := range resolvedTags { if len(resolvedTags) == 0 {
if allowed && !existing.Enable { continue
clientsToEnable[tag] = append(clientsToEnable[tag], email) }
} else if !allowed && existing.Enable { if allowed && !existing.Enable {
clientsToDisable[tag] = append(clientsToDisable[tag], email) clientsToEnable = append(clientsToEnable, email)
} } else if !allowed && existing.Enable {
clientsToDisable = append(clientsToDisable, email)
} }
} }
j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags) j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
// --- Execute enable/disable batch --- // --- Execute enable/disable batch ---
for tag, emails := range clientsToEnable { j.batchSetEnable(clientsToEnable, true)
j.batchSetEnable(inboundMap[tag], emails, true) j.batchSetEnable(clientsToDisable, false)
}
for tag, emails := range clientsToDisable {
j.batchSetEnable(inboundMap[tag], emails, false)
}
// --- Auto delete clients not in LDAP --- // --- Auto delete clients not in LDAP ---
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete) autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
@@ -257,34 +253,28 @@ func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int,
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList) logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
} }
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) { // batchSetEnable takes the bulk path: per-user calls held each inbound's lock through
// its node push, so users sharing a hung node inbound queued one push timeout apiece.
func (j *LdapSyncJob) batchSetEnable(emails []string, enable bool) {
if len(emails) == 0 { if len(emails) == 0 {
return return
} }
restartNeeded := false result, needRestart, err := j.clientService.BulkSetEnable(&j.inboundService, emails, enable)
changed := 0 if err != nil {
for _, email := range emails { logger.Warningf("Batch set enable=%v failed: %v", enable, err)
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
if err != nil {
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
continue
}
if ok {
changed++
}
if needRestart {
restartNeeded = true
}
} }
if changed > 0 { for _, skipped := range result.Skipped {
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag) logger.Warningf("Batch set enable failed for %s: %s", skipped.Email, skipped.Reason)
} }
if restartNeeded { if result.Changed > 0 {
logger.Infof("Batch set enable=%v for %d clients", enable, result.Changed)
}
if needRestart {
j.xrayService.SetToNeedRestart() j.xrayService.SetToNeedRestart()
} }
} }
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart // deleteClientsNotInLDAP detaches clients not in LDAP, one bulk detach per inbound
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) { func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
inbounds, err := j.inboundService.GetAllInbounds() inbounds, err := j.inboundService.GetAllInbounds()
if err != nil { if err != nil {
@@ -292,7 +282,6 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
return return
} }
batchSize := 50 // clients in 1 batch
restartNeeded := false restartNeeded := false
for _, ib := range inbounds { for _, ib := range inbounds {
@@ -317,23 +306,23 @@ func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[s
continue continue
} }
for i := 0; i < len(toDelete); i += batchSize { emails := make([]string, len(toDelete))
end := min(i+batchSize, len(toDelete)) for i, c := range toDelete {
batch := toDelete[i:end] emails[i] = c.Email
}
for _, c := range batch { result, nr, err := j.clientService.BulkDetach(&j.inboundService, emails, []int{ib.Id})
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email) if err != nil {
if err != nil { logger.Warningf("Failed to delete clients from inbound id=%d(tag=%s): %v", ib.Id, ib.Tag, err)
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v", continue
c.Email, ib.Id, ib.Tag, err) }
continue for _, msg := range result.Errors {
} logger.Warningf("Failed to delete client from inbound id=%d(tag=%s): %s", ib.Id, ib.Tag, msg)
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", }
c.Email, ib.Id, ib.Tag) for _, email := range result.Detached {
if nr { logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", email, ib.Id, ib.Tag)
restartNeeded = true }
} if nr {
} restartNeeded = true
} }
} }
@@ -8,6 +8,39 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/logger"
) )
// A reverse client has to be left alone: the cycle drops its reverse outbound
// handler and re-adds an account without a reverse, which no retry restores.
func TestDisconnectClientTemporarilySkipsReverseClient(t *testing.T) {
setupIntegrationDB(t)
const email = "rev-limit-probe"
inbound := &model.Inbound{
Id: 1,
Protocol: model.VLESS,
Tag: "rev-limit-probe-tag",
Settings: `{"clients":[]}`,
}
clients := []model.Client{{Email: email, ID: "11111111-1111-1111-1111-111111111111", Reverse: &model.ClientReverse{Tag: "rev-out"}}}
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
var skipped, attempted bool
for _, line := range logger.GetLogs(500, "warning") {
if strings.Contains(line, "Not disconnecting "+email) {
skipped = true
}
if strings.Contains(line, "Failed to remove user "+email) {
attempted = true
}
}
if attempted {
t.Fatal("a reverse client must not be removed and re-added")
}
if !skipped {
t.Fatal("the skip must be reported, not silent")
}
}
// The protocol gate must let hysteria through: XrayAPI supports it, and the // The protocol gate must let hysteria through: XrayAPI supports it, and the
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up. // skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) { func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
@@ -0,0 +1,98 @@
package job
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func transitiveGuids(t *testing.T) []string {
t.Helper()
tree, err := (&service.NodeService{}).GetNodeTree()
if err != nil {
t.Fatalf("GetNodeTree: %v", err)
}
var out []string
for _, n := range tree {
if n.Transitive {
out = append(out, n.Guid)
}
}
return out
}
// The heartbeat skips a disabled node and never sees a deleted one, so the
// sub-nodes it had learned from them stayed on the Nodes page for good.
func TestHeartbeatDropsSubNodesOfNodesItNoLongerProbes(t *testing.T) {
cases := []struct {
name string
retire func(t *testing.T, nodeID int)
}{
{"disabled", func(t *testing.T, nodeID int) {
if err := (&service.NodeService{}).SetEnable(nodeID, false); err != nil {
t.Fatalf("SetEnable: %v", err)
}
}},
{"deleted", func(t *testing.T, nodeID int) {
if err := (&service.NodeService{}).Delete(nodeID); err != nil {
t.Fatalf("Delete: %v", err)
}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
t.Cleanup(func() { runtime.SetManager(nil) })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "server/status"):
_, _ = w.Write([]byte(`{"success":true,"obj":{"panelGuid":"direct-guid","xray":{"state":"running"}}}`))
case strings.HasSuffix(r.URL.Path, "server/descendants"):
_, _ = w.Write([]byte(`{"success":true,"obj":[{"guid":"sub-guid","parentGuid":"direct-guid","name":"sub","status":"online"}]}`))
default:
_, _ = w.Write([]byte(`{"success":true}`))
}
}))
t.Cleanup(srv.Close)
host, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
portNum, _ := strconv.Atoi(port)
node := &model.Node{
Name: "direct", Scheme: "http", Address: host, Port: portNum, BasePath: "/", ApiToken: "tok",
Enable: true, Status: "unknown", AllowPrivateAddress: true, TlsVerifyMode: "verify",
}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
hb := NewNodeHeartbeatJob()
hb.Run()
if got := transitiveGuids(t); len(got) != 1 || got[0] != "sub-guid" {
t.Fatalf("sub-nodes after first heartbeat = %v, want [sub-guid]", got)
}
tc.retire(t, node.Id)
hb.Run()
if got := transitiveGuids(t); len(got) != 0 {
t.Fatalf("sub-nodes after the node was %s = %v, want none", tc.name, got)
}
})
}
}

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