Commit Graph

568 Commits

Author SHA1 Message Date
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 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 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
Sanaei 837addf66e v3.8.0 2026-09-14 19:11:03 +02:00
BlindMaster24 efcf152950 fix(outbound): read the probe testability gate's ids like the core (#6527)
A direct, DNS, loopback or blackhole outbound is not a proxy, so the probe
must reject it instead of measuring the panel host's own reachability. The
gate compared the protocol id exactly while the core lowercases it in
LoadWithID before resolving the handler, so "Freedom" and "DNS" were not
recognised: the HTTP probe ran through the direct outbound and returned
Success=true with a full egress block, and the row's Test button stayed
enabled because isUntestable compared exactly as well. The operator reads the
panel host's own country and delay as a working tunnel.

The batch gate now folds the id once before its switch, and isUntestable goes
through the shared isOutboundProtocol helper.
2026-09-14 19:53:05 +03:00
BlindMaster24 f69d1e869d fix(outbound): read the probe protocol id and transport name like the core (#6526)
* fix(outbound): read the probe protocol id and transport name like the core

The probe lane gate and the endpoint extractor behind it compared both
strings exactly, so a template the core is running was probed as something
else. With mode=tcp an outbound spelled "WireGuard" stayed in the dial-only
TCP lane, where extractOutboundEndpoints matched no case and the caller got
"No testable endpoint" for an outbound that is passing traffic.

The core lowercases a protocol id (infra/conf/loader.go) and a transport
name (TransportProtocol.Build) before it resolves either, and resolves both
"kcp" and "mkcp" to mKCP, so both readers now normalise the same way.

The panel no longer reaches the lane gate itself — the browser now sends
http for these outbounds — but the endpoint documents "tcp" for fast
dial-only probes with UDP-transport outbounds still probed over HTTP, and
that promise has to hold for direct API callers too.

* fix(outbound): read the batch probe protocol id like the core

Review of #6526 found that folding "WireGuard"/"AmneziaWG" into the UDP lane
newly routed those spellings onto two readers in buildBatchTestConfig that
still compared the id exactly. A case-variant WireGuard outbound therefore
reached the temp probe instance without noKernelTun -- which on Linux creates
a kernel TUN device alongside the live panel's own -- and a case-variant
AmneziaWG entry was appended raw, rejecting the whole temp config and
degrading the batch to serial per-item retries.

Both readers now fold the id the way the core does (infra/conf/loader.go
lowercases it before the protocol is resolved).
2026-09-14 19:52:31 +03:00
BlindMaster24 4d6db1c961 fix(xray): read an outbound protocol id the way the core does (#6521)
* fix(xray): read an outbound protocol id the way the core does

xray-core lowercases a protocol id before it looks up the handler
(infra/conf/loader.go: `id = strings.ToLower(id)`), so a template that
spells the direct outbound "Freedom" runs as freedom. The three config
rewriters compared the id case-sensitively, so such an outbound was
skipped while the seed was recorded as applied: the refused
sockopt.addressPortStrategy stayed and xray-core refused to start.

* fix(xray): match an outbound protocol id case-insensitively

xray-core lowercases a protocol id before looking up its handler, so a
template that spells the direct outbound "Freedom" runs as freedom while
this rewriter skipped it and left the deprecated placement in place.

* fix(xray): re-run the freedom finalRules rewrite where its seeder was gated

Both finalRules seeders recorded their rows before the predicate could see
an outbound spelled "Freedom", and a recorded row is never re-run, so the
corrected predicates alone left the #6037 private-egress hardening
unapplied on every panel that had already run them. The new one-shot
seeder replays both rewrites, and only when the config actually carries a
differently spelled freedom outbound, so stock lowercase configs stay
byte-identical.
2026-09-14 19:52:27 +03:00
BlindMaster24 39ce7cbc22 fix(xray): migrate the dns outbound off its legacy nonIPQuery and blockTypes (#6519)
* fix(xray): migrate the dns outbound off its legacy nonIPQuery and blockTypes

xray-core logs both keys as deprecated on every config load, and refuses them
outright next to rules. The panel's own dns outbound card wrote them with
defaults until it switched that card to rules, so a panel that ever had one
keeps warning at every start, and the card no longer reads them back — saving
that outbound from the current UI silently dropped the policy.

The seeder converts them into the three rules the core's legacy builder
produced, in its order, then drops the keys.

* fix(xray): read a dns outbound's null keys and protocol id like the core

Two details the seeder got wrong, both found reviewing the diff against the
pinned loader. A JSON null is a present key with a nil value here but a nil
pointer there, so `nonIPQuery: null` was rewritten into a reject policy the
core never built, and `rules: null` hid the legacy pair that the core does
still read — dropping the operator's policy on upgrade. And the core
lowercases the protocol id before dispatching, so `"protocol": "DNS"` was
never migrated and kept warning.
2026-09-14 12:30:23 +02:00
Jack c90996eda3 feat(sub): add opt-in month-end expiry presentation (#6517)
Offer monthly calendar subscriptions an explicit last-valid-second display
without moving their real billing boundary or spending renewal allowances.

Keep the option off by default and limit conversion to a shared fixed
day-1 midnight cutoff at an actual month transition in the panel timezone.
Use the authoritative client calendar mode when aggregating node traffic,
and share the header formatter across raw, JSON, and Clash exports.

Expose the setting in the existing settings API/UI, regenerate its schemas,
and document that clients may report expiry one second early or format the
date differently in another timezone. Add HTTP, settings, and DST coverage.
Stored deadlines, access enforcement, info/remark expiry values, and renewal
accounting remain unchanged.

Refs: #6516

Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com>
2026-09-14 12:10:25 +02:00
BlindMaster24 826e29e2de fix(xray): place the freedom domain strategy where the core reads it (#6515)
* fix(xray): place the freedom domain strategy where the core reads it

freedom resolves through the socket layer, so xray-core reads
sockopt.domainStrategy and treats both other placements as legacy: it warns on
every config load for the outbound-root targetStrategy it migrates itself, and
again for the settings-level domainStrategy it deprecates. The panel wrote
exactly those two keys from its Freedom Protocol Strategy select, the outbound
form card, and the IPv4 routing helper, so any install that had configured a
strategy logged a deprecation warning on every start.

The strategy now travels in streamSettings.sockopt everywhere the panel emits
it: the Basics select, the outbound form (including the JSON tab, which shares
the same adapter), the shipped default template, and the IPv4 outbound the
routing helper injects. Reading mirrors the loader's own order — root
targetStrategy, then the settings keys, then sockopt — so the card keeps showing
the value the core would actually run with, and saving drops the legacy keys
instead of leaving them behind.

A seeder moves the keys for configs already stored in the database, following
OutboundRemovedKeysFix. The shared outbound-root Target Strategy field is hidden
for freedom, since the core migrates that key into the very sockopt value the
card writes and two knobs for one value would race.

Tests: placement round-trips and the migration table run through the real
vendored core (a captured log handler proves the warning is gone after the
rewrite and present before it), and the modal asserts freedom offers a single
strategy field.

* test(database): seed the template row the seeder test needs

A fresh InitDB creates no xrayTemplateConfig row — the panel's setting defaults
live in the service layer — so the test has to insert the legacy template itself
and then assert the seeder's history gate stops a second pass from rewriting it.

* fix(xray): keep one strategy control per outbound, seed the row in tests

Review findings: the Transport tab's Sockopts block renders for freedom too, so
its Domain Strategy select and the freedom card wrote one sockopt value between
them and the card won on save — the field is hidden for freedom now, leaving the
card as the single control. The seeder is also pre-marked on a fresh install so
it does not run on the second start, and the seeder test seeds the template row
itself (a fresh InitDB has none) and asserts the rewrite structurally instead of
grepping for a key name that sockopt also uses.
2026-09-14 12:08:38 +02:00
BlindMaster24 032ddcb29f fix(nodetoken): make the corrupt-ciphertext test corrupt deterministically (#6520)
* fix(nodetoken): make the corrupt-ciphertext test corrupt deterministically

The test replaced the last two characters of the base64 body with "AA", which
can decode to the very same bytes: the body is RawURLEncoding of a 31-byte
blob, so the final character carries only 2 significant bits and the decoded
value is unchanged whenever the tag's last byte is 0x00. Measured over 50000
encryptions, 170 of those edits corrupted nothing — about one run in three
hundred fails for a reason that has nothing to do with the codec.

Flipping a bit of the decoded blob always changes the ciphertext, so the test
now pins the fallback behavior instead of the encoder's tail padding.

* style(nodetoken): trim the helper comment to the two-line limit

CLAUDE.md caps a committed Go comment block at two lines; the why fits.
2026-09-14 12:08:11 +02:00
Sanaei cfd4f64a79 fix(amneziawg): bound the SOCKS5 UDP associate exchange
newSocks5UDPSession only bounded the dial. The greeting, auth and UDP
ASSOCIATE reads on the control connection had no deadline, and they run
inline in UDPRelay.Handle -- on the peer's receive goroutine that delivers
decrypted packets into gVisor. A SOCKS5 server the kernel accepts for but
that never answers (a wedged Xray: its listen backlog still completes TCP
handshakes) therefore parked that goroutine, and every later packet from
the peer behind it, TCP included, for as long as Xray stayed wedged.

One deadline now covers the dial plus the whole exchange and is cleared
once the association is up, since after that the control connection is
only held open. The test drives the exchange against a listener that is
never accepted, which is exactly the hung-server shape.

This was the last of the three defects confirmed on the issue: the header
protection key that could not be cleared went with cfd596a4, the missing
PersistentKeepalive with 8f162994, and the manager lock inversion the same
thread flagged with e95fe80f. The session death the issue was opened for
is not a panel defect. The reporter's own capture on the host NIC shows
the client's packets stop reaching the VPS after the first burst, the
server never sees a second handshake initiation from it, and nothing the
panel sends is outside what a stock amneziawg-go 3.1 server sends (the
Apple client embeds the same library build). That is a client- or
path-side stop, which no server-side change can address.

Closes #6323
2026-09-13 23:29:40 +02:00
Sanaei 22346eef78 fix(node): import a newly selected node inbound instead of sweeping it
Saving the node form writes the grown selection and marks the node dirty
in one transaction. On the next tick ReconcileNode runs before the
snapshot merge, and its delete sweep treats a selected tag with no
central row as "deleted on the master" — so an inbound the operator just
ticked in the picker (or every unselected one, when switching the node
to "all") is deleted from the node before the import that would have
created its row ever runs.

Nothing on disk separates "pending import" from "deleted while the node
was unreachable", but the pre-adoption guard already expresses the
former: while inbounds_adopted_at is zero the sweep waits for a clean
sync to adopt. A save that grows the managed set now zeroes it again,
and the same clean sync re-stamps it, so the offline-delete sweep is
only deferred by one successful sync, not disabled.

The trade: an inbound deleted on the master while the node was
unreachable is re-imported instead of swept if the operator grows the
node's selection during that same outage. That is visible and
recoverable, where the previous behaviour destroyed a live inbound.

Closes #6329
2026-09-13 22:44:50 +02:00
mrchatam 5ad9df69b9 fix(link): restore mKCP seed and headerType on share-link import (#6480)
* fix(link): restore mKCP seed and headerType on share-link import

applyTransport / applyTransportParams ignored kcp query params that
applyKcpShareParams emits, so re-imported outbounds lost seed and
header and could not talk to the inbound. Mirror those fields (plus
mtu/tti) into kcpSettings in both Go and TS importers.

Fixes #6476

* fix(link): restore mKCP header/seed via finalmask mkcp-legacy

* fix(link): split mKCP header and seed into separate masks on import

Both importers folded a share link's headerType and seed into one
mkcp-legacy mask {header, value}. xray-core's MkcpLegacy.Build ignores
value once header is set (and reads it as the fake DNS domain for
header=dns), so an imported outbound carried the header mask but no
AES-128-GCM seed while the emitting inbound has both, and could not
connect — the failure #6476 reports, now for every link carrying both
params. Emit one mask per field, seed first: the finalmask array's
first item is the innermost layer, which puts the header around the
cipher as legacy mKCP did.

Also bound mtu/tti to KCPConfig.Build's accepted ranges (mtu >= 21,
tti 10..1000, decimal digits only on both importers) so a pasted link
cannot fail the whole Xray config load, and look header types up as
own properties so a prototype key such as "constructor" is not mapped.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 22:28:13 +02:00
Mapioe 4760ccaba0 fix(logs): standardize logs (#6484)
* fix(logs): standardize login and logout logs

* fix(logs): log the real username on login lines

The four login log lines logged safeUser, the HTML-escaped copy kept for
the Telegram and email notifiers, so an account named o"reilly<1> showed
up as o&#34;reilly&lt;1&gt; on login but o\"reilly<1> on logout. %q
already neutralises control characters, so the log now carries
form.Username and safeUser feeds only the notifiers.

Resolves the pre-existing LOW left on PR #6484. TestLoginLogsRealUsername
drives the success, plain-failure, blocking and refused paths over HTTP
and fails on the escaped value.

Refs #6483

---------

Co-authored-by: Mapioe <Mapioe@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 21:52:18 +02:00
mrchatam 435ed976c0 fix(web): restart panel after ImportDB so subPath routes match (#6446) (#6456)
* fix(web): restart panel after ImportDB so subPath routes match (#6446)

ImportDB only restarted Xray, leaving the subscription HTTP server on
startup-registered paths. Schedule the same in-process restart hook used
by restartPanel so restored subPath (and related) routes take effect
without relying on a browser follow-up that can fail after session invalidation.

* fix(web): schedule the post-import panel restart once, via PanelService

ImportDB grew a private copy of PanelService.RestartPanel (same hook check,
same Windows bail-out, same SIGHUP fallback, already diverging in log
severity) while BackupModal kept POSTing restartPanel after a successful
import, so one restore bounced the panel and the public sub server twice
back to back. The service package cannot reuse PanelService (panel imports
service), so the importDB controller now calls the existing
RestartPanel(3s) after ImportDB succeeds, the duplicated helper is dropped,
and the browser follow-up is removed; it waits out the restart and reloads.

Test drives the importDB handler against a stub xray binary and fails when
no restart is scheduled through the global restart hook.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 21:39:15 +02:00
ilyusha d600de2c2e feat(geodata): add standard source presets (#6504)
* feat(geodata): add standard source presets

Expose the existing geofile allowlist to the Geodata editor so administrators can configure the supported scheduled downloads without copying URLs manually

Tested with npm run test, npm run lint, npm run typecheck, npm run format:check, and go test ./internal/web/service ./internal/web/controller -run '^(TestStandardGeodataSources|TestGeodata)' -count=1

Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly)

* fix(geodata): preserve custom source entries

Add missing standard sources instead of replacing existing custom entries.

Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly)
2026-09-13 20:02:43 +02:00
BlindMaster24 ff1a6c3caf fix(sub): drop external Clash shadowsocks nodes the panel cannot express (#6508)
* fix(sub): gate external Clash shadowsocks links like the inbound path

clashProxyFromExternal returned as soon as it had built the ss proxy, so an
ss:// link skipped applyTransport/applySecurity: a node whose tcp/http
obfuscation Clash cannot express was emitted anyway (mihomo then opens a plain
shadowsocks stream at a server that requires the header, and the node silently
never connects), and security=tls was silently stripped. The inbound path runs
both helpers for every protocol, so the two Clash importers disagreed about the
same node.

* fix(sub): count a dropped external link in the quota header

The client email that feeds AggregateTrafficByEmails was recorded only when a
proxy came out of the link, so a node Clash cannot represent also vanished from
the Subscription-Userinfo header of every other node in the same subscription —
the header reported another client's numbers as the whole subscription's. The
inactive-link branch already counted an email without a proxy; make that
unconditional so the header describes the subscribers, not the representable
subset of their nodes.

* docs(sub): describe clashProxyFromExternal by what it does, not by protocol

The protocol list in the doc comment went stale the moment the shadowsocks
branch stopped returning early, and it restated what the switch already says.
2026-09-13 20:01:58 +02:00
BlindMaster24 8fc4fc0bf8 fix(link): rebuild shadowsocks tcp/http obfuscation on import (#6505)
* fix(link): rebuild shadowsocks tcp/http obfuscation on import

genShadowsocksLink encodes tcp/http obfuscation only as the SIP002
plugin=obfs-local;obfs=http;obfs-host=... parameter, deleting type, headerType,
path and host in the process, because SIP002 clients ignore those and read
`plugin` alone. ParseLink read none of them, so importing a link the panel had
just exported produced a plain tcp outbound with header.type none: the
obfuscation the inbound requires was gone, and the client could not connect to
the very inbound the link came from.

The plugin is now mapped back onto the header it stands for. Credentials and
every other parameter are untouched, and other plugin values are left as they
were because Xray has no equivalent for them.

* fix(link): map the SIP002 plugin in both importers

The panel parses share links twice: link.ParseLink in Go, which the external
subscriptions use, and parseShadowsocksLink in outbound-link-parser.ts, which
the Add Outbound button calls. Mapping the plugin in Go alone left the UI path
still saving header.type none for a link the panel had exported itself, so one
panel answered the same link with two different outbounds.

The unencoded plugin=obfs-local;obfs=http;... form maps as well now: stdlib
drops any query pair whose value holds a literal semicolon, and that is the
shape clients which skip percent-encoding emit, so the raw query is read as a
fallback when the parsed parameter is missing.
2026-09-13 19:58:27 +02:00
BlindMaster24 f3dba07e13 fix(link): read the vmess certificate checks on import (#6507)
* fix(link): read the vmess certificate checks on import

applyVmessTLSParams writes ech, vcn and pcs into the vmess share object, but
parseVmess only read sni, fp and alpn back. Importing a link the panel had just
exported therefore dropped all three: no pinned certificate, no verify-by-name,
no ECH. On a server whose certificate is only trusted through a pin, the
imported outbound falls back to public-CA verification against the system roots
and cannot connect to the inbound the link came from.

The url-param protocols already read the same three in applySecurity, and the
core takes pinnedPeerCertSha256 as one joined string there, so the vmess path
now fills them the same way.

* fix(frontend): read the vmess certificate checks on import

The panel parses share links twice: link.ParseLink in Go and
parseVmessLink in outbound-link-parser.ts, which is what the Add Outbound
button calls. Reading ech, vcn and pcs in Go alone would have made the two
sides disagree on one link, leaving the UI path — the one an operator uses by
hand — still dropping the pin the panel had just exported.
2026-09-13 19:58:10 +02:00
BlindMaster24 2fcd28c1bc refactor(tgbot): make the add-client expiry presets say what they do (#6503)
* refactor(tgbot): make the add-client expiry presets say what they do

The wizard's "Add N days" buttons were a copy of the renewal handler, whose
accumulate branch they cleared two lines later: the branch tested a value the
line above had just set to zero, so it was dead and only the "set N days from
first use" path was reachable. That reads as an accident, and the automated
review of #6499 flagged it twice.

The wizard keeps the term it sets, which is now the code: a create flow has no
expiry to add to, the custom keypad lands in this same case, and a corrected
number has to replace the one it follows. 0 stays the Unlimited button. The
renewal handler (reset_exp_c) genuinely adds to the client's remaining time and
is unchanged.

* refactor(tgbot): fold in the review of the expiry-preset change

The test now starts every row from a term a preset could have left, so each row
fails on its own under the accumulate semantics rather than depending on the row
before it, and it reuses the package's draft helpers instead of a second copy.
The wizard presets drop the "Add" verb they never honoured; the renewal
keyboard keeps it, where reset_exp_c really does add to the remaining time.
2026-09-13 19:57:46 +02:00
BlindMaster24 1691c9ca2a fix(tgbot): keep the add-client draft with the chat that owns it (#6499)
* fix(tgbot): keep the add-client draft with the chat that owns it

The wizard held one package-level draft for the whole bot. Its steps run on
the ten-goroutine worker pool, so two admins adding a client at the same time
wrote into the same form: whichever step ran last decided the email, the
limits and the attached inbounds of a client the other chat went on to
create, and the attach picker mutated one shared slice from several
goroutines at once as well.

Each chat now gets its own draft, reached only through the chat that owns it
and held for the duration of a step, so a client is created from the values
its own chat collected.

* fix(tgbot): take the wizard's draft lock only for the wizard

A queued report tap held one of the ten worker slots while it waited on the
chat's draft, and every chat that reached answerCallback grew the draft map
even when the admin gate rejected it. Both follow from acquiring the draft
before the gate; the wizard's own steps are the only callers that read it.

The draft is now looked up under the same admin-and-wizard check, addClient
takes the draft its caller locked instead of looking it up again, a submit
drops the entry, and StopBot clears the map with the conversation states.
2026-09-13 19:48:54 +02:00
BlindMaster24 e98be4f72a fix(tgbot): render a disabled start-after-first-use client as days (#6500)
A delayed-start expiry is stored as a negative duration, but the card checked
the disabled-client branch before the sign of that duration, so it printed the
epoch position (-2592000000 ms -> 1969-12-02) and labelled it an expire date.
The sign decides first now, which is how BuildClientDraftMessage in this file,
subscriptionExpiryFromClient and adjustTraffics already read the same value; the
Discord card is the one surface still reading it as unlimited, fixed in #6498.
2026-09-13 19:48:14 +02:00
BlindMaster24 d45a09d634 fix(discord): page the inbounds reply within Discord's embed caps (#6496)
`!inbounds` built a single embed with one field per inbound and sent it as
it was. Discord rejects the whole message past 25 fields, ten embeds or 6000
counted characters, so an operator holding more than 25 inbounds got no
answer at all, and a remark longer than ~252 runes broke the command on its
own — the `📍 ` prefix spends four units of the same 256-unit field name cap.

The failure left no trace either: the send error was discarded, so the
channel stayed empty and the log stayed quiet.

Fields are now capped by the same helper every other reply in the package
uses for its name and value limits, and packed into messages that fit those
caps, with the header leading only the first embed of each message. The caps
are counted the way Discord counts them, in UTF-16 units, and a page it
answers with a 429 is waited out once rather than dropping the pages behind
it.
2026-09-13 19:47:49 +02:00
BlindMaster24 5c34baa8df fix(discord): drop the gateway connection when heartbeats go unanswered (#6497)
The heartbeat goroutine wrote op 1 on its interval and ignored op 11, so a
connection that stopped being answered was never noticed. A half-open socket
is the case that matters: the kernel accepts the writes and the read loop
stays blocked, so the bot serves nothing for as long as the panel runs, and
nothing in the log says so. Discord asks clients to close and reconnect when
a heartbeat goes unacknowledged, which is what the ticker now does, letting
the existing reconnect loop take over.

The writeMu regression test's fake gateway answered no heartbeat at all,
which the new check reads as a dead socket; it now acknowledges them the way
Discord does and paces its op 1 flood, keeping its one-second window of
concurrent writes intact.
2026-09-13 19:47:30 +02:00
BlindMaster24 cc60cefe02 fix(discord): report a start-after-first-use client as days, not unlimited (#6498)
!usage printed Unlimited for any client whose expiry was not a positive
timestamp, but the panel stores "Start After First Use" as the duration
negated and converts it on the first traffic tick. Such a client does expire,
so the operator reading that embed was told the opposite of what the panel
and the Telegram bot already say, which both render the same value as days.
2026-09-13 19:47:07 +02:00