3545 Commits
Author SHA1 Message Date
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.
dev-latest
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 v3.8.0 2026-09-14 19:11:03 +02:00
Sanaei 840a40edcd chore(deps): update frontend and Go deps
Update Ant Design, React i18n, Zod, testing utilities, Oxc tooling, GORM Postgres, Pion transport, and sing dependencies to their latest specified versions.
2026-09-14 19:10:48 +02:00
BlindMaster24 c0271e231d fix(panel): read the outbound protocol id in the Outbounds row like the core (#6528)
* fix(panel): read the outbound protocol id in the address column like the core

outboundAddresses switched on the raw id, so a row the core runs normally but
spelled "VMess", "Trojan" or "WireGuard" fell through to default and rendered
an empty Address column in the outbounds table, the card view and the
subscription table -- a populated server that looks absent, which is what
sends an operator to recreate a correct outbound.

The id is folded once before the switch, the way isUdpOutbound already folds
the transport name.

* fix(panel): fill the outbound address column from one protocol-id rule

outboundAddresses folded the id inline while isUntestable, two functions
below it in the same file, reads it through isOutboundProtocol — so the
"the core lowercases the id" rule lived in two places and two tests. It
now routes through the shared helper, which keeps the rule with the
module that owns it.

Two further gaps in the same switch, reported in the same review:
hysteria and amneziawg are both selectable in the outbound form but had
no case, so a canonically spelled row rendered a blank Address cell that
case folding could not reach; and the VLESS branch returned a bare ":"
for a row whose servers sit in vnext, which this change newly reached
for a "VLESS" spelling.

Tests: the hysteria/amneziawg cases and the bare-separator case are red
on the pre-fix switch.

* fix(panel): read the vnext shape of a vless outbound in the address column

The vless branch read only the flat settings.address/port, so a row whose
servers sit in vnext — the shape the probe's extractor reads first
(internal/web/service/outbound/outbound.go:259-269) — rendered a bare ":"
separator, or nothing at all before this branch folded the id. It now
reads vnext first and falls back to the flat pair, the order the
extractor uses, which also makes it agree with what a probe of that row
would say.

Test: "reads the vnext server of a vless row" is red on the pre-fix
branch.

* fix(panel): read the protocol id of the outbound stream tags like the core

The identity cell gated the network and security tags on an exact-match
includes() over four ids, so the same "VMess" row whose address this
branch now shows still rendered without its ws/tls tags — the row was
half-readable. It now asks the shared isOutboundProtocol, the rule every
other reader on the page uses.

Test: "renders the stream tags and the address of a VMess row" is red
without this change (['VMess'] vs ['VMess','ws','tls']).
2026-09-14 19:53:57 +03: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 84c5aef4a1 fix(panel): probe UDP outbounds and hide the block outbound from the mtproto egress picker (#6525)
* fix(panel): match the probe and egress readers to what the core loads

Two readers left over from the case-sensitivity sweep still disagreed with
the core, both raised reviewing #6523.

isUdpOutbound compared the protocol id and the transport name exactly. The
core lowercases both before it resolves them (infra/conf/loader.go:46 for
the id, TransportProtocol.Build at infra/conf/transport_internet.go:16-17
for the name), so an outbound spelled "WireGuard" or a stream named "KCP"
still built a UDP handler but was probed with a dial-only TCP request, and
Test All Outbounds reported a working outbound as down.

The mtproto egress picker asked for outbound tags without excludeBlackhole,
so the block outbound stayed selectable there. Choosing it looks like a
working selection and discards that inbound's Telegram traffic.

* fix(panel): recognise the mkcp transport alias and pin the picker's field id

Review findings on #6525.

TransportProtocol.Build resolves both "kcp" and "mkcp" to the same mKCP
transport, so comparing the transport name against "kcp" alone left a
template spelling "network": "mkcp" in the TCP lane and reported a working
outbound as down — the same trigger this PR already fixed for the "KCP"
capitalisation.

The egress picker now carries an explicit id, the way the inbound form's
protocol select does, so the test addresses that field rather than the first
searchable select on the page and reuses the shared dropdown helper instead
of duplicating it.
2026-09-14 16:00:13 +02:00
BlindMaster24andSanaei a5a4c9cd83 fix(panel): read an outbound protocol id the way the core does (#6522)
* fix(panel): read an outbound protocol id the way the core does

xray-core lowercases a protocol id before it resolves the handler, so a
template that spells the direct outbound "Freedom" is that outbound. The
outbound editor fell through to the vless default and rendered it as an
empty vless server, and the Basics tab did not find it and appended a
second "direct", which the core refuses to load with "existing tag found".

* fix(panel): never leave the direct tag on two outbounds

A "direct" tag held by a non-freedom egress made both Basics-tab setters
push a fresh freedom outbound, and the core refuses a config whose tags
repeat ("existing tag found: direct"). The tag is now checked on its own
before anything is added, matching setDefaultOutboundTag.

* fix(panel): disable the freedom controls when direct is held elsewhere

When a non-freedom outbound holds the "direct" tag, both Basics setters
drop the edit so the core never sees the tag twice, but the Freedom
Strategy select and the Happy Eyeballs switch stayed enabled and snapped
back with no sign of why. isDirectTagTaken now disables both controls in
that state.

The find-or-create-plus-guard was also copied into BasicsTab's happy
eyeballs setter with no test of its own; ensureDirectFreedomOutbound now
owns the lookup, the guard and the creation for both setters, so the
existing helper tests cover that path too.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-14 15:59:34 +02:00
BlindMaster24 c0c2dd274c fix(panel): read outbound protocol ids case-insensitively everywhere (#6523)
Six more readers compared an outbound's protocol id exactly while the core
lowercases it, so an outbound spelled "Blackhole" passed every
excludeBlackhole filter (offered as an mtproto egress, a dialerProxy
target and the geodata download egress, all of which then drop the
traffic) and one spelled "Freedom" was queued by Test All Outbounds. They
now share isOutboundProtocol.
2026-09-14 15:02:58 +02: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
JackandJacktheRanger 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
EgorandSanaei a09e136001 docs: add Discord bot to READMEs, architecture, operations guides, and locales (#6513)
* docs: add Discord bot to READMEs, architecture, operations guides, and locales

* docs: address review feedback on Discord bot formatting, backup commands, and architecture

* docs(discord): fix Persian typo and literal arrows on fa/zh bot pages

Senior review of #6513, two LOW findings in the two new pages:

- fa/operations/discord-bot.mdx:30 spelled "developers" with Cyrillic
  "де" in place of Persian "ده", rendering a mixed-script word.
- Both pages copied `$\rightarrow$` from the en page. The docs site has
  no math plugin (nothing in source.config.ts, no remark-math
  installed), so the built HTML shows the literal string
  "$\rightarrow$" in every menu path. Replaced with a Unicode arrow on
  fa and zh; en and ru have carried the same since #6486 and are left
  for a separate change.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-14 11:53:22 +02:00
EgorandSanaei 2d7c8c77f7 docs: add TUIC v5 to READMEs, guides, and protocol references (#6511)
* docs: add TUIC v5 to READMEs, guides, and protocol references

* docs: address review feedback on TUIC architecture, external links, and i18n parity

* docs(tuic): drop the Xray-routing claim and qualify Limit IP for TUIC

The README feature bullet in all seven locales credited the TUIC sidecar
with "seamless Xray routing". A TUIC inbound never enters the Xray config
(internal/web/service/xray.go skips model.TUIC) and the generated
tuic-server config carries no forwarding target, so decrypted TUIC
traffic egresses from the sidecar directly and Xray routing rules never
see it; docs/architecture.md in this same branch already says so. An
operator reading the bullet would expect geo blocking and outbound
selection to cover TUIC clients.

The client field table marks Total (GB) as inbound-level for TUIC but
left Limit IP at "all". The IP-limit job's only data source is Xray's
online-stats API (internal/web/job/check_client_ip_job.go), which TUIC
clients never reach, so a Limit IP set on a TUIC client is silently
unenforced. Qualify that row the same way in en, fa, ru and zh.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-14 11:36:59 +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
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
939c470698 feat(inbounds): show linked host remarks in inbound list (#6468)
* feat(inbounds): show linked host remarks in inbound list

Join Host Group remarks from the existing hosts list onto each inbound
row client-side so multiple endpoints (IPv4/IPv6/CDN) are visible without
opening the inbound. Truncate long lists with a tooltip for the full set.

Fixes #6026

* fix(inbounds): skip disabled host groups in inbound list remarks

buildHostRemarksByInboundId joined every host group from /hosts/list
onto its inbounds, so a group toggled off on the Hosts page still read
as a live endpoint in the inbound remark cell and matched the search
box. A disabled group serves nothing: internal/sub/host_sub.go filters
it out of subscription output and withMtprotoHostEndpoints skips it for
MTProto share links. Skip it here the same way, and drop the unread
`truncated` field from formatHostRemarksLabel.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 22:03:50 +02:00
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
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
Alireza Arezoumandan d0ad773edf fix(clients): preserve traffic reset schedule when toggling enable (#6502)
Carry the hydrated reset cycle and day into the enable update payload so toggling a client does not normalize its schedule to never. Cover both toggle directions with a regression test.
2026-09-13 21:27: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
768bbd2a29 feat(settings): add setting for Reality scan candidates (#6471)
* feat(settings): allow customizing Reality scan candidate list

Persist a realityScanCandidates panel setting (defaulting to the previous
hardcoded list), expose it in General Settings with i18n, and have the
Find Targets scanner use it when the search box is empty.

Fixes #5847

* style(frontend): oxfmt realityScanCandidates in setting.ts

* Fix locale JSON syntax

This change removes the malformed duplicate key and missing comma in the Android per-app proxy translations across the bundled locale files. The JSON now parses correctly while preserving the translated labels for each language.

* docs(i18n): update Happ translations

Localize the remaining Happ subscription settings strings across the translation files and refine the English copy. This aligns the labels and descriptions with the current Happ behavior for notifications, TUN options, HWID enforcement, routing presets, and per-app proxy settings.

* refactor(reality): drop test-only scaffolding from the candidate setting

TestDefaultRealityScanCandidatesCSV compared the CSV against its own
initializer and a defaultValueMap lookup, and
TestRealityScanCandidateTokensFallsBackWithoutDB drove a no-database
state no production caller reaches (the only caller is the
scanRealityTargets handler, served after InitDB). The
s != nil && GetDB() != nil guard existed only for that second test.
None of them could fail except in lockstep with the code they restate.

* docs(api): describe the setting-driven scanRealityTargets fallback

An empty targets value now probes the realityScanCandidates setting,
but the endpoint summary, parameter description and handler comment
still promised the built-in seed list, so API consumers were told the
wrong target set. Regenerated openapi.json and synced the docs copy,
which also lacked the new AllSetting field in the settings reference.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 14:48:28 +02:00
EgorandSanaei bf7ce2daaa feat(discord): add Discord notification bot service (#6486)
* feat(discord): add Discord notification bot service, settings UI, and event subscriber
- internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber
- internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection
- internal/web/controller: register POST /panel/api/setting/testDiscord endpoint
- frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration
- translation: add localization keys across all 13 locales
- tests: add comprehensive unit tests with httptest server and verify route/i18n contracts

* fix(discord): address PR review findings on concurrency, linting, i18n, and stories

- subscriber: eliminate unbounded goroutines, sending inline per EventBus contract
- discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext
- format: apply gofumpt to controller and entity struct alignments
- i18n: localize testDiscord controller responses across all 13 locales
- storybook: add DiscordNotifications.stories.tsx component story

* docs: add Discord bot setup and operations guide

- add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting
- add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions
- update operations/meta.json across en, ru, zh, fa
- link Discord bot from panel configuration overview

* feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI

- internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting
- internal/web/service/setting: add defaultValueMap entries, getters, and setters
- frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts
- frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs
- translation: add localization keys across all 13 locales

* feat(discord): implement scheduled status reports and database backup attachments

- internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads
- internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds
- internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled
- internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron
- internal/web/locale: add LocalizerFor and I18nForLang helpers
- internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes
- internal/web/web: register and reschedule DiscordNotifyJob
- tests: comprehensive unit tests for multipart uploads, status reporting, and job execution

* feat(discord): add interactive bot commands via Gateway WebSocket and update documentation

- internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket)
- internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch
- commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes)
- internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates
- docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents
- tests: add end-to-end WebSocket Gateway test verifying command handling

* style(discord): fix goimports formatting and add 3x-ui to gitignore

* fix(discord): stop gateway panics, reconnect storms and proxy bypass

The Gateway client wrote to its websocket from both the heartbeat ticker
and the read loop answering server-requested op 1 heartbeats. gorilla
panics on concurrent writes and neither goroutine recovers, so a colliding
heartbeat took the whole panel process down; writes now share writeMu.

It also reconnected every 5s forever after close codes Discord marks
non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message
Content Intent is off), re-identifying and logging a warning each time.
The loop now stops on those codes; the docs say to restart the panel.

The gateway dialed with websocket.DefaultDialer, bypassing the panel
egress proxy the REST client already uses, so where Discord is filtered
notifications arrived but commands never connected.

* fix(discord): deliver the scheduled report when the backup upload fails

SendReport posted the report embed and the x-ui.db/config.json attachments
in one multipart request. Once the database outgrows Discord's upload cap
(20 MiB by default) the request is rejected and the report embed is lost
with it on every run, leaving only a log warning. Send the embed first and
the attachments as a second message.

* chore(discord): delete tests that pass whether or not the code works

TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService
feed a nil DiscordService that web.go never passes, and
TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable
guard because Xray is not running under test.

* fix(discord): require admin user IDs for bot commands and honor discordLang

Any member who could post in the configured channel could run !backup
(the whole x-ui.db and config.json, even with discordBotBackup off),
!restart and !usage. Commands now run only for the Discord user IDs in
the new discordAdminIds setting; an empty list turns commands off.

discordLang was saved and offered in the UI, but nothing read it, so
every embed stayed English. The test message, alerts, the scheduled
report and command replies now render through I18nForLang in the chosen
language, with a discord section in all 13 locales. InitLocalizer takes
an fs.FS so tests load the real translation files.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 14:04:53 +02:00
Pejman Yousefi cba8f0672f feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement (#6488)
* feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement

* fix(sub): address PR review findings on routing parity, agent regex, and i18n
2026-09-13 12:53:57 +02:00
BlindMaster24 c7518c4038 fix(tgbot): send the admin traffic reports as one message (#6490)
Reset all traffics and the sorted usage report replied with one Telegram
message per client. A panel with a few hundred clients therefore fired a
burst of sendMessage calls that trips Telegram's per-chat rate limit and
throttles the bot for every user, not just the admin who tapped.

Both reports are now assembled into one string and handed to
SendMsgToTgbot, which already pages long messages.

Two details the batching would otherwise lose: the reset report still
answers (with the reply keyboard removed) when the panel has no clients,
and both reports are HTML-escaped as a whole, because a single stray "<" in
a remark or an email now costs the ~15-client page it lands on instead of
one client's message.
2026-09-13 12:52:55 +02:00
NgaiYeanCoiandSanaei 6a5b4fab6a feat(happ): generate Crypt5 subscription links locally (#6494)
* feat(clients): add stateless Happ link generator

Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.

* fix(clients): reject duplicate Happ provider fields

Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.

* feat(clients): expose on-demand Happ link API

Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.

* fix(openapi): exclude service interfaces from generated types

Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.

* feat(clients): add stateless Happ QR presentation

Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.

* fix(clients): cover overlapping Happ generations

Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.

* fix(clients): harden Happ link handling

Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.

* fix(clients): gate Happ link generation behind operator opt-in

- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions

* fix(frontend): guard oversized Happ QR codes

Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.

* fix(clients): log the sanitized transport error for Happ link failures

Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.

Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.

* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close

HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.

Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.

* chore(clients): request Happ crypt5 links from api-v3

crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.

* feat: add local generation of encrypted Happ links

- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.

* fix(frontend): match the tuic memo deps to the non-optional subSettings

The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.

* chore(happ): trim the pinned-key provenance comment to two lines

CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-13 12:44:55 +02:00
Sanaei c3b08b6d9f fix(tgbot): guard the mock Telegram server's call counts
staleButtonServer increments its per-method map from the HTTP handler, which
httptest runs on one goroutine per connection. #6491's
TestAdminListReadersShareTheWriterLock is the first test to reach it from
several goroutines at once, so CI's race job flagged the helper's map rather
than the code under test.
2026-09-13 12:01:56 +02:00
Sanaei b98f947efe fix(tgbot): answer only the link callbacks that match nothing
#6493 was written against the if/else chain where every served link action
returned early, so its trailing answer ran only for an unrouted payload. #6489
had already turned that chain into a switch that falls through, so after the
merge every served link tap also got an error toast, while an unknown payload
still returned from the !ok branch unanswered.

Move the answer into the !ok branch, the one place nothing matched. This
turns TestClientLinkCallbackServesOwnClient and TestUnroutableCallbackIsAnswered
green again on main's go-test job.
2026-09-13 12:01:55 +02:00
DIMFLIX 2730e4d071 feat(sub): let the panel set the JSON subscription DNS servers (#6485)
* feat(sub): let the panel set the JSON subscription DNS servers

A baked routing profile (#6402) carries only the DNS its preset defines, so an
operator who wants their own resolvers has to override the whole profile or
patch the subscription behind a proxy.

Add the subJsonDns setting: either a full xray dns block or a bare array of
servers. It wins over the profile's DNS while leaving the profile's routing
rules intact, and reaches per-inbound, balancer and info-node documents alike.

The value is validated with xray's own schema (internal/xray/dnsconf): a block
the client could not load is rejected when the settings are saved and ignored
with a warning at request time, instead of being baked into every document.
Both the sub server and the settings API share that validator, so a stored
value can never be silently dropped.

xray's Build() is deliberately not used for validation: it resolves geosite
tokens from the geodata files and would reject valid configs whenever those
are absent from the panel's working directory.

* style(dnsconf): drop the ineffectual initial map assignment

golangci's ineffassign flagged the zero-value map whose value both paths
overwrite: the object branch now assigns the decoded map directly.

* docs(sub): scope the DNS setting to the documents it rewrites

The Routing header mirrored to Happ/INCY keeps the routing profile's own
resolvers, so the setting description and the header-source comment now say
so instead of claiming the profile's DNS is replaced everywhere.

Also trims two comments in the new dnsconf package to the repo's two-line cap.
2026-09-13 11:51:56 +02:00
BlindMaster24 aaa5e61cad fix(tgbot): answer the callbacks the bot cannot route (#6493)
Telegram keeps a tapped button in its loading state until the callback is
answered, and three paths dropped the tap without answering: a payload that
matched no case in the email-scoped switch, one that matched nothing in the
switch that follows it, and a button whose hash had aged out of the 20-minute
storage, which replied with a chat message only.

All three answer now. The email-scoped switch is reached only by an admin's
payload carrying arguments, and today an unknown action there falls out of the
switch into a return that tells the operator nothing.

The expired-hash path keeps the chat message as well, because
sendCallbackAnswerTgBot is a single unretried call while SendMsgToTgbot retries
connection errors, and after a panel restart that notice is the only
explanation the admin gets.
2026-09-13 11:47:22 +02:00
BlindMaster24 02c6c3a9c6 fix(tgbot): render the add-client draft as HTML and escape its values (#6492)
The draft is sent with ParseMode HTML but was written in Markdown, so every
card showed literal asterisks and backticks while the rest of the bot's
messages render properly. Its admin-supplied values (email, comment, TG id,
inbound remarks) were also interpolated raw, and a single '<' in any of them
makes Telegram reject the whole message as unparsable.

The wizard's own email and comment prompts echo those same draft values into
HTML-parsed messages and were escaped too: the card is deleted before the
prompt goes out, so a rejected prompt left the admin with an empty screen.
2026-09-13 11:46:58 +02:00
BlindMaster24 615876b2eb fix(tgbot): read the admin list and running flag under their mutex (#6491)
Start and Stop replace adminIds under tgBotMutex, but the report cron, the
backup job and every incoming callback (checkAdmin) read it unlocked. A
concurrent read of a slice header being replaced is not a benign race: it can
observe a torn header and iterate past the backing array. The readers now take
a snapshot under the same lock, and SendMsgToTgbot uses the existing IsRunning
accessor instead of reading the flag directly.
2026-09-13 11:46:36 +02:00
BlindMaster24 7ac5277c4f fix(tgbot): require client ownership for non-admin link callbacks (#6489)
A non-admin tapping a subscription, individual-links or QR-links button was
served whatever email that button carried. Those keyboards outlive the chat
they were sent to (group chats, forwarded cards, a client whose tgId was
later revoked), so the email in the callback data cannot authorise itself.
The lookup the self-service usage command already performs now decides
whether the callback is served.

The gate also has to read the email at all: encodeQuery replaces any
callback payload past 64 chars with a hash, so for a client whose email is
long enough the non-admin path saw a bare hash and dropped the tap without a
word. The raw data is now decoded before the gate, which is the same decode
the admin path already performs, and an email the caller cannot prove is
answered with the generic error instead of silence.
2026-09-13 11:46:14 +02:00