mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
af466b6a244d126ce85f28f8c08730279e9ccedc
3556 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
789a03065a |
chore(docs): bump dependencies and adapt to fumadocs-core 16.15.11
Updates the docs site's dependencies, including the Fumadocs packages, Next 16.3.5, React 19.3 and three majors: mermaid 12, vitest 5 and pnpm 12. Two code changes follow from the bump: - fumadocs-core 16.15.11 makes `llms().index()` return a Promise, so the llms.txt route now awaits it; tsc rejected the old synchronous call - lucide-react 1.46 renamed the BookMarked icon to BookBookmark. The old name is still exported, but lucideIconsPlugin looks names up in lucide's `icons` map, which only has the new one, so the Reference section lost its sidebar icon in all four locales. The build only printed a warning. minimumReleaseAgeExclude gains entries for the newly installed versions. Checked with typecheck, lint, vitest (106 tests) and a full build: no plugin warnings, and each locale's rendered /docs page contains the book-bookmark icon. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
837addf66e | v3.8.0 v3.8.0 | ||
|
|
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. |
||
|
|
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']). |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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"reilly<1> 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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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) |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |