mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
2dd903ea8e7bd912406375b84b39d476f895a55d
3464 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2dd903ea8e |
feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402)
* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription Accepts the routing-rules format emitted for Happ and INCY (inline JSON, happ:// or incy:// deeplink, or a remote https:// URL resolved through the existing remote routing cache). The JSON subscription will bake these rules into its documents so header-ignoring clients still get routing. * feat(sub): bake Happ/INCY routing profiles into JSON subscription documents When subJsonRoutingRules is set, every emitted document (per-inbound and balancer alike) carries the profile's dns and routing rules baked in, so header-ignoring clients like Happ and INCY still get routing; the legacy simple-rules merge only applies when no profile is set. The balancer document builder keeps rewriting proxy-tag rules to the balancer. * feat(sub): add the subJsonRoutingRules setting Plumbed from the settings store through the subscription server into SubJsonService, so admins can set a routing profile once and every JSON subscription document carries it. * chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules * feat(web): routing profile editor for the JSON subscription A textarea inside the JSON card accepts the routing profile (inline JSON, happ/incy deeplink, or https URL) with a remote-source badge; the badge helper moves to a shared module. Keys added to all 13 locales. * fix(sub): warm and lazily resolve the baked JSON routing source The routing profile was resolved once at service construction: a remote URL that was cold at that moment baked default routing forever, and the cron job never warmed it. The job now warms the subJsonRoutingRules URL, and the profile resolves per request with an in-memory memo (a failed resolve is not cached), so a warmed cache takes effect without a restart. * feat(sub): fall back to the JSON routing profile for the Routing header Happ and INCY download the geo files a routing profile references through the Routing response header. When the Happ header setting was blank the header stayed unset, and clients fetched no geo files even though a JSON routing profile was configured. A blank setting now falls back to the JSON profile: happ/incy deeplinks pass through, inline JSON and remote URLs are normalized to a happ:// deeplink; an unusable or oversized value leaves the header unset. Locale captions mention the fallback. * fix(sub): pass routingRules arg at call sites added by main Main gained four NewSubJsonService call sites after this branch forked; update them to the five-arg signature so internal/sub builds again. * fix(sub): address code review findings on the baked JSON routing The memoised baked template never invalidated, so an edited remote profile kept serving the superseded dns/routing subtrees until a panel restart; bakedTemplate now re-resolves the spec per request and rebuilds only when the payload actually changed (regression-tested). subJsonRoutingRules shared the happ persistence row with subRoutingRules, so only the last-written setting survived a restart; it now resolves under its own jsonhapp kind with the same validation and size caps. The setting also joins validateSettingsURLs, so remote values are canonicalised and bad URLs are rejected on save. Also: drop the unreachable half of the remote-source guard, cut the overlong comment blocks to the two-line convention, and deduplicate remoteSourceBadge in the General tab. Merges upstream/main (call sites for the widened NewSubJsonService signature). * style(sub): gofumpt the json_routing imports * fix(sub): accept happ add/ deeplinks and bound the routing warning The baked-JSON routing parser only recognised happ://routing/onadd/, but normalizeHappRouting treats happ://routing/add/ as an equally valid routing deeplink. An operator pasting the add/ form got the Routing header set, so the panel looked configured, while every JSON subscription document silently carried the default routing instead of their profile. resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it once per emitted document, so a single fetch of an unusable profile wrote one identical warning per document. On the public subscription server that floods the 10240-entry buffer the panel's log view reads, evicting real entries. Log only when the message changes, and reset on a successful resolve so a profile that recovers and fails again is still reported. Also resolve the template once in buildBalancerConfig: two resolves could straddle a profile refresh and pair one revision's dns with the other's routing. |
||
|
|
ed5465d0f2 |
feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust (#6399)
* feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust Add HWID device limit and Telegram MTProto sponsor channel (ad-tag) support to the bulk client adjustment flow in both the panel API and frontend ClientBulkAdjustModal. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(clients): gate adTag to MTProto inbounds and avoid inbound rewrite for limitHwid Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(clients): stamp updated_at only on the clients a bulk adjust changed The updated_at write was gated on hasInboundChanges, which accumulates over the whole inbound instead of describing the client in hand. Once any client in the settings array changed, every client after it was re-stamped as well, so whether an untouched client kept its own updated_at depended on its position in the array. That field feeds node-snapshot conflict resolution, where a spurious bump lets a stale snapshot value win over the stored record. Track the change per client and fold it into the inbound-level flag where the stamp is written, so the early return still skips a save whose settings JSON would be unchanged. Also condenses the BulkAdjust doc comment back to the two-line maximum. * docs(api): regenerate the bulkAdjust reference for limitHwid and adTag frontend/public/openapi.json was copied to docs/public/, but pnpm gen:api was never re-run, so the API reference page's heading, anchor id and search index still described bulkAdjust without limitHwid or adTag. docs-ci.yml fires only on docs/**, and that path had been touched, so nothing flagged the stale MDX. The externalLinks hunks are the generator rewrapping lines main had left stale, not a content change. * fix(i18n): stop enumerating fields in the bulk-adjust empty-form message bulkAdjustNothing listed the fields the form accepts, so it went stale every time one was added: only en-US ever gained "flow", leaving the other twelve locales describing days and traffic alone, and limitHwid and adTag would have repeated that. Say that one field is required instead of naming which, so the message cannot drift again. |
||
|
|
1456658028 |
feat(sub): add Happ client integration, routing presets, and app management (#6434)
* feat(sub): add Happ client integration, routing presets, and app management Implement comprehensive Happ proxy client integration according to official developer specifications. - Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings. - Add support for 'happ://routing/off' deeplink in routing validation. - Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS. - Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings. - Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings. - Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations. * fix(sub): keep Happ header overrides behind the auto-detect opt-in The Routing-Enable/Hide-Settings off values were emitted on the User-Agent alone, so every panel that upgraded would push "Routing-Enable: 0" — documented by happ.su as disabling routing globally — to every Happ client without the operator enabling anything. They now ride subHappAutoDetect like every other Happ header. Two further mismatches against the vendor spec: - serverDescription was written as a key of the VMess base64 JSON object. happ.su documents it as a "#Title?serverDescription=<base64>" link parameter or a JSON "meta" entry, so the caption never reached Happ while every other VMess consumer received an unknown key. Dropped rather than moved: emitting the documented form is unsafe here because our own parser base64-decodes the whole VMess body (internal/util/link/outbound.go). - The TUN Mode dropdown stored the literal "default", forwarded as "Tun-Mode: default", where happ.su documents system|gvisor only. It now stores the unset value so no header is sent. TUN Type "default" is a documented value and is unchanged. Each fix carries a test that fails without it. |
||
|
|
d5ab84e8d5 |
feat(amneziawg): add AmneziaWG as an outbound protocol (#6320)
* feat(amneziawg): add AmneziaWG as an outbound protocol - AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI - Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test - Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions - Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test - Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go - Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients) - Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress) - Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts) - Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks * fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys - Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns - Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound - Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection - Trim comment blocks exceeding 2 lines across modified files - Fix Storybook test execution on environments with POSIX locale Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
876497db6e |
feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326)
* feat(sub): add AmneziaWG proxy generation for Clash subscriptions Add buildAmneziaWGProxy to generate mihomo-compatible wireguard proxy entries with amnezia-wg-option sub-block for AmneziaWG inbounds. Previously, AmneziaWG inbounds were silently skipped in Clash subscriptions (buildProxy returned nil), making them unusable with mihomo/Clash clients. The new function reuses the wireguard proxy base structure and adds: - v1.0 obfuscation fields (jc/jmin/jmax/s1-s4/h1-h4/i1-i5) - v1.5 fields (s3/s4/i1-i5) - v3 fields (header-protection-key, content-padding-addition, timing, random-trailers, disable-cookies) with automatic version: 3 tagging Closes #6310 * fix review nits: doc comment and test call * fix(sub): use the AmneziaWG inbound's own tunnel address in Clash proxies buildAmneziaWGProxy took the peer address from model.Client.AllowedIPs, which matchingClients resolves out of the shared clients.wg_allowed_ips column. That column holds one address per identity, so a client attached to both a WireGuard and an AmneziaWG inbound gets the other protocol's address written into its Clash proxy - an unroutable peer, since the running interface accepts only the AllowedIPs InstanceFromInbound derives from the inbound's own settings JSON. Read the address from settings.clients[] and fall back to the shared column. Also emit remote-dns-resolve alongside dns: mihomo gates its whole `dns` list on that flag (adapter/outbound/wireguard.go, NewWireGuard), so the panel's primaryDns/secondaryDns were inert in Clash while the vpn:// .conf turned them into a real DNS line. Restricted to bare IPs - mihomo aborts the entire config when dns.ParseNameServer rejects an entry, and nothing validates those fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sub): emit the AmneziaWG effective MTU in Clash proxies main's EffectiveMTU landed while this branch was open, so the Clash builder was the one AmneziaWG emitter left omitting mtu when the operator set none. The running interface uses EffectiveMTU (internal/amneziawgnet/device.go), as do the vpn:// .conf and both TS builders; mihomo instead falls back to its own 1408, which sits above the tunnel once s4 passes 12 and fragments every packet the client sends. GenerateObfuscation31 draws s4 from 12..27, so that is the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sub): reject a zoned DNS address before opting into remote-dns-resolve netip.ParseAddr accepts "fe80::1%eth0", but mihomo's parsePureDNSServer brackets it into "udp://[fe80::1%eth0]" and url.Parse rejects "%et" as a bad escape, so parseNameServer errors and parseProxies aborts the entire config - the whole subscription's Clash profile, not just this proxy (#4641 class). |
||
|
|
8076d5edfa |
chore(frontend): bump React and Zod deps
Update frontend dependencies to React/ReactDOM 19.3.0, Zod 4.6.1, and matching React type packages. Also replace Storybook addon-vitest override placeholders with explicit Vitest/browser-playwright versions to keep dependency resolution stable. |
||
|
|
87420e3bb1 |
fix(tgbot): close stale-inbound TOCTOU and contain handler panics (#6442)
Tapping an old get_clients_for_* inline keyboard re-fetched the inbound after the keyboard lookup, discarding the error; if the row vanished between the two reads, the second GetInbound returned nil and inbound.Remark panicked. The callback handler runs on a bare goroutine with no recover(), so that panic killed the whole panel process. Fetch the inbound once in a shared chooseInboundClient helper that answers an error callback on a missing row, and pass the row down to getInboundClientsFor instead of re-reading the DB, removing the between-reads window. Route all three OnReceive handler paths through a recover() barrier so no handler panic can take down the process, and log the GetInbound failure instead of silently swallowing it. |
||
|
|
33e6c2ec0c | chore(deps): raise the swagger-ui-react js-yaml override to 4.3.2 | ||
|
|
6ee74f2032 |
fix(amneziawgnet): wait for the client netstack goroutines before closing its device
TestPortForwardRoundTripTCPAndUDP flakes in the race job: closing the test's client WireGuard device races the goroutines still writing into its netstack. amneziawg-go's device.Close() calls tun.Close() before it stops the routine draining the tun, and netTun.Close() closes the unbuffered incomingPacket channel that WriteNotify sends on. A goroutine still inside a netstack write when the deferred clientDev.Close() runs therefore closes and sends on the same channel -- reported as a data race, and on a bad interleaving a "send on closed channel" panic. The TCP echo listener, its per-connection copies and the UDP echo all write into clientNet, and teardown only closed the two listeners before the device: nothing waited for the goroutines themselves. A WaitGroup deferred right after clientDev.Close() supplies the missing edge, since LIFO then puts the wait between the listener closes and the device close. Confirmed by flooding the existing UDP echo goroutine under GOMAXPROCS=1 and 2, which failed 3/6 and 2/6 runs with the stack CI reported and 0/12 with the fix. |
||
|
|
d0edbcec81 |
feat(xray): update xray-core to v26.9.9 and follow the udpHop move
Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in DockerInit.sh and release.yml in lockstep. Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a standalone "udphop" UDP mask with a different shape (mode / interval / remotePorts / remoteIPs). The old key is gone from QuicParams, and since the config loader ignores unknown fields it is now silently dropped rather than rejected — port hopping just stops. The panel adapts where that key was live: - Both link importers rebuilt quicParams.udpHop from the standard mport param, so an imported hysteria2 link produced an outbound that no longer hops. They now emit a udphop mask in intervalremote mode, which is what the old key did. The mode is required: UDPHop.Build() rejects an empty or unknown one. - validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go link generator strips the mask from every link and sub, and Zod strips it on the next form round trip. - mport generation (Go and frontend) reads the mask first and keeps reading the legacy key, so inbounds stored before the upgrade still advertise their range. On an inbound the old key was always inert — only hysteria's dialer consumed it — so nothing regresses server-side and no migration is needed. udphop stays out of the mask dropdown on purpose: it is client-only in core, which refuses to wrap a server socket, and that form is shared with the inbound editor. |
||
|
|
cfd596a489 |
fix(amneziawg): let a cleared header protection key reach a running device
amneziawg-go reads an absent UAPI line as "keep the current value", and addressFingerprint keys only on the addresses and MTU, so an obfuscation-only edit reconfigures in place rather than rebuilding. Clearing headerProtectionKey therefore never took effect: the device kept protecting headers with the old key. The stale key also keeps the S1-S4 minimum in force, so lowering S3/S4 in the same edit made every later IpcSet fail with -22 — after replace_peers had already dropped the peers. Send the all-zero key when the field is empty, which is how the UAPI expresses "disabled"; an empty value would be rejected, since it decodes to zero bytes. |
||
|
|
efc603f59c |
fix(settings): show the SMTP failure reason instead of a raw i18n key
classifySMTPError returned keys already carrying "pages.settings.", while the
four keys TestConnection returns directly do not, and the alert renders every
Message under that one prefix. Any classified failure therefore looked up
pages.settings.pages.settings.smtpErrorAuth, which does not exist, so the panel
printed the key instead of "Authentication failed — check username and
password". The unknown case was worse: it appended the raw error to the key, and
its own text interpolated {{ .Error }}, Go template syntax the frontend's
i18next never fills.
Return the keys unprefixed like the rest, and point the unknown case at the
panel log, which already carries the underlying error.
|
||
|
|
c392f367e1 |
fix(dns): stop offering a port field that DoH entries discard
Xray ignores port for DoH/DoHL/DoQL, so valuesToWire deliberately stores none for an encrypted address and a non-standard port has to go inside the URL. The form kept offering the field anyway, pre-filled with the 53 from its own defaults: a port typed there was dropped on save and redrawn as 53 on reopen, which reads as the panel losing the value. Render the port field only where it is actually stored. DoT keeps it, since tls:// is not an encrypted-address scheme for this purpose. Closes #6403 |
||
|
|
705b291d34 |
fix(amneziawg): stop losing an inbound and its server keys on the API path
Two saves that the panel UI never makes, but the documented REST API does. A client whose allowedIPs normalized to empty passed validation, then InstanceFromInbound skipped the peer and dropped the whole instance when it was the only one. Nothing logged it, so an enabled inbound simply never opened its socket. Refuse an enabled peer with no address, naming the client, the way the injection and collision checks already do. The server keypair was regenerated whenever a payload omitted privateKey, which invalidates every client config already distributed, and a payload carrying only privateKey left publicKey empty so rendered configs got a blank "PublicKey =". An omitted key now means unchanged: the stored pair is carried forward, a half-supplied pair has its public half derived, and generation is reserved for an inbound that has no stored keys at all. UpdateInbound loads the stored row before normalizing so those keys are available. Closes #6407 |
||
|
|
4e423fa452 |
fix(sub): drop Reality parameters when a host forces plain TLS
A Host row may set Security to tls on an inbound whose own stream is Reality. The emitted link then carried security=tls next to pbk, sid, spx and the Reality dest as sni: the endpoint no longer performs a Reality handshake, so those describe a server the client will never reach, and clients that honour them fail to connect. Only the security key was rewritten at emit time, and the existing strip covered alpn/sni/fp/pcs for forceTls=none alone. Clear the Reality-only parameters before the endpoint's own TLS overrides are applied, so a host that supplies its own sni or fingerprint still wins. Closes #6424 |
||
|
|
65c5580e7d |
fix(clients): keep per-peer keys when a client spans several tunnel inbounds
A client attached to several WireGuard/AmneziaWG inbounds is that many independent peers, each with its own keypair, preshared key and tunnel address. The client edit form can only represent one peer, so Update's per-inbound loop stamped that single field set onto every attached inbound: every peer ended up with identical keys and one inbound's address, and the tunnels on all the other nodes stopped working with no way to recover the overwritten values from the panel. The only guard covered AllowedIPs, and only for AmneziaWG. When more than one tunnel inbound is in scope and the caller sent no per-inbound override, clear the shared peer fields so UpdateInboundClient's existing carry-forward preserves each inbound's own. A scoped update (?inboundIds=) still narrows to one inbound and edits it normally. Closes #6372 |
||
|
|
2004340d1d |
fix(inbounds): let a node-adopted inbound keep its own protocol on edit
UpdateInbound restores the stored NodeID before the node-eligibility check, so the payload can never introduce an assignment there — the check could only ever fire on a row that already had one. A node's MTProto inbound arrives on the master by adoption, which does not go through that check, so every later edit of it was refused with "mtproto inbounds cannot be assigned to a node". That made the share address of a node-managed MTProto inbound impossible to change from the panel that generates its subscription links. Refuse only a protocol change into an ineligible protocol, which is the one way an update can still strand a row the master's sidecar loops would never reconcile. Closes #6415 |
||
|
|
bc57548a35 |
fix(clients): withdraw the delete tombstone when the email is re-created
Deleting a client tombstones its email for 90s so a node snapshot captured before the deletion cannot resurrect it. Nothing withdrew that tombstone when the operator re-created the same email, so on a master with at least one node the next merge filtered the live client out of the snapshot and SyncInbound pruned its inbound link. The client reappeared only once the tombstone expired, which is the 90-120s detach window reported. Withdraw it on a successful create, single and bulk, so a tombstone can never outlive the identity it was meant to bury. A failed create still leaves it standing, which is what keeps the stale-snapshot guard intact. Closes #6370 |
||
|
|
246d9207a5 |
fix(sub): emit a bare host in Clash proxies
A Clash "server" is a bare host, not a URI authority, but the custom share address strategy stores an IPv6 literal with brackets so the address normalizer can hand it to the raw link generators. The Clash renderer copied that value into every proxy verbatim, so mihomo received server: "[2001:db8::1]" and failed to parse the node. Raw links were unaffected because joinHostPort strips the brackets and re-adds exactly one. Strip them once where the renderer takes the resolved dest, which is the single place all three proxy builders read the address from. Closes #6373 |
||
|
|
20d7f91c65 |
refactor(ci): add an adversarial pass and name the analyst briefing
REVIEW.md told the reviewer which repository rules to check but never to try
breaking the change, so the conditions this panel actually meets went
unexamined. "Try to break it" adds six, each tied to a mechanism here rather
than to a generic checklist: an upgrade over an operator's existing rows and
the rollback that reads them again, a restart that drops in-memory state under
the cron jobs, a sub-node racing the master on the same row, an operation
applied twice, an inbound or client at the empty and the thousand end, and a
dependency that is down. It closes with the gate that running a case is not
reporting it - each one still has to clear the verification bar below it, so
the section cannot become a licence for hypotheticals.
repo-context.md said nothing about which bot reads it. Only the issue analyst
does, since the review job's briefing moved inline in
|
||
|
|
acf3603dc8 |
refactor(ci): review pull requests with one senior-engineer role
The review job ran the official code-review plugin, which fans a pull request out to five Sonnet reviewers plus a Haiku scorer per finding and drops everything scored under 80, and the briefing file spent most of its lines overriding that plugin. Both are gone: the job hands one Senior Software Engineer prompt to the action inline, the way the issue analyst does, and denies the Agent tool so the single role is mechanical rather than a request. REVIEW.md moves from the emoji markers to CRITICAL/HIGH/MEDIUM/LOW with a pre-existing qualifier. The uncapped rule is scoped to findings the pull request introduced or worsened so it cannot collide with the cap of three pre-existing ones. "A finding is a report, not a patch" stays as it was. Workflow housekeeping: GH_TOKEN, REPO and PR live in the job env instead of six step copies; the skip gate is per pull request, so a head pushed after the automatic review is reviewed only on @claude review; the comment counters sum gh's per-page jq output, which read "0\n0" as a review on a pull request with more than 100 comments; --max-turns rises to 300 because every read now costs the single agent a turn instead of a subagent. |
||
|
|
47d2303334 |
fix(inbounds): reject missing TLS certificates before saving (#6429)
An inbound could be saved with security "tls" and a certificate row carrying neither a file path nor inline content. Nothing rejected it, so the row reached xray-core, whose readFileOrString fails with "both file and bytes are empty" and takes the whole config build down with it — every other inbound included. Validate the credentials on both sides of the wire. validateInboundTLSCertificates follows xray's file-over-inline precedence, requires a private key for every non-verify certificate and insists on at least one server certificate, so a verify-only CA list no longer passes as a server config. The inbound form's Zod schema enforces the same rules per field and serializes only the editor mode the operator actually used, and a failed save jumps to the Security tab naming the certificate row that broke. On update the guard is scoped to a real TLS edit. A row already stored incomplete is grandfathered: it stays editable, and only a save that breaks a previously valid block is refused. A sub-node stores whatever the master pushes, and Remote.UpdateInbound falls back to AddInbound when the node does not yet hold the tag, so a grandfathered row could otherwise never be deployed or re-seeded — the rejection is swallowed to a logger.Debug line and the node stays on a stale config while the panel shows the client as cut off. The controller now marks a node-sync request (mTLS or a node-sync token) on a per-request copy of InboundService, and the guard steps aside for it on both add and update: the row was judged where the operator acted, and a node that refuses it only falls out of sync. Operator and admin-token saves are held to the guard as before. The security union is parameterised on its tlsSettings branch instead of copied, and tlsCertUsesFiles is the one file-vs-inline inference shared by the form schema and the adapter, so the mode the editor opens in and the pair of fields the save serializes cannot drift apart. |
||
|
|
9f76a66dcf |
feat(sub): add dummy info node and status configs for subscriptions (#6412)
* feat(settings): add subInfoNodeEnable and status template settings * feat(sub): add dummy info node and status configs for raw links * feat(sub): support dummy info node in clash and json subscriptions * feat(ui): add subscription info node switch and status templates to settings * style: apply gofumpt formatting * fix(sub): address review feedback on subscription info node - Restore GetSubs contract to avoid unintended remark expansions on non-subscription-body calls. - Exclude dummy info node from Clash PROXY select group when active server nodes exist. - Track hasEnabledClient and set traffic.Enable in JSON and Clash paths so status tokens evaluate correctly. - Deterministically sort client emails across subscriptions before selecting primaryEmail. - Consolidate duplicated info-node evaluation logic into resolveInfoNodeRemark helper. - Remove redundant pure-getter test from setting_sub_info_node_test.go. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
b8597314f8 |
docs(api): mark collection responses nullable (#6430)
* docs(api): mark collection responses nullable Describe allLinks and panel log response objects as nullable string arrays so generated clients accept the existing nil-slice wire format. Pin both schemas with buildSpec regression assertions and regenerate the OpenAPI copies. * docs(api): include nullable Xray log responses Allow generated response arrays to opt into nullability while retaining their schema references and Go-derived examples. Apply this to Xray logs, whose nil slices already serialize as null, and pin the schema and example through buildSpec. |
||
|
|
3cd3836d77 |
fix(amneziawg): account for S4 junk in the default tunnel MTU (#6376)
* fix(amneziawg): account for S4 junk in the default tunnel MTU
amneziawg prepends S4 random bytes to every transport packet
(device.NewOutboundElement) and, unlike content padding and random trailers,
never clamps them against the tunnel MTU. A full-size packet therefore lands on
the wire at MTU + 60 + S4 bytes: 20 IPv4 + 8 UDP + S4 + 16 transport header +
16 poly1305 tag.
With the 1420 default that overflows a 1500-byte link once S4 exceeds 20, and
GenerateObfuscation31 draws S4 from 12..27 inclusive -- so roughly 44% of newly
created inbounds fragment every full-size packet they send.
Measured on a live pair of interfaces, predicted against observed:
MTU 1380 S4 12 -> 1452 on the wire (fits)
MTU 1420 S4 12 -> 1492 (fits)
MTU 1420 S4 20 -> 1500 (exactly at the limit)
MTU 1420 S4 21 -> 1501 (fragments)
MTU 1420 S4 27 -> 1507 (fragments)
EffectiveMTU now subtracts S4 from the default; an explicit MTU is untouched.
Client configs carry the same number. They previously omitted the MTU line
whenever the server had no explicit value, which left the client on its own
1420 default and fragmented the client-to-server direction even after the
server side was fixed -- silently, and only in one direction. All three
emitters (the Go subscription text and the two TypeScript ones) now agree,
which is what the existing parity test exists to protect.
* fix(amneziawg): rebuild the device when S4 changes the derived MTU
Addresses review feedback on the previous commit.
Deriving the default MTU from S4 made a construction-time-only property depend
on a hot-reloadable input, but addressFingerprint -- ensureLocked's only rebuild
trigger -- still hashed the raw inst.MTU. S4 is a UAPI field, so an S4-only edit
took the in-place IpcSet branch and the gVisor netstack kept the MTU derived
from the old S4 while all three client emitters already advertised the new one.
Every panel-created inbound leaves mtu unset, so that was the normal case, not
an edge one: with S4 raised far enough the fragmentation this fix exists to
remove came straight back, and stayed until a panel restart or an unrelated
address edit.
Folding EffectiveMTU into the fingerprint fixes it. An explicit MTU still takes
the in-place branch on an S4 edit, since it does not move the interface MTU.
Also trims four comment blocks to the 2-line cap in CLAUDE.md, and points
NewDevice's doc comment at EffectiveMTU instead of the deleted defaultMTU.
|
||
|
|
5a63d5d468 |
fix(mtproto): use hosts for public share links (#6369)
* fix(mtproto): use hosts for public share links Generate MTProto subscription, client, copy, QR, and export links from managed Hosts so reverse-proxied public ports are advertised correctly. Migrate the redundant legacy custom share address into a Host and keep old imports compatible. Closes #5126. * fix(mtproto): keep host share links lossless and consistent Address review on the MTProto hosts share-link change. The migration no longer drops a legacy custom share address: an unrelated (or disabled) Host stopped suppressing it, so only a Host already advertising the same address does. An imported address now clears the same validation the strict normalizer applies to every other protocol before it becomes a Host. Panel and subscription agree on the endpoint a Host advertises: a portless host string inherits the inbound port rather than the group's, and a port-only host inherits the inbound address instead of emitting server=%3A8443. LinksForClient prefers host endpoints for every protocol, the way getSubs and inboundLinks already do, so the client-links API no longer ignores managed hosts. * fix(mtproto): migrate legacy share address past unusable hosts The seeder skipped the conversion whenever any Host already carried the address, including one that is disabled or excludes the raw sub type. hostEndpoints drops those, so nothing advertised the address afterwards and the marker committed with no way back. The duplicate check now mirrors that same predicate. UpdateInbound cleared a legacy MTProto shareAddr without the Host conversion AddInbound runs, so re-applying an inbound definition through the API dropped the public address silently. Both paths share one capture helper now. Refresh the generated clients API reference for the summary reworded in the previous commit. * fix(inbounds): wait for the hosts list before building mtproto links The page destructured only `hosts` from useHostsQuery, and that list reads empty both while /panel/api/hosts/list is in flight and after it fails. withMtprotoHostEndpoints then returns the inbound untouched, so Copy, QR and Export advertise the internal listen port — the endpoint this branch exists to replace. It is worse than not fixing it: the seeder has already moved a legacy custom share address into a Host, so the fallback is the panel's own hostname instead of the operator's address, and the Go generators reading the same rows from the DB stay correct, so the two disagree for one inbound. Fold the query into the page's existing readiness gate, the same way useInbounds and HostsPage already consume that hook, so an empty list means "no hosts" rather than "not loaded yet". The error branch fires only when nothing is cached, so a refetch failing on window focus does not blank a page whose host rows are still perfectly usable. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
d2ac3b4d7a |
fix(cli): let -getApiToken name the token it regenerates (#6405)
* fix(cli): let -getApiToken name the token it regenerates The flag's help text said "Display current API token". It cannot display anything -- tokens are stored as SHA-256 hashes, and the command's own first two output lines say so. What it does is destroy and reissue a credential: GetApiToken calls RecreateByName on the hardcoded name "cli-fallback". The help therefore invited an operator to run a command they believed was read-only, and it revoked a token someone else was holding. Because that name is a single global slot, two callers silently invalidate each other, and the loser is left with a token that answers HTTP 404 with an empty body -- indistinguishable from a wrong base path, so the failure does not even say what happened. install.sh is one of those callers, at lines 1231 and 1325, so the collision already exists inside this repository. Add -tokenName, defaulting to cli-fallback so install.sh and every existing invocation behave exactly as before. -getApiToken stays a boolean on purpose: install.sh calls it as `x-ui setting -getApiToken true`, and a string flag would swallow that trailing argument and mint a token named "true". The name now reaches both branches of GetApiToken. On a database with no tokens the command used to create one called "install", which the CLI could then never rotate -- defeating the stated purpose of the cli-fallback constant, that -getApiToken cannot accumulate admin-equivalent credentials it never revokes. Both branches use the resolved name, so repeated calls rotate a single slot instead of leaving a permanent token behind. Also cap the name at 64 characters in RecreateByName. Create already enforces that limit on the same column; RecreateByName did not, and it now receives operator input. Assisted-by: Claude Code:claude-opus-5 (mostly) * fix(cli): keep the installer's token out of the rotated slot Folding both branches of GetApiToken onto one name made the bug worse in the exact case this change is about. install.sh records the token it gets on a fresh panel; with both branches on cli-fallback, the next bare -getApiToken rotated that very row and silently invalidated the credential written into the install-result file. Restore the split default -- "install" when the database has no tokens, cli-fallback when it does -- so nothing about an unnamed call changes. An explicit -tokenName still applies to both branches, which is what keeps the flag coherent: -tokenName ci-bot now yields ci-bot on a fresh panel too, rather than "install". Pin it with a test that reads the install row's id and hash before and after a rotation, since a name-only assertion would pass against a deleted-and- recreated row. * fix(cli): stop the `-getApiToken true` form from swallowing -tokenName Three corrections from review. Go's flag package stops parsing at the first non-flag argument, so the trailing `true` in install.sh's invocation does not merely get ignored -- it terminates parsing. An operator copying that documented shape and writing `x-ui setting -getApiToken true -tokenName ci-bot` left tokenName empty, so the command rotated cli-fallback: the shared-slot collision this change exists to remove, reachable through the one form the repository itself demonstrates. Verified against the built binary, which printed `The API token "cli-fallback" has been regenerated`. Drop the stray `true` from both install.sh call sites so the documented form no longer teaches the trap, and warn whenever `setting` is given positional arguments, naming what was ignored. A warning rather than an error, because an older install.sh in the wild still passes `true` and must keep working. Cover both branches in the help strings. They described only the rotation path, so on a fresh panel -h announced cli-fallback while the command actually mints `install`, and nothing is regenerated or invalidated there at all -- misleading help being the defect this change set out to remove. Assert the concrete error in the name-length test. It checked only that some error came back, which RecreateByName's empty-name guard and its transaction errors would satisfy just as well. |
||
|
|
2d151d7648 |
Update deps and simplify parsing
Bump frontend and Go dependencies, then modernize a few hot paths with newer Go string/range helpers. Also widen the client form quota/limit fields to improve the layout. |
||
|
|
2ec6c73613 |
feat(xray): update xray-core to v26.9.8 and adapt panel
Bump xtls/xray-core to 37ceb8b4b6 (v26.9.8) and the three binary pins (DockerInit.sh, release.yml Linux + Windows) in lockstep. No deleted symbols; the impact is entirely on the JSON config surface. Outbound "proxySettings" is now refused by the config loader (moved to streamSettings.sockopt.dialerProxy) and a freedom outbound rejects sockopt.addressPortStrategy. Either key in a stored template would keep the core from starting after the upgrade, so a new OutboundRemovedKeysFix seeder rewrites xrayTemplateConfig once: proxySettings.tag becomes sockopt.dialerProxy (an existing dialerProxy wins) and addressPortStrategy is dropped from freedom outbounds. Template saves and outbound subscriptions already run through the vendored loader, so the new refusals surface there with the core's own message. REALITY no longer applies a built-in minClientVer (26.3.27) when the field is empty. The form placeholder and the min/max hints in all 13 locales now say that empty means no minimum. New upstream keys the Zod schemas would otherwise strip, with form support where a sibling field already had it: - blackhole response type "custom" with base64 customResponseData - realm finalmask ipMode (dual/v4/v6) and portMapping (UPnP / NAT-PMP) - quicParams brutalDisableLossCompensation, disableChromeParrot, disableGSO, disableStatelessReset - hysteria masquerade proxy xForwarded - wireguard outbound remoteDNS - routing rule localOS freedom.domainStrategy is only deprecated upstream (auto-migrated to sockopt.domainStrategy with a warning) and is left untouched. |
||
|
|
a5e68f410f |
perf(node): bound the per-client node push and fan out the traffic reset
An operator with several nodes reported that editing a client or resetting
its traffic takes more than ten seconds on the master. Measured against real
Remote HTTP (fake node servers, one client per node), the healthy case is
already fast — 3 nodes: update 51ms, delete 51ms; 5 nodes: 102ms / 103ms —
but two things were not:
- ResetTrafficByEmail still walked its inbounds one node round-trip after
another: 152ms at 3 nodes, 253ms at 5, linear in node count.
- Every per-client op blocked on the SLOWEST node's push. With one node
answering in 3s, update/delete/reset all took 3003ms regardless of node
count. A node that answers the 4s heartbeat probe but hangs on the push
stays "online", so every edit waited on it up to remoteHTTPTimeout — the
ten seconds in the report. More nodes only raise the odds one is sick.
The push is an immediacy optimisation, not the source of truth: every one of
these ops calls MarkNodeDirtyTx inside the transaction that commits the
change, before it pushes, and the node reconcile job converges a dirty node on
its next 5s tick by re-sending the inbound whose fingerprint was not advanced.
So bound the synchronous push with nodeClientPushTimeout = 4s — the budget the
heartbeat and traffic-sync jobs already treat as "responsive" — at the eight
node-branch push sites. A node that does not answer in time is left dirty and
converged a few seconds later instead of stalling the request; the tag-cache
list fetch inside resolveRemoteID shares the same budget.
Once one push in a batch has timed out, the rest of that inbound's batch now
stops pushing too, as AddInboundClient already did: the node is dirty and one
reconcile converges the whole inbound. Deleting three clients on one hung node
went from 30.08s (three remote timeouts) to 4.06s; at the 32-client push
threshold that is 128s of deadlines saved per inbound.
Fan the reset out through fanoutInboundApplies like the other client ops. Its
node propagation is still attempted whatever the node's status flag says, as
before, because nothing replays a traffic reset — the reconcile pushes inbound
config, not counters — so a node still serving after being marked offline must
receive it now or never.
Trade-offs stated plainly: a node that would have answered in 4–10s now falls
to the reconcile's full-inbound push, which on the node is a delete+add of the
inbound and drops its sessions there — the same fallback a failed 10s push
already used, now reached sooner. The reset stays best-effort with no retry
path, which predates this change. The response still reports success while a
timed-out node catches up; the pending-node badge is keyed off node status by
design, so only the warning log records it.
Tests: a barrier test that a sequential reset cannot satisfy; two tests against
a real runtime.Remote and an httptest node that hangs on the push, pinning that
an edit returns at the deadline (exactly one push reached the node, the node
is left dirty) and that a bulk delete stops after its first timed-out push.
All red without the change; the two hung-node tests pay their 4s deadline on
every run.
|
||
|
|
e9e2e30278 |
perf(clients): push a bulk client change to every node at once
|
||
|
|
f2cf589947 |
fix(node): flag every hosting node before a client edit applies
A client edit fans out one transaction per inbound, and each one renames the single shared clients row but calls MarkNodeDirtyTx for only its OWN node. So between the first and the last commit the record already carries the new email while every other node hosting that client is still config_dirty = false. setRemoteTrafficLocked gates the snapshot merge on that flag, so a merge landing in the gap is accepted, sees a pre-rename snapshot, finds no record for the old email and inserts one through syncInboundClients' CreateInBatches — the only place in the panel that creates a client record. The ghost is never in any later merge's perInboundOld, so markSyncOrphan never fires and ReapSyncOrphans never collects it: the operator is left with a permanent second client under the old name. The same stale merge reverts an expiry-only edit instead of duplicating it. Mark every node hosting the client dirty in one serialized write before the fanout starts, so a merge queued behind it skips the node instead of merging a half-applied edit. The nodes were going to be marked by their own applies anyway; doing it up front only moves it earlier, and a client on local-only inbounds never reaches the writer at all. The set is the client's FULL attachment list, taken before the inboundIds filter narrows it: the rename rewrites the one shared record, so an inbound the filter excluded goes stale too. Two tests, both red without the change. The first pins the ordering rather than the end state — it reads the watched node's flag from inside another inbound's push, so moving the marking after the fanout turns it red. The second pins that the filtered path still covers the excluded node. This narrows the window rather than closing it everywhere. A reconcile tick can still clear the flag mid-fanout, and on a filtered edit the excluded inbound keeps the old email in its settings for good, so its next merge duplicates again. The case-drift path — a node reporting another case of a known email — is untouched and still duplicates. |
||
|
|
f072d0448d |
fix(clients): flag the restart a partly-applied edit or delete still needs
|
||
|
|
33058c8eed |
fix(tgbot): use a token telego accepts in the edit-message tests
telego.NewBot validates the token against `^\d+:[\w-]{35}$` before any
option is applied, so the "test-token" literal in the two
not-modified tests failed with "telego: invalid token format" and the
go-test and race jobs went red on every run since #6340. Use a
placeholder token that matches the format; the tests now reach the
mock API server, pass with the guard in place and fail without it.
|
||
|
|
8e13f8b172 |
fix(tgbot): suppress 'message not modified' warnings in Telegram edit calls (#6340)
When users click Refresh buttons in the Telegram bot (usage_refresh, client_refresh, ips_refresh, onlines_refresh), editMessageText and editMessageReplyMarkup are always called even when the content has not changed. Telegram returns a 400 "message is not modified" error which was logged as Warning, cluttering the logs on every refresh click. Add isTelegramNotModifiedError helper that detects this specific Telegram API error and logs it at Debug level instead of Warning. |
||
|
|
fc05249e0c |
fix(geofile): verify downloaded geo databases against published digests (#6404)
* fix(geofile): verify downloaded geo databases against published digests UpdateGeofile wrote whatever the three upstreams returned straight into the Xray asset folder with no integrity check. Xray parses these databases when it builds its routing matchers, so a corrupted or substituted file takes the core down at its next start. The panel already does this for the other artifact it downloads: installXray checks the release archive against the SHA-256 published in its .dgst sidecar. The geo databases were the one download that skipped it, even though all three upstreams publish a <asset>.sha256sum beside every .dat. Fetch that sidecar, compare it against the bytes that actually arrived, and stage every file in a temporary folder first, so one bad database installs nothing rather than leaving the core running databases from two releases. Match the digest line by base name rather than by the path it records. Loyalsoldier and runetfreedom write "<hash> geoip.dat" while chocolate4u writes "<hash> release/geoip.dat" -- the path from its own build -- so `sha256sum --check` semantics fail on a perfectly good download. Also skip the Xray restart when every upstream answered 304. The conditional GET was already there, but the restart ran unconditionally and dropped every client connection on a refresh that changed nothing. Assisted-by: Claude Code:claude-opus-5 (mostly) * fix(geofile): pin the release and scope atomicity to one upstream Four corrections to the digest verification, all from review. Pin the release. The asset and its .sha256sum were fetched as two independent requests to releases/latest/download/, so GitHub re-resolved "latest" between them. These upstreams publish several times a day -- 202609022346, 202609030908 and 202609031849 are three tags from one day -- so a release landing mid-batch had release N+1's digest checked against release N's bytes, reporting a healthy upstream as "corrupted or tampered with". Resolve the tag once per upstream from the redirect GitHub already returns, then fetch body and digest from it. Modeling the entry as repo + asset rather than an opaque URL is what makes that possible. Scope atomicity to one upstream. A single failure discarded every verified download, so one transient 5xx from one of three independent repositories threw away four good files and re-downloaded tens of MB on the next attempt. The integrity argument holds for a geoip/geosite pair out of one release; across repositories it buys nothing. Each upstream now installs or aborts on its own and errors are collected, as the code did before this feature. Make the all-or-none test deterministic. It ranged a map, so when the corrupt entry came first the run returned before the good file was ever requested and the assertions held trivially -- a coin flip that would also pass against an implementation installing each file as it verified. Iteration is sorted now, and the test asserts the good file was actually downloaded first. Assert which error. The error table checked only that err != nil, so its two branches could swallow each other's cases; each row now pins the message. Also trims three comment blocks to the two-line limit. Assisted-by: Claude Code:claude-opus-5 (mostly) |
||
|
|
4e355edc15 | fix(sub): skip AmneziaWG JSON entries (#6420) | ||
|
|
0f6e1ae8d7 |
fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds (#6418)
* fix(sub): bind JSON local inbounds to 127.0.0.1 and keep mux.cool off Vision outbounds The JSON subscription's local SOCKS/HTTP inbounds had no listen address, so every client that runs the profile verbatim bound an unauthenticated proxy on 0.0.0.0, and iOS packet-tunnel clients could not reach it at all (Happ iOS: CONNECTED with zero traffic, same symptom as #6379 — on the same device the mixed inbound also worked once bound to 127.0.0.1). Bind both to loopback, which is what every client's own generated config does. The global subJsonMux was also applied to VLESS outbounds carrying xtls-rprx-vision. XTLS flows do not support mux.cool: Xray answers the mux handshake with "common/mux: unexpected network TCP" and the tunnel passes nothing, on every platform (verified with Happ iOS/Android/macOS, V2Box iOS and desktop Xray 26.6.27 against a 3x-ui 3.7.0 box with per-client traffic counters). Skip the mux block whenever the outbound carries a flow. Refs #6379 * fix(sub): keep XUDP settings when disabling TCP mux on Vision outbounds Clearing the whole mux object also dropped xudpConcurrency, xudpProxyUDP443 and any per-host muxParams override. Xray reads those only under mux.enabled, so set concurrency to -1 instead: TCP mux.cool (which XTLS flows reject) is off, XUDP and the UDP/443 policy stay. The test now decodes each outbound into a fresh map. --------- Co-authored-by: Farhan Zare <farhan.zare@openscreen.com> |
||
|
|
ed6bc1d898 |
docs(api): align OpenAPI with runtime contracts (#6409)
Document the cookie-authenticated WebSocket upgrade and its emitted envelopes without exporting pseudo-paths. Align REST response schemas, paged-client filters, and subscription HEAD operations with their runtime implementations, then regenerate frontend and docs artifacts. |
||
|
|
3b5273b1d6 |
fix(amneziawg): reject obfuscation values amneziawg-go's own UAPI rejects
ValidateObfuscation exists, by its own doc comment, so that a bad manual entry cannot break the embedded device's IpcSet. It was not covering enough to do that. Auditing the panel against amneziawg-go v3.1.20260828's full UAPI surface turned up two holes, both confirmed by driving the values through a real IpcSet: S1 = 70000 upstream parses s1-s4 as uint16 S2 = 70000 (device/uapi.go) Jc = -1 jc/jmin/jmax are uint32, so no negatives Jmin/Jmax = -5/-1 Jc = 5000000000 and nothing wider than uint32 I1 = <rand 100> newObfChain hard-fails on an unknown tag I1 = <r 100 ... and on a missing '>' I1 = <> ... and on an empty one All eight passed validation and were then rejected by the device. Only S3 and S4 were bounded, which is why the asymmetry went unnoticed. The inbound saves, the reconcile fails on every tick, and the interface never comes up with a single log line to say so. Bound the five numeric fields to the widths upstream actually parses, and check the I1-I5 chain's <tag value> structure against a tag set mirroring upstream's own obfBuilders map. Each tag's value grammar stays amneziawg-go's to enforce -- that is eight builders across several files, and duplicating them here would drift. So <r abc> still reaches IpcSet, now as the only remaining class rather than one of four. Mirror the same bounds in the Zod schema, next to the max() that s3 and s4 already carried, so the form rejects the value instead of the save doing it. TestValidatedObfuscationAlwaysApplies pins the contract itself: whatever ValidateObfuscation accepts, a real amneziawg-go device must accept too. It covers the specs the new grammar check deliberately allows, not just the ones it rejects, so the allowlist cannot quietly become stricter than upstream. The rest of the audit found no gaps: all 17 settable device keys reach buildUAPIConfig, ServerSettings, the Zod schema and all three .conf emitters. fwmark and persistent_keepalive_interval remain unemitted, both deliberately -- the panel models no fwmark anywhere, and keepAlive is carried client-side where WireGuard puts it. |
||
|
|
be5ee3e0e1 |
fix(amneziawg): three defects in the embedded relay's connection handling
Half-close. Both TCP relays -- RelayTCP into Xray's SOCKS5 inbound and relayTCPForward into a peer's tunnel address -- waited on a single `done` receive and then closed both sides. A client that finished sending and shut down its write side therefore had the connection torn down before the response came back. pipeBothWays now runs both directions to completion and propagates the half-close via CloseWrite (which *net.TCPConn and *gonet.TCPConn both implement), falling back to a full Close for anything that does not. Waiting for both directions reintroduces the risk the old single-receive was implicitly avoiding: a peer that vanishes mid-transfer would pin the pair forever. guardedReader bounds that, but as an idle window rather than a total one -- the deadline is re-armed on every read once armed -- so a slow transfer is never cut, while a silent peer is. Two minutes matches the idle window UDPRelay.pump and portForwardUDPIdleTimeout already use. UDP session retirement. pump's teardown deleted the map entry by key alone, so a session that lost a create race evicted whichever session currently held that source, orphaning a live flow. It now retires only its own entry, and Handle keeps the already-published session when it loses the race. The map is keyed on netip.AddrPort rather than src.String(), matching udpForwardListener next door and dropping one allocation per relayed datagram. SOCKS5 reply decoding. bytesReader had a value receiver, so each Read restarted at the head of the slice, and receive never advanced past a domain-form address because its switch only handled ATYP 0x01 and 0x04 -- a 0x03 reply decoded to a wrong source, port and payload. splitSocks5Addr replaces it: all three address forms, length-checked at every step, with the domain form accepting only a literal. Resolving there would have put a blocking DNS lookup on the receive path, and a datagram's own source is an address already. Unreachable against Xray's own inbound, which always answers with an IP, so this is a latent-bug fix rather than an observed one. |
||
|
|
24cb6bfe1f |
perf(amneziawg): return gVisor's pooled buffers on the embedded data path
Every packet crossing the embedded AmneziaWG interface allocated instead of reusing gVisor's pools, in both directions. stackTun.Write injected each decrypted packet and never called DecRef, so the packet buffer and its chunk were never returned; stackTun.Read copied each view out and never released it. gVisor's own link endpoints settle the ownership question -- loopback.go and sharedmem.go both DecRef immediately after DeliverNetworkPacket, because the injector owns the buffer. AttachUDPHandler compounded it by cloning a packet buffer it then dropped on the floor, on top of a Data().AsRange().ToSlice() that already returns an owned copy, so the clone bought nothing and stranded a pooled buffer plus a cloned view per datagram. Measured with the benchmarks added here: stackTunWrite (upload) 794ns -> 107ns 4 -> 0 allocs stackTunRead (download) 707ns -> 129ns 3 -> 0 allocs UDP datagram, end to end 2.69us -> 1.58us 8 -> 2 allocs The remaining UDP allocation is the ToSlice copy itself. Through a real handshaked tunnel -- both devices in one process over loopback, so ChaCha20-Poly1305 and the UDP syscalls dominate -- it is worth -48% bytes/op and -33% allocs/op, and about +4.8% throughput in each direction (n=18, p<=0.01). On a small VPS, where the allocation pressure is not spread over 24 idle cores, the throughput share should be larger; that part is reasoning, not something measured here. The three regression tests assert allocations per packet rather than timing, since the defect is the pool miss, not the nanoseconds. Thresholds leave room for the extra allocation -race adds. |
||
|
|
d34ec97f62 |
perf(node): push a client edit to every node at once, not one after another
Editing, deleting or detaching a client on a master with several nodes took one node round-trip per node, added end to end. Create and Attach already fanned their per-inbound applies out through fanoutInboundClientAdds, but Update, Delete, Detach and DeleteByEmail's record-less fallback still walked their inbounds in a plain sequential loop, and each iteration blocks on a node RPC (10s timeout, more when a node is slow or has just gone unreachable and the heartbeat has not marked it offline yet). Measured with a node runtime injecting 100ms per RPC, before: nodes=1 create=101ms update=101ms delete=101ms nodes=3 create=102ms update=303ms delete=302ms nodes=5 create=202ms update=504ms delete=504ms after, all three track create: nodes=3 create=102ms update=102ms delete=101ms nodes=5 create=203ms update=203ms delete=203ms Generalize the existing helper into fanoutInboundApplies over an inboundApply list and route the four remaining loops through it, so they inherit the same concurrency cap, per-inbound panic recovery and joined errors. Each caller still builds its payloads sequentially first: fillProtocolDefaults mints the shared credentials on the first inbound and every later one reuses them, so that order has to stay deterministic. Only the applies overlap; their DB work still serializes through the single traffic writer, and the per-inbound mutation lock is unchanged, which is exactly what Create has relied on. Behaviour change: one failing inbound no longer aborts the remaining ones, matching what Create already does. The error still names each failed inbound and the record-level writes are still skipped when any inbound failed. The snapshot merge on the same serialized writer was measured as a second suspect and cleared: ~43ms per node at 500 clients, an order of magnitude below the RPC serialization. |
||
|
|
3ef06b7000 |
docs(readme): refresh all seven READMEs for the current feature set
The READMEs had not moved since 2026-07-07, 341 commits ago, and had drifted far enough to misdescribe the panel: AmneziaWG and MTProto inbounds were missing from the protocol list entirely, the outbound list predated PIA, and the API section still advertised Swagger rather than scoped, optionally expiring tokens. Add the two missing protocols plus a bullet each for what makes them notable — AmneziaWG runs on the embedded userspace netstack, so unlike the DKMS/awg-quick shape it originally shipped with there is nothing to install, and MTProto client edits hot-apply through the mtg-multi management API instead of bouncing the process. Fold the smaller additions into the bullets they belong to (HWID device limits, IP-limit exemptions, renewal cycles, inbound cloning, balancer-to-balancer fallback, geosite/geoip browsing, named subscription formats) and add one for PWA installability. Point documentation at docs.sanaei.dev, which the panel sidebar already links to and which supersedes the wiki, using each README's own locale where the docs site has one (fa/ru/zh). Bump the pinned install example to the current stable tag, note the .sha256 verification install.sh and update.sh now perform, and document XUI_NODE_TOKEN_KEY_FILE / XUI_NODE_TOKEN_KEY, which no markdown in the repo covered. All seven files move together so the language picker keeps pointing at equivalent documents. |
||
|
|
2e81865a02 |
style(node): tighten the comments and probe assertion from the QA pass
Two follow-ups on the preceding fixes, no behaviour change: - The sweep comment in inbound_node.go had grown to a contiguous six-line block, over the two-line maximum. The prefix rationale it carried is already stated by nodeSelectedTagSet itself and by 6f40a51d's message. - The probe cap test asserted only that an error came back, which cannot tell a size rejection from a transport failure or a success=false envelope. It now pins LastError to the decode rejection. Both remain red-first: neutralizing maxProbeBodyBytes still fails the probe test on the new assertion. |
||
|
|
5fc4b9f463 |
fix(node): let a node-reported tag outrank a stale adopted alias
The alias re-application added in
|
||
|
|
ab4229534e |
fix(node): cap the status body the heartbeat probe decodes
probe decoded the node status response with json.NewDecoder(resp.Body) and no size limit. encoding/json buffers the whole value before decoding, so the allocation was dictated by the peer regardless of how few fields the envelope declares — and the heartbeat job probes up to 32 nodes concurrently on a 4s budget with no client-level timeout. The sibling RPC path already caps every node response at 64 MiB (readCappedBody in internal/web/runtime), so this was the one uncapped read of node-controlled data. A status envelope holds a handful of scalars, so the cap here is 1 MiB rather than the RPC figure. The peer is untrusted in the skip and pin TLS modes, and the same decode is reachable from the nodes test and probe endpoints. |
||
|
|
0775fcaad2 |
fix(node): keep an adopted inbound alias across a remote id cache refresh
AdoptInboundAlias maps a central tag onto a node inbound that carries a different name, recording the pairing in both remoteIDByTag and adoptedAliases. refreshRemoteIDs then rebuilt remoteIDByTag from the tags the node reports and nothing else, so the central-tag entry was dropped on the next cache miss for any other tag. After that every op on the adopted inbound failed to resolve, and UpdateInbound falls back to AddInbound — creating a duplicate inbound on the node at the same port. cacheGetTag only recovers an n<id>- prefix flip, never an arbitrary alias, so the pairing could not be rediscovered until a master restart. The rebuild now re-applies adoptedAliases onto the fresh map, which keeps the map the single place a tag is resolved from. |
||
|
|
6f40a51d62 |
fix(node): sweep a selected inbound the node reports without its prefix
In "selected" sync mode the reconcile sweep built its set of managed tags verbatim from node.InboundTags. A panel-created node inbound is stored with an n<id>- prefix (composeInboundTag) and pushed to the node with that prefix stripped (wireInbound), so the tag the node reports never matched the set and the sweep skipped it. The effect is the case the sweep exists for: an operator deletes a node inbound while the node is offline, and the node keeps serving it — and its clients — indefinitely. Only unprefixed tags were unaffected, which is why the existing selected-mode test did not catch it. nodeSelectedTagSet already builds both tag forms for exactly this reason and is used by the snapshot filter; the sweep now uses it too, so the two agree. |
||
|
|
f6bfcfe759 |
refactor(ci): make the Claude workflow review pull requests and nothing else
claude-bot.yml ran three jobs: the pull-request review, an @claude mention responder, and a conflict resolver that committed and pushed to contributor branches. Only the review is wanted, so the other two are gone and the file is renamed to say what is left. Consequences worth knowing: - secrets.CLAUDE_BOT_PAT is no longer referenced by any workflow. It was the only push credential handed to an agent in this repository and can now be deleted from the repository settings. - @claude goes unanswered everywhere. claude-issue-analyst.yml deliberately excludes mentions (!contains(body, '@claude')) so the two jobs would not both reply; with the mention job gone, only `@claude review` on a pull request still reaches anything. Dropping that clause from the analyst would restore mention answering on issues. - The workflow display name changes, so a branch protection rule keyed on "Claude Bot / review" has to become "Claude PR Review / review". The job name, which is what statusCheckRollup reports, is unchanged. The review job itself is byte-identical. The workflow-level permission drops to issues: read, which is all the remaining job needs - it already declares its own. |