mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-24 20:07:13 +00:00
940842495968fc52a38fd0a8f6f8498dd6e70de5
102 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
effcccceac |
feat(amneziawg): add native AmneziaWG protocol support (#6105)
* feat(amneziawg): add native AmneziaWG protocol backend AmneziaWG (WireGuard plus DPI-resistant obfuscation) needs no Docker here — it runs as a genuine kernel interface via awg-quick/awg, managed the same way internal/mtproto manages mtg: one Inbound row is one desired Instance, and a Manager reconciles running interfaces toward the database every 10s (internal/web/job/amneziawg_job.go) plus immediately after a client edit (applyLocalAmneziaWG). Clients reuse model.Client verbatim (the same PrivateKey/PublicKey/ PreSharedKey/AllowedIPs fields WireGuard already uses), so bulk operations, the QR/share-link modal and subscriptions come from the shared inbound infrastructure instead of a parallel implementation. internal/amneziawg owns the obfuscation param generator/validator (ported from coinman-dev/3ax-ui, upgraded to AmneziaWG 2.0's S3/S4 padding and I1 signature packet) and the exec wrapper around awg-quick/awg, with fingerprint-based reconcile (noop / reload-via- syncconf / full restart) mirroring mtproto.Manager so a same-protocol edit doesn't force an unnecessary interface bounce that would drop every peer's connection. Frontend and install.sh's DKMS/awg-tools setup are tracked separately; this is backend-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): add frontend support and fix a Go->Zod generator gap Wires the amneziawg protocol through the panel UI the same way every other protocol is registered: a Zod settings schema (nested {server, clients}, matching the Go JSON exactly), the protocol enum, the inbound-form's per-protocol fields component and its tab-visibility allowlist, the default-settings factory, the client schema dispatcher, and the sniffing-capability exclusion (no Xray inbound exists for amneziawg, same as mtproto). Client key/allowedIPs fields are reused rather than duplicated: since AmneziaWG clients are wire-identical to WireGuard clients (same model.Client fields), ClientFormModal renders one shared field block for both, switching only the visible label by which protocol is active. The private-key input also gets a live public-key sync via a new useEffect, because unlike WireGuard's Xray-native inbound (which re-derives its public key at runtime and never stores one), AmneziaWG's server.publicKey is a real persisted field the Go backend reads directly — free-typing a new private key without this would silently save a mismatched keypair. Adds a downloadable per-client .conf (amneziawgConfig.ts, mirroring wireguardConfig.ts) with the obfuscation lines, and an InboundOption.AwgServer field on the Go side so the config builder gets the full server block in one round trip. Along the way, running tools/openapigen surfaced a real bug: it doesn't flatten anonymously-embedded Go structs the way encoding/json does, so ServerSettings embedding Obfuscation20 produced a Zod schema with a nested `obfuscation20` key that never matches the real wire JSON. Fixed by un-embedding (flat fields + an accessor method) and registering internal/amneziawg in the generator's own package list, which had been silently emitting a dangling schema reference. English and Russian translations are complete; the other 10 locale files still fall back to English for the new keys. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): complete frontend parity for the Inbounds list page The Clients page (form, CRUD, QR/config) already worked from the prior commit; this closes the remaining gap on the Inbounds side and in a couple of protocol allowlists that a plain search for existing wireguard/mtproto handling turned up. lib/xray/inbound-link.ts gets amneziawg-specific link/config builders (genAmneziaWGLink/genAmneziaWGConfig, plus the *s fan-out variants) mirroring the wireguard ones — AmneziaWG has no legacy peers-array to fall back to, so these read settings.clients directly and add the obfuscation lines every client must share with the server. Wired into genInboundLinks generically, and into three consumers that call the wireguard builders directly rather than through that dispatcher: QrCodeModal, InboundInfoModal, and InboundsPage's bulk export. ClientInfoModal, ClientBulkAddModal, and the bulk attach/detach modals each had their own protocol allowlist that needed amneziawg added alongside wireguard/mtproto. Two real gaps surfaced by grepping every remaining 'wireguard' / Protocols.WIREGUARD hit in frontend/src rather than trusting the checklist was exhaustive: - useInbounds.ts's TRACKED_PROTOCOLS gates the deactive/depleted/ expiring/online client counts shown per inbound on the list page; without amneziawg those counts would silently read zero. - inbound-tag.ts is an explicit client-side mirror of the Go backend's port_conflict.go (the file says so itself: "Keep in sync"). It still only special-cased wireguard for UDP, so an amneziawg inbound would have fallen through to the TCP default and disagreed with the backend's own port-conflict math. Also finishes translating the AmneziaWG UI strings into the 11 locale files that were still falling back to English (ar-EG, es-ES, fa-IR, id-ID, ja-JP, pt-BR, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW), matching en-US/ru-RU key-for-key (26 new keys, verified by count in every file). Not run anywhere: npm run typecheck / build. This machine has neither Node nor npm, so nothing here has compiled — reviewed by hand plus brace/paren balance checks and cross-referencing the generated Zod/TS types. Treat this as needing a real typecheck before shipping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(install): note that AmneziaWG kernel module install is still manual Tracked separately (not yet ported into this script) — see coinman-dev/3ax-ui's install_amneziawg for the reference approach (ppa:amnezia/ppa). Also serves as a real, path-filter-matching change to get the previous empty commit's CI trigger to actually fire — release.yml's push trigger is paths-scoped and an empty commit changes no files, so it never matched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): add a button to randomize obfuscation parameters Mirrors the existing key-regenerate button next to the private key field. Client-side randomization matches the ranges/constraints of GenerateObfuscation20's "default" preset (internal/amneziawg/params.go) closely enough for a form suggestion — the user can still hand-edit any field afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(install): auto-install the AmneziaWG DKMS module + amneziawg-tools Ports install_amneziawg from coinman-dev/3ax-ui's install.sh, adapted to this script's broader distro coverage and NONINTERACTIVE convention: - Ubuntu/Debian/Armbian: ppa:amnezia/ppa (primary, tested path), with a reachability pre-check for the Launchpad PPA host — often blocked by hosting providers, especially Russian VPS — so a flaky network skips the feature instead of hanging apt through several retries. - Fedora/RHEL-family, Arch/Manjaro/Parch: best-effort fallback to plain wireguard-tools (+ AUR amneziawg-dkms via yay/paru when available), with a manual-install pointer. - Everything else: manual-install pointer only. Also installs ndppd and persists IPv4/IPv6 forwarding (for the future IPv6/NDP phase, not yet wired into the panel) and adds a Secure Boot warning at the end of the run, since a DKMS-built module is unsigned and won't load while it's enabled — a common trap on cloud VPS images. Never fatal: the panel installs and runs fine either way, an AmneziaWG inbound just won't bring up its tunnel until the module is present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(amneziawg): resolve all 3 real CI failures (typecheck/lint/codegen) Found by checking the fork's Actions tab after the last two pushes — the release build passed (it doesn't run these checks) but the separate CI workflow caught three real issues: - golangci-lint (noctx): every internal/amneziawg/manager.go exec.Command call is now exec.CommandContext with a 30s timeout, so a hung awg-quick/awg invocation can't block the reconcile job indefinitely (mirrors internal/mtproto/process.go's own CommandContext usage). - tsc --noEmit: frontend/src/schemas/client.ts's hand-maintained InboundOptionSchema (used by the useClients hook, separate from the auto-generated one in generated/) never got an awgServer field added when the AmneziaWG frontend work was done — every read of inbound.awgServer.* in amneziawgConfig.ts was typing as {}. Added AwgServerOptionSchema, nested (not flattened like wg*) to match what amneziawgConfig.ts already expects. Also guarded server.publicKey in inbound-link.ts's genAmneziaWGLink against the schema's optional type. - codegen staleness: frontend/public/openapi.json is produced by a Node script (gen:api) this machine can't run; hand-applied the exact diff the CI failure log already showed (amneziawg protocol enum entry, ServerSettings schema, InboundOption.awgServer, one example payload), verified as valid JSON. Also confirmed independently by this run: install_amneziawg (previous commit) installed and loaded the DKMS module successfully on both amd64 and arm64 CI runners. The two "Deploy Smoke Tests" failures are unrelated to this change — this fork has only ever published the dev-latest pre-release, and GitHub's /releases/latest API deliberately excludes pre-releases, so the smoke test's no-argument install path (which resolves "latest") has nothing to find. Not a regression; needs an actual tagged release whenever that's wanted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): Phase 2a — IPv6 support + NDP proxy Adds native dual-stack IPv6 to AmneziaWG inbounds, ported from coinman-dev/3ax-ui's approach: - ServerSettings gets ipv6Enabled/ipv6Subnet/ipv6ExternalInterface; Instance carries the server's own IPv6 address (first host of the subnet) alongside its IPv4 one. - defaultAmneziaWGClients allocates an IPv6 host address per client (second AllowedIPs entry) when the server has IPv6 enabled, reusing allocateWireguardAddress — which needed a real fix along the way: it always suffixed "/32" regardless of address family, which is wrong for an IPv6 host address (needs /128). Now family-aware. - generateServerConfig's PostUp/PostDown gains IPv6 forward-accept rules, proxy_ndp sysctl, and one `ip -6 neigh add/del proxy` entry per enabled peer with an IPv6 address — the lightweight per-client method, not the ndppd-daemon whole-subnet method (not worth the config-file-management complexity at this scale; ndppd itself is still installed by install.sh in case that changes later). - ValidateIPv6Subnet rejects a malformed subnet before save. - Frontend: ipv6Enabled/ipv6Subnet/ipv6ExternalInterface fields on the AmneziaWG inbound form, EN+RU translations, openapi.json/generated/* regenerated (the latter via `go run ./tools/openapigen`, pure Go). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(amneziawg): fill in IPv6 fields missed by the Phase 2a commit Two real gaps the CI caught (both new fields, both my miss): - inbound-defaults.ts's createDefaultAmneziawgInboundSettings() built a server object literal predating ipv6Enabled/ipv6Subnet/ ipv6ExternalInterface — AmneziawgServer's inferred type now requires them (zod .default() fields are non-optional post-parse), so this didn't typecheck at all. - openapi.json's ipv6Enabled property was missing the description the real generator attaches (the Go doc comment covering all three IPv6 fields is attached to the first one) — a one-line diff, but git diff --exit-code doesn't care how small. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): Phase 2b — per-client port-forwarding Admins can now set a per-client ForwardedPorts string (e.g. "80, 443, 8000-8100") that gets DNAT'd + FORWARD'd to that peer's tunnel address via iptables rules in PostUp/PostDown, ported and simplified from coinman-dev/3ax-ui's shared/portfwd. Two decisions worth flagging for future readers: - The iptables --comment tag on each rule is awg-fwd-<fnv32a(email)>, not the raw client email. Email is admin/API-supplied free text that ends up embedded in a shell-executed PostUp/PostDown line; a hash can never carry a shell metacharacter through where raw interpolation could. - The reconcile manager gained a third fingerprint (portFwdFP, next to the existing structural/peers ones). `awg syncconf` only touches the WireGuard peer table — it never re-applies PostUp/PostDown iptables rules — so a port-forward-only change has to force a full awg-quick down+up bounce, same as a structural change, rather than the lighter sync a plain peer add/remove can use. Also fixes a real pre-existing bug found while wiring up IPv6 client allocation in the previous commit's spirit: allocateWireguardAddress always suffixed "/32" regardless of address family, which produced invalid host bits for IPv6 (needs "/128"). ForwardedPorts flows through model.Client -> model.ClientRecord (gorm column wg_forwarded_ports, auto-migrated) -> ToRecord/ToClient/ MergeClientRecord, mirroring the awgServer field's earlier lesson that new fields need checking against a second, hand-maintained persistence-layer struct. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): route a client's traffic through Xray via the Routing page Every enabled AmneziaWG inbound gets its own Xray TPROXY bridge automatically, with no toggle to enable first: a loopback dokodemo-door inbound (sockopt.tproxy) tagged with the AmneziaWG inbound's own real tag, so it's already selectable in the existing Routing page's inbound-tag picker — the same trick the mtproto sidecar's own bridge already relies on (InboundService.GetInboundTags is a plain, protocol-blind SELECT over every inbound row's tag, no dedicated UI plumbing needed). internal/amneziawg's defaultPostUpDown TPROXYs every peer's traffic into that bridge unconditionally; the bridge's port is derived deterministically from the inbound's id (EgressPortForInbound) so the kernel-side reconcile loop and the Xray-config generator never need to negotiate a runtime value between them. injectAmneziawgEgress never generates a routing rule itself — whether a client's traffic goes anywhere beyond Xray's default routing is entirely up to whatever rules the admin adds through the existing Routing UI (pick the AmneziaWG inbound's tag as source, optionally a specific peer's IP via that page's own Source-IP field, and an outbound), exactly the same workflow as routing any other protocol. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(amneziawg): recover orphaned interfaces after an ungraceful exit Two gaps left an AmneziaWG interface stuck outside the manager's control after a crash (kill -9/OOM/panic skips StopAll): - ensureRestart's teardown was gated on the in-memory `exists` map, which is always empty on a fresh process, so a survived interface never got interfaceDown before interfaceUp tried `ip link add` against a name the kernel already had — failing forever and never populating m.ifaces, so traffic accounting silently stopped and the inbound could never be removed. Gate on isInterfaceUp instead, which checks real kernel state rather than this process's own bookkeeping. - An inbound deleted from the database entirely while the panel was down has no entry in `desired` ever again, so it never reaches the per-id cleanup loop in Reconcile (which only walks m.ifaces). Add a one-time sweepOrphansLocked scan of configDir, mirroring mtproto.Manager.sweepOrphansLocked, that tears down and removes any leftover interface/config not in the current desired set. Found by the automated review on MHSanaei/3x-ui#6105 (Finding 1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * i18n(amneziawg): backfill IPv6/obfuscation/port-forwarding keys in 11 locales Only en-US/ru-RU ever got these 9 keys as each AmneziaWG feature landed (the regenerate-obfuscation button, then Phase 2a's IPv6 fields, then Phase 2b's per-client ForwardedPorts) — the other 11 locale files were never backfilled, so i18next has been silently falling back to English for all of them since Phase 1. Cosmetic-only (never broke anything), but now closed for every shipped locale. * fix(amneziawg): resolve 7 Medium findings from the automated PR review Each is independently reproducible; fixed together since one review pass found all of them. - manager.go: the shared "ip rule add fwmark" policy route had no existence check, so it duplicated in "ip rule show" on every interface bounce (which hostRulesFingerprint forces on any client add/remove/ re-IP). Now checked via "ip rule list | grep -q ..." first. (Finding 2) - params.go: ExternalInterface, IPv6ExternalInterface, and subnetIp/ subnetCidr are interpolated unescaped into a shell-executed PostUp/ PostDown line, but only obfuscation and the IPv6 subnet were validated before save. Added ValidateInterfaceName (a strict charset+length pattern) and ValidateSubnetIPv4 (netip.ParsePrefix), wired into normalizeAmneziaWGSettings. (Finding 3) - amneziawg_job.go: IsAwgInstalled() existed but nothing ever called it, so a host without awg/awg-quick (the Docker image, RHEL, Arch, a failed install.sh PPA step) logged a reconcile failure every 10s forever. Now checked once an inbound actually needs it, warning once instead of spamming. (Finding 4) - client_inbound_apply.go: the WireGuard/AmneziaWG credential carry-forward (added so a metadata-only client edit doesn't rotate keys) never covered ForwardedPorts, so a partial edit -- an API call or Telegram-bot toggle that omits the field -- silently wiped a client's port-forwarding spec. Carried forward and written back the same way the key fields already are. (Finding 5) - manager.go: hostRulesFingerprint keyed each peer on its IPv4 address only, and structuralFingerprint omitted IPv6Enabled/IPv6ExternalInterface entirely, so an IPv6-only change could pick the syncconf reload path (which never re-runs PostUp, leaving a stale NDP-proxy entry) or be a complete no-op. Both fingerprints now cover the IPv6 fields. (Finding 6) - port_conflict.go: the AmneziaWG egress bridge (injectAmneziawgEgress) binds 127.0.0.1:63100+id with no collision check anywhere, since it isn't a database row the ordinary port-conflict query can see -- same blind spot the reserved Xray API port already has its own check for. Added the equivalent check for the AmneziaWG bridge port. (Finding 7) - install.sh: install_amneziawg ran unconditionally for every install/ update, building a DKMS kernel module and enabling host-wide IPv4/IPv6 forwarding whether or not the feature is ever used. Gated behind a new should_install_amneziawg (XUI_INSTALL_AMNEZIAWG=true/false, or an interactive y/N prompt defaulting to no). Also replaced the deprecated apt-key adv with a dedicated keyring + signed-by= on the Debian branch, and guarded its sources.list appends against duplication on a retried install. (Finding 8) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(amneziawg): make the Xray TPROXY bridge a per-inbound opt-in Addresses Finding 10 from the automated PR review: an always-on TPROXY bridge makes every AmneziaWG tunnel hard-depend on Xray being up (all traffic, including DNS, drops whenever Xray restarts), and forces a full awg-quick down+up bounce on any client add/remove/re-IP, permanently losing the syncconf fast path. Adds ServerSettings.RouteThroughXray (off by default): - defaultPostUpDown only emits the TPROXY/policy-route rules when it's on; a plain AmneziaWG tunnel now has zero Xray dependency out of the box. - structuralFingerprint covers it (toggling it changes whether PostUp/ PostDown contain any TPROXY rules at all -- structural, not a per-peer host-rule). hostRulesFingerprint's IPv4 tracking is now itself conditional on RouteThroughXray (and IPv6 tracking on IPv6Enabled), so an instance that never uses either keeps the syncconf fast path for a plain peer re-IP. - injectAmneziawgEgress only creates a bridge for inbounds that opted in; checkAmneziawgEgressConflict (the Finding-7 fix) now parses each candidate through InstanceFromInbound so a non-routed inbound's port is correctly never treated as reserved. - New inbound-level Switch in the AmneziaWG form; the actual outbound decision is still made entirely through the panel's stock Routing page, same as before -- only whether the bridge exists at all is now a choice. Translation keys added to all 13 locales in the same commit this time, not backfilled later (see Finding 9's lesson). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(amneziawg): resolve 4 Low findings from the automated PR review - manager.go: serverAddress assumed subnetIp always ends in ".0"; a base like "10.8.1.5" was used verbatim as the server's own address, eventually colliding with peer allocation (which starts at .2 upward). Now derives the first host of the actual subnetIp/subnetCidr network via netip, matching serverAddressV6's own approach. A /32 base (no host bits at all) is still used as-is. (Finding 12, partial -- the /16 pool-widening half of this finding only exists on the upstream-pr/amneziawg branch's merged client_wireguard.go, not here; handled separately on that branch.) - manager.go: ensureLocked carried the previous per-peer traffic counters (`last`) forward even through a full restart, but awg-quick down+up resets the kernel's own counters to zero -- the next CollectTraffic computed a large negative delta (clamped to 0), silently discarding real traffic. Extracted the decision into nextTrafficBaseline: only a reload (syncconf) preserves the baseline. (Finding 13) - portfwd.go: exported ForwardedPortsInclude; inbound_amneziawg.go's new checkForwardedPortsConflict uses it to reject, at save time, a client's forwardedPorts that would DNAT the panel's own port or another enabled inbound's port to the tunnel client -- portForwardLines has no destination restriction, so this collision was previously silent. Wired into both the single-client update path and the add-client path (client_inbound_apply.go), plus normalizeAmneziaWGSettings for the whole-inbound save path. (Finding 14) - inbound.go: InboundOption.AwgServer sent the whole ServerSettings struct including PrivateKey to GetInboundOptions callers -- a shared, admin-wide dropdown-filling endpoint the frontend's own AwgServerOptionSchema never reads that field from. Redacted it before assigning. (Finding 11) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(amneziawg): don't widen the peer address pool past AmneziaWG's own subnet Completes Finding 12 from the automated PR review (the serverAddress half of this finding was already fixed on main and cherry-picked here). This half is specific to this branch: allocateWireguardAddress's /16 pool-widening fallback is an independent addition from upstream's own main that this branch inherited during the cherry-pick rebase -- it doesn't exist on the fork's own main at all, so this fix can't be cherry-picked the normal way and is committed directly here. Widening is safe for WireGuard's own Xray-native inbound (AllowedIPs isn't tied to a strict kernel interface subnet), but AmneziaWG's kernel interface Address is exactly the configured subnet -- an address allocated from the containing /16 once the /24 fills up would be silently unroutable. allocateWireguardAddress now takes an explicit allowWidening bool: WireGuard's own caller passes true (unchanged behavior), AmneziaWG's passes false (fails loudly on exhaustion instead). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(docker): note that AmneziaWG doesn't work in this image Investigated: the image is Alpine-based, and AmneziaWG's own packaging (DKMS module + amneziawg-tools) doesn't target Alpine/musl at all -- unlike the Debian/Ubuntu/Fedora/Arch paths install.sh already handles, there's no package to apk add even with full host network/capabilities. The panel already degrades gracefully (IsAwgInstalled() logs one warning instead of retrying forever), so no code change is needed -- just made the reason explicit at the point where a user would reach for cap_add/ network_mode to try to work around it. * fix(sub): include amneziawg inbounds in subscription links getInboundsBySubId's SQL protocol allowlist never had 'amneziawg' added, so every AmneziaWG client was silently excluded from all three subscription formats (plain/individual links, JSON, Clash) and from the Telegram bot's QR/individual-link buttons, which fetch through the same path. genAmneziaWGLink itself was already fully implemented and already wired into GetLink's dispatch switch -- it just never got a chance to run. Same bug shape as the earlier TRACKED_PROTOCOLS frontend gap: a hardcoded protocol list one entry short. Found while investigating whether the Telegram bot needed AmneziaWG- specific client-management code -- it doesn't (the bot itself is fully protocol-agnostic), but this is the actual root cause of "can't share an AmneziaWG client's config via the bot." Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(inbound): enforce node-eligibility server-side, not just in the UI Investigated multi-node interaction with AmneziaWG: the master's own reconcile (DesiredAmneziaWGInstances) and Xray config generation (injectAmneziawgEgress, the GenXrayInboundConfig protocol skip) all correctly filter on NodeID IS NULL, so a node-assigned AmneziaWG (or MTProto) inbound would never be managed by the master. But nothing stopped one from being created that way: NODE_ELIGIBLE_PROTOCOLS (frontend/src/pages/inbounds/form/InboundFormModal.tsx) only hides the node picker client-side -- a direct API call could set nodeId on an AmneziaWG inbound, which every node then reconciles as an ordinary local inbound (nodes run the identical binary, full cron suite included), leaving it running unmanaged and untracked by the master's own AmneziaWG bookkeeping. Added isNodeEligibleProtocol (inbound_protocol.go), mirroring the frontend's allowlist, and enforced it in both AddInbound (the actually exploitable path -- nodeId comes straight from the request) and UpdateInbound (defense in depth; NodeID is already restored from the stored row there before this check, so it mainly guards against a protocol change on an existing node-hosted inbound). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(amneziawg): allow TPROXY-marked traffic through a default-deny INPUT chain TPROXY never rewrites a packet's own destination address, only the routing decision. A default-deny firewall whose INPUT chain sanity-checks "is this destination actually local" (UFW's ufw-not-local, via addrtype --dst-type LOCAL, is a concrete example) silently drops the redirected packet before Xray's socket ever sees it -- RouteThroughXray looked fully configured (TPROXY rule present and counting, Xray listening with IP_TRANSPARENT set) yet every peer's traffic vanished with no trace on either side. Adds an idempotent, never-torn-down "iptables -I INPUT 1 -m mark --mark <fwmark> -j ACCEPT" alongside the existing shared policy route, so this works regardless of which firewall manager owns the rest of the INPUT chain. * fix(frontend): give AmneziaWG the same UDP tag and its own tag color The Inbounds list only special-cased isWireguard/isHysteria for the "UDP" network badge, so an AmneziaWG row showed just the bare protocol tag with no transport badge next to it. Added the missing isAmneziawg flag (mirrors isWireguard exactly) and wired it into the same branch. Client-row protocol-color maps in ClientsPage/HostList had no amneziawg entry, silently falling back to grey -- ClientInfoModal already had amneziawg: 'yellow' from earlier work, these two just never got it. * feat(logs): show which AmneziaWG client an access-log line belongs to The dokodemo-door TPROXY bridge every AmneziaWG peer's traffic is routed through has no per-user identity, so Xray's own access log never carries an "email:" token for these lines -- the Access Logs modal showed a blank Email column for every in-*-udp row, even though every other protocol's rows show the client normally. The peer's decapsulated tunnel IP does survive as the log's "from" address, and that IP deterministically maps to exactly one configured peer. Builds a "<inbound tag>|<ip>" -> email index from the same AmneziaWG inbounds already parsed elsewhere (amneziawg.InstanceFromInbound), and fills in Email from it whenever the raw log line didn't have one. * fix(amneziawg): enable sniffing on the TPROXY bridge Domain-based Routing rules could never match RouteThroughXray traffic: an AmneziaWG peer resolves DNS itself, through the tunnel, before ever sending a packet, so the decapsulated traffic TPROXY hands to the bridge is already a bare destination IP with no domain name attached at the network layer. Every other inbound recovers this via sniffing (confirmed working for the stock wireguard inbound, which does have it configured); the bridge never got a sniffing block at all, so only tag/IP/network-based rules could ever match it -- any domain rule above it in the list was silently unreachable. * docs: add an AmneziaWG config page and list it as a supported protocol Closes the PR checklist gap: the feature shipped with zero mention on the docs site. Mirrors reality.mdx's structure (key settings, setup steps, config excerpt) and notes the Docker/multi-node/Telegram-bot caveats the PR itself is honest about not having confirmed. * fix: address the fresh review round on PR #6105 (8 findings) 1. hostRulesFingerprint didn't account for ForwardedPorts when RouteThroughXray was off, so re-IPing a peer with port-forwarding configured left stale DNAT rules pointing at an address the next peer could be handed. 2. Server/client config values (keys, email, I1) were never validated for control characters before being written into the generated .conf; a newline could smuggle a PostUp hook into awg-quick's parser. Added ValidateConfigValue at save time and a sanitizeConfigValue backstop at render time. 3. checkForwardedPortsConflict didn't scope to node_id IS NULL, so a port used only on a different node produced a false collision; also hoisted the panel-port/inbounds lookup out of the per-client loop (portConflictContext) so N clients cost one query, not N. 4. PostDown commands were ";"-joined and abort on the first failure; appendOrTrue makes teardown best-effort so an external firewall flush can't leave DNAT rules to accumulate across bounces. 5. The "ip rule list | grep -q" existence check could SIGPIPE under pipefail and re-add a duplicate rule; switched to grep -c >/dev/null. 6. Ported the vpn:// share-link format (base64url of the plain .conf text, matching the real AmneziaVPN app) onto this branch -- it had only ever landed on our own fork's main, so this PR branch was still on the old amneziawg://+query-params scheme our own docs no longer described. Also corrected the docs' install.sh claim (opt-in/ interactive, not automatic) and stale pre-opt-in comments in route_egress.go. 7. install.sh: Arch's ndppd install used pacman -Syu (full system upgrade) instead of -Sy like every other call in the script; and should_install_amneziawg re-prompted on every `x-ui update` even when awg was already installed. 8. CollectTraffic could clobber a concurrent restart's freshly-reset (empty) traffic baseline with stale pre-restart counters, since getPeerStats runs lock-free; now checks pointer identity before writing back. sweepOrphansLocked permanently disabled itself on a transient os.ReadDir failure instead of allowing a retry. go build/vet/test and frontend typecheck/lint/build/vitest all pass. * fix(install.sh): check the live sysctl value, not sysctl.conf text Reviewer feedback (cherts, PR #6105): grepping /etc/sysctl.conf for the setting name is unreliable -- many distros split sysctl config across /etc/sysctl.d/*.conf, and /etc/sysctl.conf can be a symlink into that directory, so the check can miss an already-active setting (harmless duplicate append) or match a disabled/commented line (forwarding silently stays off). Query the live value via `sysctl -n` instead, which is accurate regardless of which file set it. Applied the same fix to both the IPv6 and IPv4 checks for consistency. * fix: update inbound_amneziawg.go to the split buildInboundForLocalRuntime Same fork-only-file blind spot as the one caught on our own main after the 3.6.0 sync: upstream split buildRuntimeInboundForAPI into buildInboundForNodePush / buildInboundForLocalRuntime (part of the node-sync client-deletion fix, |
||
|
|
da01b7637d |
feat(sub): client-side balancers for the JSON subscription (#6243)
* feat(sub): add SubBalancer model and migration Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up. * feat(sub): add SubBalancer service List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart. * feat(sub): add SubBalancer API controller and routes GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it. * feat(sub): emit client-side balancers in JSON subscription For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present. * test(sub): cover SubBalancer service and JSON output Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup. * feat(sub): add sub-balancers i18n keys pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales. * feat(sub): add SubBalancer schema and API queries Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key. * feat(sub): add subscription balancers settings tab SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled. * test(sub): add SubBalancer form modal test Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled). * feat(sub): register sub-balancers in API docs and OpenAPI Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it. * docs: sync openapi.json with frontend docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API. * docs: add subscription balancers API reference Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations. * feat(sub): accept roundRobin balancer strategy Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist. * feat(sub): add roundRobin strategy label pages.settings.subBalancers.strategyRoundRobin added to all 13 locales. * feat(sub): expose roundRobin in balancer form Zod strategy enum, form modal label key, and table strategy colour for roundRobin. * docs(sub): list roundRobin in strategy description The create/update strategy param description now mentions roundRobin alongside the other three. * feat(sub): add subJsonObservatory setting Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask. * feat(sub): wire observatory config through sub controller WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction. * feat(sub): emit observatory conditionally with configurable probes burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override. * feat(sub): add subJsonObservatory to AllSetting model Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string. * feat(sub): add balancer observatory config card New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults. * fix(sub): hide save/restart header on sub-balancers tab Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly. * feat(sub): add observatory config i18n keys pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales. * feat(sub): regenerate openapi for subJsonObservatory openapigen picks up the new AllSetting field; openapi.json synced into docs. * feat(sub): add observatory tab to sub-balancers Mirrors the Xray Balancers page: two tabs (Balancers + Observatory). Wires allSetting/updateSetting into the tab and adds tabBalancers / tabObservatory labels to all locales. The page Save header is shown again on this tab so the observatory config can be saved. * refactor(sub): drop observatory tab from sub-formats Now that the observatory config lives under sub-balancers, remove the duplicate tab plus its state and defaults from sub-formats. * fix(sub): add missing inboundsCount i18n key The sub-balancers table rendered the raw key path in the Inbounds column because pages.settings.subBalancers.inboundsCount was not defined. Added it to all 13 locales. * test(sub): pin disabled-inbound exclusion from balancer The balancer builds its members from the subscriber's already-filtered entry set, so an inbound disabled for that user can never surface as a member. Adds tests for both shapes (one of several disabled, and the only selected one disabled). * fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback Three coupled defects on the balancer observatory surface, flagged in PR review: - The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this. - Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value. - routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking. Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it. * fix(sub-balancer): localize controller toasts and reject malformed ids Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success. * fix(sub-balancer): enforce remark length cap server-side The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap. * fix(sub-balancer): exclude mtproto from balancer member picker SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment. * docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope - Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only). - Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX. - Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror. * style(model): trim SubBalancer comment to 2-line cap CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3. * fix(sub-balancer): parse enabled explicitly and preserve it on partial update parseSubBalancerForm treated any non-"false" value as true (so "bogus" silently enabled) and always overwrote Enabled on update, so a PATCH that omitted the toggle reset a disabled balancer back to enabled. Parse the field with strconv.ParseBool and return *bool: absent means "no change" on update and "true" on create; a malformed value is rejected as 400. Update keeps the stored Enabled when the pointer is nil. * fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds DelInbound cascaded hosts but left the deleted inbound id in every sub_balancers.InboundIds, so the balancer kept emitting a member no subscriber could resolve — a dangling outbound tag with no proxy behind it. Strip the id inside the existing delete transaction (same shape as the hosts cascade, #5648); with the last member gone the balancer stops emitting. * fix(sub-balancer): return not-found when deleting a missing balancer Delete returned the gorm result error only, which is nil when no row matched, so the controller reported success:true for an id that never existed — a stale UI row looked like a clean delete. Check RowsAffected and return a not-found error on 0 so the toast reflects reality. * style(sub): shorten leastPing/leastLoad observatory comments The observatory-emission guard comment and its test comment ran a few lines long; trim them to a couple of lines each without dropping the invariant that leastPing/leastLoad require a burst observatory. * fix(sub): validate observatory setting instead of silently dropping it SetObservatoryConfig applied whatever survived json.Unmarshal with no checks, so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even unparseable JSON was either silently applied or silently ignored. Validate each field: parse durations with time.ParseDuration, require http(s) URLs for destination/connectivity, and log a warning naming the field and the bad value on every fallback — including the unmarshal error, which was a quiet return. Bad values now keep the built-in defaults instead of leaking into the emitted burstObservatory. * fix(sub): deduplicate burst-observatory defaults across Go and frontend The burst-observatory ping defaults lived in three places that had drifted: Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity pre-check URL). Align them to one set: https probe destination, sampling 2, and empty connectivity (skip the direct pre-check). The settings tab now parses the stored JSON through PingConfigSchema and seeds its default from DEFAULT_BURST_OBSERVATORY instead of carrying its own literal. * refactor(sub): extract proxy outbounds once before the balancer loop buildBalancerConfig unmarshalled every inbound document and re-extracted its first outbound on each balancer, so with B balancers and N inbound docs the same document was parsed B*N times. Pull each doc's proxy outbound in a single pre-pass over the entries and cache it per entry; buildBalancerConfig now clones the cached map before retagging, so one parse serves every balancer. Output is byte-for-byte unchanged. * fix(sub): form balancer member tags from the inbound protocol, not tcp→vless balancerTransport derived the bal-N tag suffix from the outbound's transport network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was mislabelled "vless" in every client config — the tag lied about the proxy type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless, bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and dedup suffix are unchanged. Update the existing tag assertions and add a vmess case that fails under the old mapping. * fix(sub-balancer): default strategy to random in the create form The create-balancer form seeded strategy to 'leastLoad', but the service validate() defaults an empty strategy to 'random' and the API docs say the default is 'random' — so a freshly opened form showed leastLoad while saving without touching the field silently stored random. Align the form default to 'random' so what the admin sees is what gets persisted. * feat(api-docs): document the SubBalancer response schema The five sub-balancer endpoints carried no responseSchema, so the API docs page rendered them without a typed example. Add example: tags to every SubBalancer field, allow the struct through openapigen, and point the list (responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/. * style(sub-balancer): drop whitespace-only separator lines, add final newline subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as separators between statements and had no trailing newline. Replace them with clean empty blank lines and end each file with a newline. * fix(i18n): translate sub-balancer toasts and observatory note The sub-balancer toast messages (list/create/update/delete/invalidId) and the observatory note were left in English across 11 non-English locales (ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key in the subBalancers block was already translated. Translate them to match the meaning and terminology of the surrounding keys in each file; the JSON structure and keys are unchanged. * fix(sub-balancer): hide disabled inbounds from the member picker The picker offered every protocol-eligible inbound regardless of its enable flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled member is therefore dropped from every subscriber's entries, and when it was the balancer's only member the balancer document silently stops being emitted — with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll MembersDisabled already documents that backend behavior. Filter the way the sibling client picker has since #5645: hide disabled inbounds, but keep one that is already selected so editing an existing balancer cannot silently drop a member. Drop the `?? []` on the useWatch result so the new useMemo dependency stays referentially stable. * style(sub): trim the balancerMemberSuffix comment to the 2-line cap Comment blocks in committed Go are capped at 2 lines; the name already carries what the function picks, so keep only the why. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
bd6a6aba43 |
feat(pia): add PIA login-and-add WireGuard outbounds (#6272)
* feat(pia): add login-and-add WireGuard outbounds (#2) * fix(pia): keep PIA outbounds identifiable after the editor strips hostname The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch. |
||
|
|
af3e6c11b6 |
docs(api): document WireGuard and mtproto secret generation on clients/add (#6282)
* docs(api): document WireGuard and mtproto secret generation on clients/add The POST /panel/api/clients/add summary enumerated the protocols whose secrets the server fills in, and that list stopped being complete when WireGuard gained per-client keys and mtproto gained a FakeTLS secret. Read literally it says the endpoint is unusable for WireGuard without a hand-made keypair and address, while defaultWireguardClients in fact generates the keypair, derives the public key from a supplied private one, and allocates a free /32. Rather than extend an enumeration that goes stale on every new protocol, the summary now states the rule alone and the per-protocol detail moves into the operation description - a field Endpoint already declares and build-openapi.mjs already maps, but that no endpoint used until now. Swagger UI in the panel and the docs site both render it. The attach operation gets the rule added for #5785 that nothing documented: a client already carrying allowedIPs brings them into the new inbound instead of being given a fresh address, and is rejected when another client of that inbound holds it. Closes #6276 * docs(api): correct the clients/add generation rules flagged in review Three claims in the new description did not hold: Shadowsocks does not keep every supplied password. fillProtocolDefaults regenerates it when validShadowsocksClientKey rejects it, which on a 2022-blake3-* inbound means any password that does not base64-decode to 16 or 32 bytes - the call still returns success, so the caller has to read the client back to notice. Split off from Trojan and spelled out. The UUID is not always fresh: re-adding an email that already exists, with the stored subId, reuses the stored id, password, auth and secret so the identity stays in sync across its inbounds. That branch was documented nowhere. The mtproto secret falls back to www.cloudflare.com when the inbound carries no fakeTlsDomain. |
||
|
|
1250fbb734 |
feat(clients): allow removing a single HWID device (#6265)
* feat(clients): allow removing a single HWID device Only "list" and "clear all" existed for registered HWID devices, so freeing one slot under a client's HWID limit meant clearing every device and waiting for the ones you kept to re-register. Adds a per-device delete: DELETE /panel/api/clients/hwids/:email/:id, scoped to the client's own sub_id (device ids are a global auto-increment, not per-subID, so this also prevents deleting another client's device), plus a delete button next to each device in the existing HWID modal. Addresses MHSanaei/3x-ui#6245. * feat(clients): surface HWID limit + device log in the client info card Mirrors the existing IP-limit row/eye-icon-modal pattern that's already in this card. The HWID devices modal reuses the same list/clear-all/per-device-delete UI already shipped for the edit form's own HWID modal, so a device can be removed without opening the edit form at all. * i18n: add HWID single-delete strings to all 13 locales deleteHwid/deleteHwidConfirm/hwidDeleted were only added to en-US and ru-RU in the previous commit; backfilling the other 11 locales the project's own translation set covers. * fix(clients): address automated review of HWID single-delete PR - ClientInfoModal: use the existing dateLabel() helper (Jalali-aware) for HWID first/last-seen instead of a raw dayjs format, matching every other timestamp in the same modal. - Add okText/cancelText to the delete-device Popconfirm in both ClientInfoModal and ClientFormModal so all 13 locales get a translated confirm dialog instead of Antd's English default. - deleteHwid controller: stop reusing the success toast key on both error paths, which rendered a red "Update successful" toast on a real (not just theoretical) failure such as a stale HWID modal. - Trim DeleteClientHwid's doc comment to the repo's 2-line cap and correct it: deletion is scoped by sub_id, which can span more than one ClientRecord, not strictly "this client only". - Add TestDeleteClientHwid covering cross-sub_id id rejection, unknown id rejection, and a real successful delete. * chore: retrigger CI (previous run stuck installing Playwright Chromium) * fix(clients): address the arbiter review on the HWID single-delete PR - Extract the HWID device list into a shared frontend/src/lib/clients/ hwid-log.ts type/normalizer, a shared useClientHwids hook, and a shared ClientHwidListModal component, mirroring the existing IP-log pattern. ClientInfoModal and ClientFormModal both render the same component now, so the two copies can no longer drift the way they already had (different date formatting, different tag styles). - Add a Popconfirm to the HWID "Clear all" button (previously unconfirmed, unlike the per-device delete right next to it) — closes the confirm/no-confirm asymmetry the review flagged as the main risk. - Sync docs/public/openapi.json with the two hwids paths and regenerate clients.mdx. Scoped to just those two paths rather than a full copy from frontend/public/openapi.json: the docs copy is far enough behind on unrelated paths (a host-group API rename) that a full sync breaks the Next.js build on locale pages referencing the old shape — out of scope for this PR. * fix(clients): trim HWID list comment blocks to 2 lines Repo convention caps comment blocks at 2 lines; both were 1 line over. * chore: retrigger CI build (arm64) and build (armv6) failed on a transient Go module proxy network error (INTERNAL_ERROR stream reset), unrelated to this PR's changes. |
||
|
|
abd320994a |
Add per-client external link controls (#5650)
* Add enable toggle for external client links * Document external link enable API fields * Extend external client link metadata * Fix external subscription cache status updates * fix(sub): address the review on per-client external link controls Blocking: the expiry filter dropped legacy rows. expiry_time was added without a default, so AutoMigrate makes it nullable and backfills NULL, and `expiry_time = 0 OR expiry_time > ?` is false for NULL under three-valued logic — every external link written before the upgrade vanished from all subscriptions. Add `default:0` on expiry_time and last_fetch_at, make the predicate NULL-tolerant, and backfill the NULLs a pre-fix build could already have written. Rework fetch-status recording. It ran inside the singleflight in-flight window, so every goroutine parked on the shared fetch waited for a DB write to commit on the public, unauthenticated subscription path — and because it was keyed on the row id, waiters and cache hits recorded nothing, leaving rows that lost the race stuck on "Not fetched yet" forever. fetchSubscriptionLinks now reports whether it did the network fetch and expandEntry records afterwards, off the serving path, keyed on kind+value so every row sharing the URL is stamped by the one fetch. Keying on value also closes the recycled-rowid hazard: saves delete and re-insert rows, and SQLite reuses rowids, so an in-flight write could land on an unrelated client's row. The write no longer discards its error either. Drop the inert id round-trip. The panel never sent it, and the byId branch was guarded by the exact kind+value equality that byKindValue already keys on, so it could not change an outcome. Matching on kind+value alone is what actually preserves fetch status across saves. Reject a negative expiryTime instead of storing a row that is silently invisible in every subscription — elsewhere a negative expiryTime means "a duration from first use", so an API caller reusing that convention got no error and no links. Drop the ~50 lines of .client-form-* / .client-inbounds-field CSS that no component renders; it is leftover from the WireGuard PR this one was split from. i18n: reuse the already-translated pages.inbounds.leaveBlankToNeverExpire instead of shipping an English duplicate under pages.clients, and translate namePrefix, lastFetchAt, lastFetchError and neverFetched into all 12 non-English locales. Cover the persistence path that had no test: the fetch-status writer over a real DB against a failing then a succeeding server, a cache hit writing nothing, and the negative-expiry rejection. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
708a69acde |
fix(reality): make the REALITY target check usable on a private network (#6242)
* fix(reality): make the REALITY target check usable on a private network The probe dials through netsafe.SSRFGuardedDialContext, so a fronting service reachable only inside the deployment (a Docker service name, a LAN address) always failed with "blocked private/internal address": the inbound itself works, because the guard sits in the probe path only, so the panel reported a red verdict on a healthy configuration. Instead of a panel-wide setting that lifts the guard for good, the guard is now lifted per probe and only after the operator confirms the local-network warning in a modal; the verdict keeps privateTarget set, so a passing local check stays a warning rather than a green success. The probe also sent the target host as SNI. Clients dial the target but send a name from serverNames, so a fronting proxy answered with its default certificate — a Traefik front reached as "traefik" reported "certificate is valid for <hash>.traefik.default, not traefik" on a deployment whose clients get a valid chain. The panel now sends the first configured serverName as SNI and the certificate is verified against it; empty serverNames keeps the old fallback. The reported target stays the dialled address, so a passing check no longer rewrites the target field with the SNI host. The result panel reports what was actually seen: the SNI used, the certificate subject/issuer and its expiry stay visible when the chain is untrusted (with "Not trusted" appended) instead of being replaced by that verdict alone. Certificate names are copied into the SNI field only when the chain verified — the names on a proxy's default certificate would otherwise become the SNI of the next check. The bulk/CIDR scanner keeps the guard unconditionally: honouring the opt-in there would turn it into an internal network scanner. * fix(reality): recover from a stale SNI and report a refused address reliably Review follow-up on the REALITY target check. The probe sends the stored serverNames as SNI, and the panel only wrote names back when the whole chain verified, so switching Target while the SNI field still held the previous target's names failed every rescan: the new target's real names came back from the probe but were discarded with the verdict. The certificate is now checked in two steps — chain first, then the name — and a trusted chain presented for other names is enough for the panel to offer those names, so the next scan passes. Picking a row in the bulk scanner replaces the names outright, since keeping the previous target's SNI leaves a REALITY config that cannot work. SSRFGuardedDialContext kept the refusal only in lastErr, so on a dual-stack name a refused private address followed by a failing public one lost the sentinel and the panel silently skipped the confirmation. The refusal is now tracked separately and reported alongside the last dial error. Honouring the opt-in is logged with the target and the resolved address, since it bypasses the SSRF guard on an authenticated endpoint. The read-only SNI row in the result is labelled "SNI used" so it no longer collides with the SNI field below it, and the comment blocks are back within the 2-line limit. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
5c7ca5b579 |
feat(clients): give each client its own traffic reset cycle (#6240)
* feat(clients): give each client its own traffic reset cycle Traffic reset is configured on the inbound, so every client sharing an inbound resets together. An operator running a monthly 1000GB plan and a weekly 200GB plan side by side has to press Reset Traffic by hand. Clients now carry the same trafficReset / trafficResetDay pair the inbound already has, with the same vocabulary and the same monthly due-day rule, and PeriodicTrafficResetJob makes a second pass over the clients whose own cycle matches the period it is running for. A client that leaves the field at never behaves exactly as before: only its inbound's schedule can reset it. The fields live on ClientRecord as well as in the inbound settings JSON, so an ordinary edit does not write the cycle back as empty, and an unknown period is rejected rather than coerced, since a coerced value would read as configured while no job would ever select the client. Cron expressions and a custom post-reset quota from the issue are left out: both are separate decisions, and neither has an inbound-level counterpart to stay consistent with. * fix(clients): make the per-client reset cycle editable and safe to run Review found three things wrong with the first cut, one of them mine and worse than the bug it replaced. The cycle could only be set at creation. ClientService.Update writes the columns directly only for a client with no inbounds; the normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so an edit updated the settings JSON while the clients column kept the old value and the job kept applying the old cycle. The earlier test passed because it asserted the value survived an unrelated edit, which it did precisely because nothing ever wrote it. Replaced with a test that changes the cycle and switches it off again. Avoiding the re-enable that ResetTrafficByEmail performs was wrong. Depletion disables clients.enable and the settings JSON as well as client_traffics.enable, so lifting only the quota gate left a depleted client out of the generated config with zeroed counters, which no longer match the depleted predicate: locked out permanently. The rule is now about cause, not state — a client the quota switched off is restored, one disabled below its quota was switched off by hand and is skipped. The bulk path also bypassed node propagation and the MTProto sidecar quota that ResetTrafficByEmail handles, so it silently did nothing on node-backed inbounds. Dropped in favour of the integrated path, whose needRestart is now collected and turned into a single SetToNeedRestart. Also adds the AutoMigrate NULL backfill, guards the merge so a stale node snapshot cannot erase a configured cycle, validates the bulk-create and import paths, normalizes the day the way the inbound path does, marks the fields omitempty so existing clients match the published contract, and shares one TRAFFIC_RESETS tuple between the three forms. * fix(clients): validate renew fields on the bulk and import paths too BulkCreate and ImportClients insert client records without going through Create, so the resetDay/resetMax checks added with the calendar renewal (#6239) and the renew cap (#6238) never ran there. An API caller could store resetDay 45 or a negative resetMax, values the renewal query then mishandles silently. Mirror Create's validation on both batch paths, next to the trafficReset check they already carry. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
b8903fadf4 |
feat(clients): renew on a calendar day instead of a rolling interval (#6239)
* feat(clients): renew on a calendar day instead of a rolling interval Auto-renew advances the expiry by a fixed number of milliseconds, so a client set to 30 days drifts against the calendar: renewing on 31 January lands on 2 March, and by the end of the year the billing day has wandered a fortnight from where the operator's own plan resets. Add a per-client renewal day. When set, the expiry steps whole calendar months at midnight in the panel's time zone. A month too short for the chosen day renews on its last day and the following month returns to the chosen one, so the 31st does not decay into the 28th permanently. Zero keeps the interval mode, so existing clients are untouched. The interval branch now also refuses a zero step. It is unreachable while the selection filter holds, but that loop runs on the single traffic writer, and a zero interval there would hang every panel mutation behind it. * fix(clients): persist the calendar renewal day on the client record resetDay lived only in the inbound settings JSON and client_traffics, so every path that rebuilds a client from the clients table wrote it back as zero: an ordinary edit, an attach to a second inbound, a traffic reset on a disabled client. Calendar mode turned itself off during normal use and the operator only found out a month later. Adds reset_day to ClientRecord and threads it through ToRecord, ToClient, applyClientRecordMerge and the record update map, so the value survives the round trip. The clients page filter and ClientSlim now recognise the mode, nodeClientRenewed classifies a calendar renewal as a renewal, the node snapshot merge carries reset_day, and the service layer rejects a day outside 0-31 rather than clamping it silently. Also renames the label keys to renewOnDay to keep them apart from the existing renewDays, translates them and the new RESET_DAY subscription placeholder in all 13 locales, adds the field to the bulk-add modal, and drops the stray internal/web/dist/.gitkeep build stub. * fix(clients): let the billing day be changed after creation ClientService.Update writes the record columns directly only for a client with no inbounds. The normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so moving a client from the 20th to the 5th updated the inbound settings JSON while clients.reset_day kept the old value and the renewal kept using it. The existing test did not catch it: it asserted the day survived an unrelated edit, and it survived precisely because nothing on that path ever wrote it. TestClientEditChangesTheBillingDay moves the day and then switches calendar mode off again; removing the record write turns it red. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
d6472740dc |
feat(limitip): let operators exempt trusted addresses from the IP limit (#6230)
* feat(limitip): let operators exempt trusted addresses from the IP limit Behind a shared address — an office gateway, a campus NAT, a residential carrier — every user looks like the same client. One of them trips the IP limit and the address is disconnected and handed to fail2ban, taking the others with it. Today the only way out is editing jail.d by hand, which an update overwrites. Add an allowlist setting of addresses and networks. A matching address is neither banned nor counted towards the limit: counting it would still cut the shared network the entry exists to protect. Entries are validated on save rather than skipped at scan time — a typo would otherwise leave the address unprotected until someone noticed the bans. * fix(limitip): keep each doc comment on its function and one grammar for the list Three review follow-ups. loadAllowlist landed between hasLimitIp's doc comment and hasLimitIp itself, so godoc showed one function's rationale above another's body; it now sits after that function with its own comment. The parser advertised semicolons and whitespace as separators while the settings validator accepts commas only, making those forms unreachable through the panel and the API — a promise the software never keeps. Both sides now read the same comma-separated grammar. The dist stub was a build artifact and does not belong in the tree. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. * chore(i18n): translate the IP limit allowlist strings into the remaining locales Ten locales carried the English source text verbatim; only ru-RU and uk-UA were translated. The i18n dead-key test only checks that a key exists in every file, so an untranslated value passes it silently. Wording follows each locale's existing terms: the ipLimit noun already in the file, and the comma-separated IP/CIDR phrasing from trustedProxyCidrsDesc. * refactor(limitip): share one IP/CIDR list validator and read the allowlist only when enforcing The allowlist check in CheckValid was a line-for-line copy of the trusted-proxy loop directly above it. Both now call one helper, each passing its own message, so the two lists cannot drift apart. Run() read the allowlist on every 10s scan, including the majority of panels where no client carries an IP limit and the value is discarded. It is now read only once enforcement is known to apply. CheckValid had no test for either list. The new one pins that a malformed entry is rejected and that each list still names itself in the error, which is what the shared helper could otherwise break. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
e940f30bb8 |
feat(clients): cap how many times a client may auto-renew (#6238)
* feat(clients): cap how many times a client may auto-renew Auto-renew today runs forever: a prepaid or fixed-term client keeps being handed new periods until an operator remembers to switch it off. There is no way to say "renew this three times, then let it lapse". Add a per-client maximum. Zero keeps today's behaviour, so nothing changes for anyone who does not set one. When the count is reached the client is simply left to expire, like any client without auto-renew. Catching up several missed periods spends one allowance per period. A client that was away for three cycles must not receive three of them free of the cap, and the catch-up stops at the last period the cap paid for rather than jumping to the present. * fix(clients): persist the auto-renew cap and stop the capped churn resetMax lived only in the inbound settings JSON and client_traffics, so every path that rebuilds a client from the clients table wrote it back as zero. The edit dialog showed 0 for a capped client, and saving an unrelated comment change lifted the cap; an attach or a traffic reset did the same with no operator action at all. Adds reset_max to ClientRecord and threads it through ToRecord, ToClient, applyClientRecordMerge, the record update map and ClientSlim, so the cap survives the round trip. When the cap truncates a catch-up the client is still expired, but the renewal side effects fired anyway: counters were zeroed for periods it can never use, and it was enabled and pushed to xray only for disableInvalidClients to undo both in the same transaction. Those are now skipped when the new expiry has not reached the present. Also makes any non-positive resetMax mean unlimited instead of silently meaning "never renew again", rejects a negative one at the service layer, surfaces renewals used against allowed in the client info modal so the operator can see what to raise, adds the field to the bulk-add modal, translates the labels in all 13 locales, and drops the stray internal/web/dist/.gitkeep build stub. * fix(clients): let the renewal cap be changed after creation ClientService.Update writes the record columns directly only for a client with no inbounds. The normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so raising a cap from 3 to 6 — the natural action when a customer buys another block of periods — updated the inbound settings JSON while clients.reset_max kept the old value and the renewal query kept enforcing it. The existing test did not catch it: it asserted the cap survived an unrelated edit, and it survived precisely because nothing on that path ever wrote it. TestClientEditChangesTheRenewalCap raises the cap and then lifts it entirely; removing the record write turns it red. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
930a0ed59d |
feat(inbound): DisableFlow — opt an inbound out of auto XTLS Vision (#5689) (#5698)
* feat(inbound): add DisableFlow to opt an inbound out of auto XTLS Vision Adds an inbound-level DisableFlow flag so operators can suppress automatic xtls-rprx-vision injection on a specific inbound even when its transport is flow-capable — e.g. a tunneled/CDN-fronted XHTTP+vlessenc inbound where Vision is not wanted, while keeping it on the same client's Reality inbounds. When set, the inbound reports tlsFlowCapable=false, the write path clamps each attached client's flow to empty (so flow_override stores ""), and share links/subscriptions never carry the flow for it. The flag is panel-only metadata and is never sent to xray. Closes part of #5689. * feat(inbound): DisableFlow toggle in the inbound form (frontend) Wire the DisableFlow field through the form schema + adapters and add a VLESS-gated switch in the inbound form, plus en-US strings. tsc --noEmit and eslint pass. * fix(inbound): honor DisableFlow in all emitters + on toggle; regen OpenAPI Addresses review on #5690: - Clash (clash_service.go) and JSON (json_service.go) subscription emitters now also skip the flow for a DisableFlow inbound — previously only the raw share-link path was gated, so those two still advertised it (blocking 1). - UpdateInbound now strips any flow already stored on a DisableFlow inbound's clients (settings.clients[].flow + client_inbounds.flow_override) so xray and the subscription agree; otherwise toggling DisableFlow on an existing Vision client left xray expecting a flow the client no longer sends. - Regenerated the OpenAPI + zod/types/examples artifacts for the new field and added an example tag (blocking 2; make gen-check is clean). - Added Clash + JSON DisableFlow suppression tests alongside the raw-link one. * fix(inbound): make DisableFlow durable, clamp on create, guard live config Addresses the review + completeness audit on #5690: - UpdateInbound now persists inbound.DisableFlow onto the saved row. It was only read to branch strip-vs-restore, so toggling the flag on an existing inbound never stuck and MigrationRestoreVisionFlow re-injected the flow — the exact #5689 path (editing a multi-inbound client's inbound) self-reverted. - DBInbound (frontend) declares + initializes disableFlow so ObjectUtil .cloneProps carries the API value through; the edit Switch previously always read false and re-saving silently reverted the opt-out. - AddInbound strips client flow (settings + parsed clients) when DisableFlow is set, so a created-disabled inbound never persists a flow xray would expect. - GetXrayConfig forces flow="" for DisableFlow inbounds (VLESS + Trojan) as defense-in-depth, keeping the live config and the subscription in agreement. - genTrojanLink share link honors DisableFlow too. - Drop the dead explicit flow_override clear in UpdateInbound (SyncInbound rebuilds it from the stripped settings). - Clear disableFlow in the inbound form when switching to a non-VLESS protocol. - Add disableFlow/disableFlowHelp to the remaining 12 locales. Tests: stripClientFlows unit cases; DB-backed AddInbound clamp; UpdateInbound persist+strip+resist-restore regression (fails without the persist fix); frontend DBInbound + adapter round-trip (fails without the model field). * style(inbound): drop // line comments per repo CLAUDE.md The DisableFlow work followed the surrounding code's commenting style; the repo CLAUDE.md forbids // line comments in committed Go/TS. Remove the comments I added (Go + frontend + tests) and regenerate OpenAPI/schemas, which drops the generated field descriptions sourced from the Go doc comments. No behavior change; full go test (service+sub, CGO) + frontend typecheck/vitest green; golangci-lint clean on the changed files. * fix(runtime): propagate disableFlow to nodes Preserve the inbound DisableFlow flag when syncing inbounds across nodes and when recreating central records from remote traffic snapshots. This keeps multi-node deployments from reintroducing VLESS Vision flow in node configs and share links, and updates the related tests to cover the wired field and VLESS JSON generation. |
||
|
|
b4e4478699 |
feat(inbounds): add a narrow endpoint for subscription sort order (#6179)
* feat(inbounds): add a narrow endpoint for subscription sort order Changing an inbound's position in subscription output currently goes through /update/:id, which takes a whole inbound: the caller has to send settings and the entire client list back, and whatever it read before the edit is what gets written. Two people reordering and editing clients in the same inbound race on one blob, and the reorder wins by overwriting. Mirror the existing /setEnable/:id shape. The handler takes only the index and the service reads the stored inbound, so nothing in the request can reach the settings JSON. Node-owned inbounds are marked dirty in the same transaction and pushed through the existing runtime update. * fix(nodes): scope sub sort index updates --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
d7698ec7aa |
feat(xray): browse geosite/geoip categories from routing rules (#6165)
* feat(xray): browse geosite/geoip categories from routing rules Routing rules made you type category names from memory: nothing showed which categories a database actually contains, what is inside one, or whether a name resolves at all — a typo only surfaced when Xray refused the config. The panel now reads Xray's .dat databases itself and exposes them over four endpoints: databases in the asset folder, a database's categories, one page of a category's rules, and validation of the tokens already in a rule. The reader walks the protobuf wire format directly rather than decoding into Go structs, because a 10 MB geosite.dat holds well over a million domains and materialising them costs ~284 MB where streaming costs ~19 MB. Only the category index is cached, entry pages are scanned on demand, and scans are serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB. A database's type is decided by its contents, not its file name, since custom .dat files are named freely. In the rule form, the source-IP, IP and domain fields gain a database button opening the browser: search over categories, a preview of what a category holds, and a multi-select that merges into the field. Plain domains, CIDRs and categories the panel does not know are left untouched; categories already present come back ticked, and unticking one removes it from the rule. * fix(xray): read geo databases through os.Root and match codes verbatim CodeQL flagged the database read as a path built from a user-supplied value, and it was right about the shape of it. The file name arrives in a request; resolve() rejects traversal and stats the file through an os.Root, but the read itself went through a joined path with os.ReadFile. That left the symlink defence incomplete: the stat could pass while the read followed a link planted — or swapped in — afterwards. Reads now go through the same root, so a request-supplied name never becomes a path this code resolves on its own, and the size limit is applied to the opened file rather than to a separate stat of it. Lookup no longer trims the category code either. It backs the routing-token validator, and the core matches codes verbatim: "geosite: cn" will not start Xray, so repairing that space here hid exactly the typo the validator exists to report. * fix(xray): address review findings on the geo category browser Asset folder. The browser read config.GetBinFolderPath() unconditionally, but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the bin folder (ensureXrayAssetLocation). On an install pointing at a shared asset directory the panel listed an empty folder and reported perfectly valid geosite:/geoip: tokens as missing — the validator warning about a correct config. The directory is now resolved with the core's precedence. Paging. Serving one page read and rescanned the whole database, so walking category-ads-all re-read it per page. The index now records each category's byte range and a page reads only that record through the os.Root handle, with the current category's records held for the duration of a paging session. Profiling that also showed the real cost was not the read but the slice of payload pointers built per call — a category holds a hundred thousand of them — so records are now walked with a callback instead. Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB. Cached failures. Any error from reading a file was latched under the file's size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as damaged until it changed on disk. Only deterministic failures are cached. Wrong kind. A geoip: token typed into a domain field parsed as a plain domain and was waved through, though the core cannot resolve it as one. It is now reported, with its own reason and wording. Frontend. The category filter fed the query key on every keystroke, so each character triggered a request that re-scanned the database; it is debounced now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus these three fields on a validation error again. A failed validation shows that it failed instead of rendering the same empty state as "no issues". Also drops an unreachable branch in the token-count guard and corrects the categories endpoint docs, where limit is unbounded by default. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com> |
||
|
|
694ad6deae |
feat(sub): add per-client subscription HWID limits (#5802)
* feat(sub): add per-client subscription HWID limits * fix(sub): address HWID review on shared subId and bulk create * fix(sub): store HWID devices by sub_id and drop anchor client workaround * fix(sub): restore UA auto-detect and HTML page routing in subs() The cherry-pick of the HWID gate onto main's refactored SUBController had dropped main's UA-based format auto-detection and sub-page handling from subs(). Restore those branches, slotting enforceHwid after the HTML page and before format detection so the gate only applies to machine-readable subscription bodies. Also adapt tests to main's options-struct constructor and to the ClientService.Update signature extended with limitHwid. * fix(frontend): drop axios from HttpUtil.delete The bulk-delete rework's committed version still referenced axios, which this file no longer imports, breaking typecheck in CI. Use the httpRequest wrapper like the other verbs. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
7ecd88b9e3 |
fix(nodes): apply a rotated master mTLS certificate without restarting the panel (#6194)
* fix(mtls): invalidate pooled clients after credential rotation * fix(mtls): make connection reload read-only --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
1230559e69 |
feat(api): scoped, optionally expiring API tokens (#6201)
* security(api): add scoped expiring API tokens * security(api): make scoped token lifecycle enforceable --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
2217213e9f |
feat(sub): expose last subscription fetch time (#6217)
* feat(sub): expose last subscription fetch time Record successful GET subscription fetches per client and surface the timestamp in the client API and UI. * fix(frontend): include last subscription fetch in client traffic * Update sub_fetch_test.go --------- Co-authored-by: Hermes Agent <hermes-agent@localhost> |
||
|
|
f52c3c4837 |
perf(clients): make the clients page scale to large panels
The clients page was slow on panels with many clients for two independent reasons: the server rebuilt the whole picture on every request, and the browser rebuilt the whole table on every poll. Server side, ListPaged loaded every client row, every client_inbounds link and every client_traffics row into Go memory, then filtered, sorted and paginated in a loop -- on a request the page repeats every five seconds. Every predicate now runs in SQL and only the requested page's ids are hydrated, so the cost tracks the page size rather than the client count. Measured on SQLite with a realistic status mix: the default view at 100k clients goes from 1,072ms to 64ms. Behaviour is preserved deliberately in the subtle places -- the cross-panel global-traffic overlay is folded into the same used-bytes expression the predicates and sort use, LIKE wildcards are escaped so a search for "a_b" stays literal, and the two different tiebreak rules the in-memory comparator had are reproduced per sort key. The summary's per-bucket email lists are capped at 200 with exact counters beside them. They only back hover popovers, but shipping every match made the response grow with the panel: at 100k clients it carried ~42k emails, and the page revalidated all of them through a strict Zod parse every five seconds. The popover now shows a "+N" chip for the remainder. Browser side, the page fired three sequential list requests per load and threw the first two away: the query went out before the persisted sort was applied, and again before the configured page size was known -- 0 meaning "one long page" is indistinguishable from "not loaded yet". The page size is now derived rather than mirrored through an effect, and the previous visit's value is remembered so the single request goes out at mount instead of queueing behind /setting/defaultSettings. Then the per-poll work. Reading isFetching made it a tracked property, so the refetch interval notified twice per cycle and re-rendered the page even when structural sharing left the data identical. Xray reports a traffic row per client whether or not it moved bytes, so the speed map was mostly zeros and was replaced wholesale every push; zero rows are now dropped and an unchanged result returns the previous object, which lets React bail out instead of re-rendering. The five Tooltip-wrapped buttons and the inbound chips per row do not depend on traffic at all and are now memoised, keyed on the email because a push replaces the row object of every client whose counters moved. antd's hashed:false drops 3,311 :where(.css-<hash>) wrappers and 29% of the generated stylesheet, and a pinned cssVar key stops each of the eleven page-level ConfigProviders minting its own token scope. Two callers that only need the mutations, GroupsPage and ClientBulkAddModal, no longer start the list query -- the groups page had been polling the full paged list every five seconds for data it never renders. |
||
|
|
87ebcc7a6f |
feat(ui): tag settings that sit at their shipped default value (#6128)
* feat(ui): tag settings that sit at their shipped default value A field showing 2096 reads identically whether the install never set it or the operator saved 2096 — newcomers cannot tell which knobs they have touched, and after the cleared-port fix (#6121) a port can never visually return to an unset state. Add a small grey tag next to numeric settings whose current value equals the shipped default. The tag deliberately compares values, not provenance: a stored 2096 and a fallback 2096 behave identically, so they read identically, and the tag reacts live as the user types. The backing endpoint filters defaultValueMap through the AllSetting field set, so per-install material (secret, panelGuid, node mTLS keys) and redacted credential fields never leave the server; a test pins that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): keep the default tag out of the accessible name, pin the defaults contract From review, in order of severity: The badge was rendered inside the element whose id feeds the control's aria-labelledby, so a visible tag changed every field's accessible name ('Panel Port Default'). The title text now carries the id on its own span and the badge sits beside it. The same default values live in three places: the Go defaultValueMap, the frontend AllSetting class, and the tag's verdict. A new contract test parses the Go map's string literals and asserts every shared key matches the AllSetting class default through the tag's own comparison — and on first run it caught two real drifts (tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but 'login.attempt,cpu.high' on the server), now aligned. matchesFactoryDefault no longer coerces blank or unparsable defaults (Number('') is 0; a junk string is not false). The Go tests are table-driven t.Run subtests and gained the structural invariant: every returned key is an AllSetting json tag outside the credential deny-list. The service doc comment now describes the projection mechanism instead of overclaiming; the i18n key is re-indented and placed at the head of pages.settings in all 13 locales; the fetch falls back to {} when validation fails; and smtpPort gets the tag so plain numeric settings-list fields are covered uniformly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
33f72f8f4a |
fix(api): authenticate GET /panel/api/openapi.json + pin the route registry to the router (#6133)
* test(web): pin the endpoints.ts registry to the actual Gin routes endpoints.ts is a hand-maintained registry and nothing checked it against the router: an omitted API route silently vanishes from the generated OpenAPI docs, and an entry for a removed route documents an endpoint that 404s. Two new tests construct the real router against a throwaway DB and diff the /panel/api surface both ways. The check found one gap on arrival: GET /panel/api/openapi.json — the endpoint that serves the docs — was itself undocumented. Registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api)+test: authenticate openapi.json, fold the two route-contract tests into one Three things from the review, in severity order. The bot found that GET /panel/api/openapi.json was registered on the base-path group one line before the /panel/api group installs checkAPIAuth, so Gin's snapshot of the parent chain meant the whole admin API surface plus build version was fetchable without a session — while this very PR was about to document it as auth-required. Move the registration inside the authed api group. Verified: unauthenticated it now 404s exactly like server/status (was 200), and a logged-in session still serves it 200, so the docs page is unaffected. The existing api_docs_test.go already checked the forward direction by regex-scanning controller source against a hand-maintained per-file path switch — which is why it missed this web.go-registered route, and whose fall-through default silently mis-paths any unlisted controller file. The new router-based test is a strict superset, so fold in the extra surface it guarded (/login, /logout, /csrf-token, /getTwoFactorEnable, /ws) and delete the old test rather than run two. Harden the endpoints.ts parser: pair each method with the next path sequentially instead of a brace-crossing regex, and fail loudly when the parsed count doesn't match the declared method fields. Construct the server once across both subtests, cancel it, and restore the previous global on cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
17e6b5a460 | inbounds: allow custom monthly traffic reset days (#6071) | ||
|
|
6af2995930 |
feat(api): add GET endpoint to look up clients by Telegram ID (#5945)
* feat(api): add GET endpoint to look up clients by Telegram ID
GET /panel/api/clients/getByTgId/:tgId returns all clients matching the given Telegram user ID. tgId is not unique, so the response is an array of {client, inboundIds, externalLinks, usedTraffic} objects.
* fix: guard tgId=0 sentinel, index tg_id, deduplicate enrichment in getByTgId
Three issues from the code review on the new GET /panel/api/clients/getByTgId/:tgId
endpoint: the lookup did not short-circuit tgId <= 0 (this codebase's sentinel
for 'no Telegram ID'), had no index on clients.tg_id causing a full table scan
on every call, and duplicated the per-record enrichment (inbound IDs, external
links, effective flow, traffic) identically between get and getByTgId.
- Reject tgId <= 0 in GetRecordsByTgId with a clear error, matching the
'0 = none' convention used elsewhere in the codebase.
- Add index:idx_clients_tg_id to ClientRecord.TgID (struct tag + idempotent
startup migration for existing databases).
- Extract buildClientPayload helper used by both get and getByTgId.
- Update client_lookup_test.go to verify sentinel rejection instead of
expecting tgId=0 to be a valid lookup.
* refactor(api): move Telegram client lookup under /get/tgId/:tgId
Nest the Telegram-ID lookup beside the email lookup as /get/tgId/:tgId
instead of the flat /getByTgId/:tgId, so both client fetch routes share the
/get prefix. Gin resolves the static tgId segment ahead of the :email
wildcard, so /get/:email keeps matching plain email lookups, including a
literal 'tgId' email. The endpoint is unreleased, so no compatibility
concern.
|
||
|
|
8f49327efb |
feat(sub): allow identity tokens on every subscription link (#5935)
Keep usage tokens first-link-only while adding an opt-in setting for repeating EMAIL and USERNAME in subscription-body remarks. Co-authored-by: x06579 <x06579@ai-dashboard> |
||
|
|
cd674c8d4f |
feat(sub): expose live online status and add ?format=info endpoint
Custom subscription templates only received the lastOnline timestamp, so template authors had to fake an online indicator by comparing it against the current time, and the page was a one-shot server render with no way to refresh usage without reloading the whole HTML. The template context (and window.__SUB_PAGE_DATA__) now carries isOnline, computed from the panel's own online-client tracking (local xray plus remote nodes) at render time. The subscription URL also answers ?format=info with the page view-model as JSON — minus the links, with emails deduplicated — so templates can poll live status cheaply. The shared view-model construction moved into buildSubPageData/subPageContext so the HTML page, the SPA payload and the info JSON cannot drift apart. Also documents the previously injected but undocumented announce template variable. |
||
|
|
941c6116a9 |
chore(openapi): regenerate schemas with int64 formats on node fields
Output of make gen: the generator now stamps format int64 on the node status schema's 64-bit integer fields (timestamps, net counters, uptime), syncing the committed OpenAPI doc and generated schemas with the Go structs. |
||
|
|
c77608bc47 |
fix(nodes): make node API tokens write-only (#5613)
* fix(nodes): make node API tokens write-only * fix(nodes): keep token optional on edit for write-only API tokens NodeView no longer returns apiToken, so the edit form must consume hasApiToken and not require re-entering the token. Relaxes the form validation on edit, adds a keep-current placeholder, and adds the i18n key to all 13 locales. |
||
|
|
2b1308ca29 |
feat(notifications): add a consecutive-failure threshold for outbound.down alerts (#5968)
Problem: a flaky outbound produces hundreds of false-positive "outbound down" notifications overnight — each fires the moment xray's observatory reports a single failed probe, and the next successful probe fires an "up". applyObservatory forwarded every raw alive:true->false transition straight to EventOutboundDown; xray's observatory has effectively no hysteresis, and nothing on the panel side debounced it (the email/Telegram subscribers are pure formatters). Fix: debounce per outbound. outbound.down now fires only after outboundDownThreshold consecutive FAILED probes (new setting, default 3); outbound.up fires immediately on the first successful probe and only when a down was actually notified. The threshold gates the event itself, so email and Telegram share one knob (exposed next to the outbound.down toggle). The streak counts genuinely new probes (last_try_time advancing), not sampler polls — the sampler runs every 2s but the observatory re-probes per its probeInterval, so counting samples would trip the threshold instantly. outboundDownThreshold=1 reproduces the legacy notify-on-first-failure behaviour. Tuning the observatory's probe interval/timeout is not a workaround: those probes also drive the load balancer's outbound selection, so loosening them to quiet notifications would slow real failover away from a genuinely dead outbound. Notifications don't need observatory-grade latency, so the tolerance belongs at the notification layer, leaving the observatory (and balancer) untouched. Adds TestApplyObservatoryDebounce covering the threshold, probe-vs-sample counting, single-blip suppression and the legacy path. Co-authored-by: Yuriy Khachaturian <y.khachaturian@souzmult.ru> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
129f50d92a |
feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)
* feat(settings): add subscription format controls
* feat(sub): auto-detect subscription formats
* fix(xray): validate balancer regexes before save
* Revert "fix(xray): validate balancer regexes before save"
This reverts commit
|
||
|
|
1cfd7b49b0 |
fix(email): build an RFC 5322 message with a proper From address and name (#5941)
The notification/test email carried only From/To/Subject/MIME headers, and
the From header was the raw SMTP username. Two problems:
- When the SMTP login is not a bare email address (common with relays and
submission services), the From header has no valid address and strict
receivers reject the message — e.g. Gmail returns "550-5.7.1 ... Messages
missing a valid address in From: header".
- There was no Date (mandatory per RFC 5322 section 3.6) and no Message-ID,
which also raises spam score.
Add smtpFrom (sender address) and smtpFromName (display name) settings and
assemble the message with net/mail: a name-addr From ("Name" <addr>), a
Date, a Message-ID, and an RFC 2047 encoded Subject, in a deterministic
header order. From falls back to the username when smtpFrom is empty, so
existing setups keep working. Wire the settings through the model, the SMTP
send and test paths, the Email settings UI, and all 13 locale files;
regenerate the Zod/OpenAPI artifacts.
Validate smtpFrom in AllSetting.CheckValid (reject anything net/mail cannot
parse), which surfaces a bad address at configuration time and prevents CRLF
header injection; strip CR/LF in buildMessage as defense in depth. Add
buildMessage and CheckValid tests.
|
||
|
|
e211a5cc47 |
feat(frontend): hide redundant migration download on sqlite panels
Back Up's .db now restores directly into a PostgreSQL panel, so the SQLite-side Download Migration row only duplicated it; the row stays on PostgreSQL panels where it is the only PG-to-SQLite path. Restore accepts .dump and .db everywhere, the backup modal texts describe the accepted formats in all locales, and the orphaned migrationDownloadDesc key is removed. |
||
|
|
30b611614b |
feat: import SQLite migration dumps through the PostgreSQL panel restore
The SQLite panel's Download Migration produces a portable SQL text dump advertised as seeding a PostgreSQL panel, but the PostgreSQL Restore only accepted pg_dump custom archives, so the migration file was rejected with 'Invalid file' even though the upload picker asked for .dump. importDB now sniffs the upload header: PGDMP archives keep the pg_restore path, while raw SQLite databases (.db) and SQL text migration dumps are rebuilt, integrity-checked, and copied into PostgreSQL with the same MigrateData engine as 'x-ui migrate-db --dsn'. The restore picker accepts .dump/.db on PostgreSQL and the backup modal texts describe the accepted formats in every locale. |
||
|
|
2c95e29297 |
fix(api): preserve 64-bit integer schema formats (#5908)
Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com> |
||
|
|
42690e1b8c |
feat(hosts): bulk-add multiple hosts to multiple inbounds (#5677)
* feat(hosts): bulk-add multiple hosts to multiple inbounds Allow users to select multiple inbound IDs and enter multiple host addresses (with optional per-host port override) in a single form submission. - Add BulkAddHostReq entity and POST /panel/api/hosts/bulk/add endpoint - Add AddHostsBulk service with GORM transaction safety - Add parseHostAndPort helper (IPv4, bracketed/bracketless IPv6, port) - Update HostFormModal to multi-select inbounds and tag-input hosts - Wire bulkCreate mutation in HostsPage with existing-host suggestions - Register endpoint in api-docs/endpoints.ts and regenerate OpenAPI/Zod * feat(hosts): group override records by group_id and support group editing * fix: import Popover in HostList * fix: use messageApi in HostFormModal * fix(hosts): resolve 4 bugs found in host-group code review - fix(schema): allow empty hosts array in BulkAddHostSchema so users can save a host without an address (inherits inbound endpoint). The old .min(1) was never enforced at runtime since the schema is only used for type inference, but the type was incorrect. - fix(service): validate new inbound IDs in UpdateHostGroup before deleting old rows, matching the same check already present in AddHostGroup. Prevents orphaned host rows when an invalid inbound ID is supplied on edit. - fix(service): replace full-table scan in GetHostsByInbound with two targeted queries (DISTINCT group_id WHERE inbound_id=?, then WHERE group_id IN ?) to avoid loading every host in the DB. - fix(mutations): remove unused createMut / create export from useHostMutations. The /hosts/add endpoint is identical to /hosts/bulk/add; only bulkCreate is used by the UI. * fix(hosts): address code review feedback (optimize bulk inserts, add validation tests, and remove comments) * fix(fmt): apply gofumpt formatting to model.go and db.go The previous merge commit incorrectly applied gofmt (tab-aligned) to these files. The repository's golangci config requires gofumpt+goimports which produces space-aligned struct fields. This commit restores the correct gofumpt formatting that matches upstream/main. * chore(frontend): regenerate API schemas and update lockfile * fix * refactor(hosts): dedupe host-group service and tidy frontend AddHostGroup and UpdateHostGroup shared an identical ~35-field model.Host construction and hand-rolled transaction boilerplate (tx.Begin plus a committed flag plus a deferred recover/rollback). Extract buildHostRows, validateInboundsExist and formatHostAddr, and run every mutation through db.Transaction. groupHosts collapses its duplicated address/port formatting and create/append fork into one path using slices.Contains. Behavior-preserving: host.go drops ~90 lines with the existing service/controller tests green. Frontend: drop the Partial union and two as-casts in HostsPage.onSave (the modal always passes a full BulkAddHostValues), and remove the movable index map in HostList in favor of the table render index arg. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
bc309ed9f8 |
refactor(frontend): replace axios with the native Fetch API
Drop the axios (and qs) dependencies in favor of a native fetch wrapper.
axios only ever handled same-origin JSON/form calls, a CSRF header, a 401
redirect, and a 403-retry, all of which the platform now provides directly.
- New src/api/http-init.ts (replaces axios-init.ts) reimplements the
request/response interceptors on fetch: base-path prefixing,
X-Requested-With, same-origin credentials, the CSRF token on unsafe
methods, a single 403 retry with token refresh, and the 401
redirect-and-latch. A small encodeForm() reproduces qs's
arrayFormat:'repeat' encoding, so the request wire format is unchanged.
- HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg
envelope, so the ~49 API call sites are untouched. HttpOptions is now
hand-rolled instead of extending AxiosRequestConfig.
- PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get
with { silent, timeout }.
- Add tests for the fetch core (CSRF header, form/JSON/FormData bodies,
base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for
HttpUtil's envelope unwrap / toast / error mapping; this logic was
previously untested.
- Remove the vendor-axios manualChunks branch and the qs type shim, and
reword stale "axios" mentions in docs and route comments.
|
||
|
|
43500a5470 |
feat(mtproto): per-client ad-tags, management-API auth, and record secret sync
Catch the panel up to the mtg-multi README (v1.14.0): - Each client can now carry its own 32-hex advertising tag overriding the inbound-level one. The tag lives on the client (settings JSON is the source of truth, clients.ad_tag is the UI projection), is rendered into the fork's [secret-ad-tags] section for active secrets only (mtg rejects a config whose override names an unknown secret), is pushed per entry through PUT /secrets, and is part of the reload fingerprint so a tag edit hot-applies without dropping connections. - The loopback management API can replace the whole secret set, so every mtg process now gets a random per-process api-token; the manager sends it as a bearer token on PUT /secrets and GET /stats and reuses it across config rewrites, because mtg reads the token only at startup. - Malformed tags are rejected at every save path and additionally dropped in InstanceFromInbound: one bad tag would otherwise fail the whole generated config and take every client of the inbound down with it. - SyncInbound never copied a re-keyed mtproto secret into the canonical clients table, so the clients page and subscription links kept serving the old secret, which mtg then rejects. It is now guarded-copied like the other credentials. |
||
|
|
a1ca43d869 |
chore(gen): refresh generated schemas after Client.Secret comment drop
Commit
|
||
|
|
d97bd8643e |
feat(mtproto): adopt dolonet/mtg-multi and make MTProto inbounds multi-client
Replace the upstream 9seconds/mtg sidecar with the dolonet/mtg-multi fork so a single MTProto inbound can serve many per-user secrets. Each panel client is now one named FakeTLS secret in the fork's [secrets] section: clients are first-class (attach/detach, limits, expiry, per-client tg:// links) exactly like every other protocol, mirroring the WireGuard multi-client model. Per-client traffic and online status come from the fork's /stats JSON API (its Prometheus output has no per-user label), fed into the existing email-keyed client_traffics accumulator; an optional throttle caps concurrent connections. A one-time seeder converts each legacy single-secret inbound into a one-client inbound. The fork ships only linux/darwin amd64/arm64 binaries but is pure Go, so provisioning builds it from source for every supported platform (release.yml, DockerInit.sh) while keeping the panel-expected mtg-<os>-<arch> filename and the 'run' verb, so process.go is untouched. Also fixes a pre-existing update.sh gap that never renamed the mtg binary for armv6/armv7 updates. |
||
|
|
052dd85ad3 |
feat(clients): hide disabled inbounds in the client form selector
The attach-inbounds select in the client add/edit modal listed every inbound, so panels with many disabled inbounds had to scroll past dead entries. InboundOption now carries the inbound's enable flag and the form drops disabled inbounds from the options, keeping ones the client is already attached to so edit mode still renders existing assignments. Closes #5645 |
||
|
|
f90e4a6962 |
fix(panel): use the hosting node address for WireGuard client configs (#5679)
* fix(panel): use the hosting node address for WireGuard client configs The clients page rendered a node-managed WireGuard inbound's config with the master panel's host in Endpoint instead of the hosting node's address, so the copied/QR config pointed at the wrong server. The subscription path already resolves this via resolveInboundAddress; the UI generator did not. Expose the share-host resolution inputs (node address, listen, share-address strategy/address) on InboundOption and route buildWireguardClientConfig through the same canonical resolver the inbounds-page share links use, extracted as resolveShareHost. This also brings local inbounds with a shareable listen or a listen/custom share strategy into parity with the subscription Endpoint; the common listen=0.0.0.0 case still falls back to the panel host. * fix(frontend): keep a raw fallback host and refresh node-fed inbound options Code review of the WireGuard node-endpoint change surfaced two gaps. resolveShareHost normalized its last-resort fallbackHostname, so a panel reached via a hostname the share-host grammar rejects (underscore label, trailing-dot FQDN) emitted a broken 'Endpoint = :51820'; the fallback now stays verbatim when normalization empties it. Node mutations only invalidated the nodes query, leaving the staleTime-Infinity inbound options cache serving an edited node address until the sync job broadcast (never, for disabled/offline nodes); they now invalidate the options key too. Also folds the ShareHostFields projections into direct structural passes, elides the default node shareAddrStrategy so omitempty drops it, and replaces the nullable node-address scan with COALESCE. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
9e13b32c34 |
fix: make all self-managed file downloads/installs atomic, with real completion status (#5711)
* fix(script): download the live x-ui.sh script atomically before replacing it
update_menu(), update_shell(), and update.sh's update_x-ui() all overwrote
/usr/bin/x-ui in place via `curl -o`, truncating and rewriting the same
inode a currently-running x-ui process may still be reading from. A
network hiccup or slow write during that overwrite leaves a
half-old/half-new script on disk, which then fails with bogus syntax
errors on the next run. Download to /usr/bin/x-ui-temp and `mv -f` into
place instead, matching the atomic pattern install.sh already uses.
Also fixes update_menu() checking chmod's exit code instead of curl's,
which meant a failed download could still report "Update successful."
* fix(script): close remaining gaps in the atomic script-update path
Code review of the previous commit found the atomic mv fix was itself
incomplete:
- None of the mv -f calls checked their exit status, so a failed move
fell through to chmod and "success" messaging while /usr/bin/x-ui
stayed on the old file.
- update_shell()'s `[[ -s x-ui-temp ]]` guard couldn't tell "curl -z
got a 304, nothing to do" from "a stale temp file survived an
earlier crashed run" -- the latter could get moved into place with
no freshness check.
- update_menu(), update_shell(), and update_x-ui() all hardcoded the
same /usr/bin/x-ui-temp path, so two concurrent updates (e.g. a
cron auto-update racing an interactive menu update) could collide.
- update.sh's update_x-ui() was missing the non-empty-file guard
update_shell() already had.
x-ui.sh's update_menu() and update_shell() now share a
replace_xui_script() helper that uses a PID-suffixed temp path
(/usr/bin/x-ui-temp.$$), pre-cleans it before every attempt, and
checks the exit status of curl, the non-empty test, and mv before
treating the update as successful. update.sh's update_x-ui() gets the
same sequence inlined (it's fetched as a standalone script and can't
call x-ui.sh's function), closing the missing-guard gap and using its
own unique temp path.
* fix(script,panel): harden the remaining self-update download paths
install.sh had the same unguarded /usr/bin/x-ui-temp overwrite the two
already-fixed scripts had: no exit-status check on mv, and a fixed temp
name shared with x-ui.sh/update.sh's (now-unique) temp files. Give it
its own PID-suffixed temp path, an empty-file guard, and an mv
exit-status check, matching the pattern used there.
Audited the web dashboard's Go-native updater (panel.go) for the same
bug class: it already uses os.CreateTemp for a genuinely unique temp
file and cleans up via both a deferred Remove and a shell EXIT trap, so
it was never exposed to the fixed-path race. It was missing a check
for a zero-byte download (a 200 OK with an empty body would chmod +x
and exec an empty script) -- added that alongside the existing size
cap.
Not addressed here: once startUpdate()'s child process starts, the Go
service releases it and returns success immediately. If update.sh
fails partway through, the still-running old panel keeps answering
/status, so the frontend's poll can report success with no update
having happened. Fixing that needs update.sh to signal completion
status back and the frontend to check it -- a separate follow-up.
* feat(panel): report real completion status for the web self-update
Fixes the fire-and-forget gap flagged in the atomic-overwrite fix: once
startUpdate() launches update.sh detached, the Go service had no way to
learn whether it actually succeeded. If update.sh failed partway
(network drop, disk full, permission denied), the still-running old
panel kept answering /status, so the frontend's poll reported success
with nothing having changed.
update.sh now writes its outcome to a small JSON status file
(/etc/x-ui/update-status.json by default) via `trap ... EXIT`, which
covers every exit path in the script -- including the two bare `exit 1`
call sites that don't go through the existing _fail() helper. The Go
service generates a run ID before launching, passes it and the status
path to update.sh via XUI_UPDATE_RUN_ID/XUI_UPDATE_STATUS_FILE, and a
new GET /panel/api/server/getUpdateStatus endpoint reports it back. The
frontend now polls that instead of blindly trusting HTTP reachability,
and shows a distinct error or "couldn't confirm" message instead of
silently reloading into a false success.
Adversarial review of this surfaced three more issues, fixed here:
- No lock stopped two concurrent /updatePanel calls from launching two
update.sh runs that would race each other on the actual update work
(tar extraction, service unit swap). Added an in-memory guard with a
5-minute self-expiring window, so a run that never reaches a terminal
state doesn't lock out retries indefinitely.
- XUI_UPDATE_RUN_ID is read from the environment and was interpolated
unquoted into the status JSON; a malformed value would produce
invalid JSON. Now validated as digits-only before use.
- The run ID is a UnixNano timestamp (19 digits), sent as a raw JSON
number it would lose precision in JavaScript (past
Number.MAX_SAFE_INTEGER), letting two different runs round to the
same value on the wire and defeat the whole comparison. It's now a
decimal string end to end (Go, the status file, and the generated
frontend type).
install.sh's equivalent temp-file/mv path and the Go-native
downloadPanelUpdater() path were audited for the same bug classes
during this work; findings from that audit were addressed separately.
* fix(panel): release the update lock as soon as the run finishes
An exhaustive multi-angle review of the whole branch (12 finder angles,
3-vote adversarial verification, a fresh-eyes sweep) surfaced a real
bug in the concurrency guard added in the previous commit, plus several
smaller issues; this fixes what's actionable now.
The bug: acquireUpdateSlot only ever released on the 5-minute stale
timeout or if launching itself failed. If update.sh launched fine but
failed fast (bad GitHub API response, "x-ui not installed", any of its
early exit paths), the status file correctly reported "failed" within
seconds, but a retry was still rejected with "a panel update is
already in progress" for up to 5 more minutes -- the guard never
looked at the very status file this branch built to know a run was
done. It now tracks which run ID currently holds the slot and checks
that run's own status before falling back to the timeout, so a fast
failure clears the way for an immediate retry. Added a regression test
for this, plus one confirming a stale, unrelated runID can't be
mistaken for the current run finishing.
Also:
- Added a genuinely concurrent test for the guard: 200 goroutines
racing acquireUpdateSlot, asserting exactly one wins. The previous
tests only ever called it from one goroutine, so they gave no signal
if the mutex's check-then-set were silently broken -- verified this
by temporarily removing the lock and confirming the old tests still
passed while the new one caught it immediately under -race.
- Removed the redundant upfront "pending" status write: GetUpdateStatus
already defaults a missing/stale file to pending, and the frontend
matches by run ID regardless, so the write changed no observable
behavior. Deleted writeUpdateStatus entirely since that was its only
caller.
- Renamed replace_xui_script()'s unclear "conditional" parameter to
use_if_modified_since, matching what it actually controls.
- Added HTTP-level tests for the new getUpdateStatus endpoint,
including a regression test that the runId wire format is a JSON
string (decoding into a Go string field fails outright if it were
ever a bare number). updatePanel's actual launch path is not
covered: on a Linux test runner it would make a real network call
and could exec a real update.sh, so only its non-Linux guard path is
safely testable without mocking.
Not fixed here, tracked separately: the same unsafe-overwrite pattern
this branch eliminated for /usr/bin/x-ui is still present for the
systemd unit file install in update.sh and install.sh (lower severity
since systemd only reads it on daemon-reload, not continuously); and
startUpdate's systemd-run-vs-detached-fallback branching has no test
coverage since testing it safely needs dependency injection this fix
doesn't warrant bundling in.
* fix(script): make systemd unit file installation atomic
Same anti-pattern as the /usr/bin/x-ui overwrite fixed earlier: every
site that lands the systemd unit at ${xui_service}/x-ui.service --
copying it from the extracted release tarball, or falling back to a
GitHub download per distro family -- wrote straight onto the live
path via cp/curl, no temp file, no verification. A network drop
mid-download or an interrupted cp leaves the unit file truncated;
systemd then fails to parse it on the next daemon-reload/start,
leaving the panel unable to come up until an operator manually
re-copies a good unit file.
Lower severity than the /usr/bin/x-ui case (systemd only reads this
file on demand at daemon-reload time, not continuously the way bash
interprets a running script line by line), but it's the identical
gap, just left uncovered when that fix landed.
Added a small shared helper in both update.sh and install.sh --
_install_xui_service_unit() -- covering both source types (cp from
the tarball, curl from GitHub): write to a PID-suffixed temp file,
verify the copy/download succeeded and the result is non-empty, then
mv -f into place and check that exit status too, matching the pattern
already used for /usr/bin/x-ui. All 4 cp sites and the 3-way curl
fallback in each file now go through it; verified no other site
writes new content to the unit path (the remaining ${xui_service}
references are a pre-install existence check, an rm during old-version
cleanup, and the chown/chmod that already ran after the file is safely
in place -- none of those need atomicity).
Verified with bash -n on both files, plus a standalone scratch test
exercising cp-success, cp-with-missing-source, cp-with-empty-source,
and curl-failure paths: on every failure the previous, good unit file
content is left untouched and no temp file is leaked behind.
* fix(script): make Alpine's OpenRC init script install atomic; drop a stray comment
A final maximum-rigor review of the whole PR (12 finder angles including
a repo-wide sweep for any remaining instance of the bug class this PR
fixes) found two more real issues:
- Alpine's /etc/init.d/x-ui startup script is downloaded via a bare
`curl -fLRo` straight onto the live path in both update.sh and
install.sh -- the exact same unguarded-overwrite pattern already
fixed for /usr/bin/x-ui and the systemd unit file, just left
uncovered on the OpenRC side. A network drop mid-download truncates
the live init script; OpenRC then fails to source/execute it on the
next start, leaving the panel unable to come up. Fixed with the same
temp-file + non-empty check + mv -f (with its own exit-status check)
pattern used everywhere else in this PR. Verified with bash -n and a
standalone scratch-script test covering success, empty-download, and
destination-preserved-on-failure paths.
- internal/web/service/panel/panel_test.go had one line-level `//`
comment on a call site, which the root CLAUDE.md's hard rule ("No //
line comments in committed Go/TS... rename instead of annotating")
explicitly prohibits. The comment duplicated context already stated
in the test's own doc comment two lines above, so it's simply
removed rather than reworded.
Also flagged, deliberately not bundled here since it's a different
subsystem: x-ui.sh's update_geofiles() downloads Xray's live
geoip.dat/geosite.dat with the same unguarded curl -o pattern. Tracked
as its own follow-up.
* fix(script): make geo-data file downloads atomic
Same anti-pattern as /usr/bin/x-ui, the systemd unit file, and the
Alpine init script fixed in prior PRs: update_geofiles() downloaded
Xray's live geoip.dat/geosite.dat (and the IR/RU variants) with curl
writing straight onto the exact path Xray reads at runtime
(internal/xray/process.go's GetGeoipPath/GetGeositePath), no temp
file, no verification. The existing check only inspected the reported
HTTP status via -w '%{http_code}', not file integrity, so a network
drop mid-download could leave a truncated .dat file on disk that
passes the status check. Xray then fails to parse it on the next
restart/reload, breaking any routing rules that reference geoip:/
geosite:.
The -z conditional-GET usage needed care here: the original code
pointed both -z and -o at the same live path. Fixed by pointing -z at
the live file (to keep the "already current" freshness check) while
-o writes to a PID-suffixed temp file, matching the pattern already
proven in x-ui.sh's replace_xui_script(). Verified with a local HTTP
server that a 304 response leaves the temp file untouched/nonexistent
(so the existing "already up to date" branch still works unchanged),
and added a non-empty check plus a checked mv -f before treating a
download as installed.
Verified with bash -n and an end-to-end scratch test against a local
server covering: fresh download, 304-not-modified, empty response
body, and a 404 -- confirming a failure at any stage leaves the
previous good .dat file completely untouched and no temp file behind.
* fix(script): verify the release tarball extraction, not just the download
The final maximum-rigor review found the most significant remaining gap
in this whole effort: update.sh and install.sh check the tarball
download's exit status, but never check tar's exit status, and never
verify the extracted x-ui binary actually exists before continuing.
Worse, by the time extraction runs, the previous installation has
already been stopped and deleted -- there's no rollback. A truncated
download that still passes curl's own check, or a tar failure (disk
full, killed process), left the panel silently in a broken half-state:
chmod/config/service-install all continued to run against a missing or
empty binary, with no error surfaced anywhere. This is the same bug
class as everything else in this PR (unverified write to a path
something then depends on), just for the tarball itself rather than a
single file -- and it also covers the geo-data files this PR already
fixed once for the interactive/cron path, since they ship inside this
same tarball on every panel update.
Added: a non-empty check on the downloaded archive (both files, both
install.sh call sites) and a check that tar succeeded and produced a
non-empty x-ui binary before proceeding, failing loudly with a message
that explicitly says the previous install is already gone, since
silently continuing here is worse than anywhere else in this PR.
This doesn't make the multi-file extraction fully atomic (that would
mean extracting to a temp directory and atomically swapping the whole
install tree into place, a materially larger restructuring than
anything else in this PR) -- but it closes the "fails silently, user
discovers it days later when Xray can't start" gap, which was the
actual reported problem this whole effort traces back to.
Also fixed, all much smaller:
- replace_xui_script() in x-ui.sh implicitly returned chmod's exit
status instead of success, so a successful atomic install could be
reported as failed if chmod transiently failed after the mv already
landed the new script. Added an explicit `return 0`.
- update_geofiles() had no default case branch; an unrecognized
argument would silently reuse whatever dat_files/dat_source values a
previous call left in the un-scoped globals instead of failing.
Currently unreachable (all three call sites pass fixed literals) but
cheap, defensive, and worth having.
- internal/web/controller/server.go's updatePanel has one branch (an
unparseable "dev" form value) that's both untested and safe to test
on any platform, since it's rejected before any real exec/network
call. Added the missing test case.
Verified: bash -n on all three scripts; an empirical scratch test
covering an empty downloaded archive, a corrupt (non-gzip) archive,
and a successfully-extracting-but-empty archive, confirming each is
caught before the script proceeds; full go build/vet/test -race
across the whole module; frontend generation confirmed still in sync.
* fix(panel): base the update-slot staleness fallback on process liveness
Addresses the automated review on the upstream PR (MHSanaei/3x-ui#5711).
Blocking finding: acquireUpdateSlot's staleness fallback freed the
update slot purely on elapsed wall-clock time (5 minutes), with no
check on whether the update.sh process it launched was actually still
running. update.sh runs install_base() (apt-get/dnf/pacman update and
install) before update_x-ui even starts, plus several GitHub
downloads (release tarball, x-ui.sh, and possibly a service unit or
x-ui.rc) -- on a slow or throttled host, a small VPS being the typical
deployment target for this project, that alone can plausibly exceed 5
minutes with nothing wrong. A second /updatePanel call arriving in
that window (an admin retrying after the frontend's 90s poll times
out, or overlapping master-node bulk-update calls) would launch a
second update.sh, racing the exact rm/tar/mv/systemctl sequence this
whole PR exists to make safe.
Fixed by recording the launched process's PID (detached-fallback path
only; the systemd-run path's own process has already exited by the
time startUpdate returns, so it never learns update.sh's real PID) and
checking it via the standard POSIX kill(pid, 0) liveness probe before
treating a run as stale, following the existing panel_unix.go /
panel_other.go platform-split pattern already used for
setDetachedProcess. A confirmed-alive process now keeps the slot held
past updateStaleAfter (raised from 5 to 20 minutes as a safer baseline
for the systemd-run path, which still has no way to check liveness
directly). updateHardCeiling (2 hours) is an absolute backstop so a
genuinely wedged run can never lock out retries permanently even on
the PID-tracked path.
Added two regression tests exercising the new logic (gated to Linux,
since processAlive is a no-op stub elsewhere): a live PID keeps the
slot held past the stale window, and the hard ceiling overrides
liveness. Traced both by hand against the new acquireUpdateSlot logic;
could not execute-verify processAlive itself on this Windows dev
machine (no WSL distro installed, and installing one felt
disproportionate to validate kill(pid, 0), an extremely well-established
POSIX primitive), but cross-compiled clean for linux/amd64 and this
repo's CI runs the real test suite on Linux.
Also fixed, both suggestions from the same review:
- install.sh: two failure paths right after tarball extraction were
exiting without cleaning up the already-downloaded x-ui.sh temp file
(xui_script_temp), leaving it behind. Every other new failure branch
in this PR removes its temp file before exiting; these two now do
too.
- frontend/src/pages/api-docs/endpoints.ts: updatePanel's doc entry
did not reflect that a successful response now carries an obj with
runId. Added an inline response example matching the existing
pattern used for other ad hoc (non-schema-backed) responses like
getWebCertFiles.
Verified: go build/vet clean on both windows (native) and a linux/amd64
cross-compile; full go test ./... clean; go test -race on the panel
and controller packages; bash -n on all three shell scripts; npm run
gen confirms the openapi.json diff is exactly the new response example
with no stray changes to src/generated; TestAPIRoutesDocumented still
passes.
|
||
|
|
d8221a8153 |
fix(sub): bake Host VLESS Route into subscription UUIDs
The Host VLESS Route field was stored and shown in the panel but never applied to any generated subscription (raw, JSON, Clash), so the UUID was emitted unmodified (#5655). Xray reads the route from the UUID's 3rd group (bytes 6-7, net.PortFromBytes) and masks those bytes to zero before authenticating, so a value can be baked into the share/JSON/Clash UUIDs without breaking the user match. A shared applyVlessRoute helper encodes a single 0-65535 value as the 3rd group; empty/invalid/non-UUID input is left unchanged, so legacy data never yields a broken link and no DB migration is needed. The field was wrongly validated as a multi-segment port spec (that form belongs to the separate server-side routing rule). It is now a single value 0-65535, with frontend validation, link-preview parity (genVlessLink/hostToExternalProxyEntry), hint + error translations across all 13 locales, and tests on every path. Closes #5655 |
||
|
|
789e92cddc |
fix(clients): re-enable depleted clients on API renewal (#5619)
Renewing a subscription via POST /panel/api/clients/bulkAdjust extended a client's expiry/quota but left it disabled. The enforcement loop disables a depleted client across client_traffics, client_records and the inbound settings JSON (and pushes that to the node), while BulkAdjust only updated expiry/total and never cleared enable. On a node its UpdateUser push was built from the stale ClientRecord (Enable=false), which the next traffic poll merged back onto the master, so the client never recovered. BulkAdjust now re-enables a client only when it was disabled because it was depleted and the adjustment lifts it back within limits, computed as a set-difference of the production depletedCond predicate and applied through the canonical BulkSetEnable (run after the per-inbound loop, since lockInbound is non-reentrant). Manually-disabled or still-depleted clients stay disabled. Update now writes the clients.enable column explicitly so re-enabling sticks for inbound-less clients and stops feeding a stale record into node pushes. |
||
|
|
a329882e0e |
feat(wireguard): client config UX, collapsible config card, configurable DNS
Land the WireGuard client-config UX work on main (the upstream PR #5642 branch could not be pushed to). - Reusable collapsible ConfigBlock (copy/download/QR, actions aligned right) for the client .conf, used by client info and the public sub page. - Correct .conf: canonical PresharedKey casing and DNS sourced from the inbound (configurable per-inbound, default 1.1.1.1, 1.0.0.1). - Configurable per-inbound DNS for WireGuard (schema + form + backend hint via InboundOption.WgDns); inert at the Xray layer. - Public sub page now shows the WireGuard config, rebuilt from the share link; the Go wireguard:// link carries dns/presharedkey/keepalive for completeness. - QR enabled for the wireguard:// link; link rows are compact like other protocols. - Client information order is subscription, copy URL, WireGuard config; the redundant config tab is removed from the add/edit client modal. - Drop the Inbound Information and QR Code row actions for WireGuard inbounds. |
||
|
|
60c54827aa |
feat: ldap skip tls verify (#5637)
* feat(ldap): add InsecureSkipVerify field and tlsConfig helper Extract the inline TLS config at both LDAPS dial sites (FetchVlessFlags, AuthenticateUser) into a tlsConfig(cfg) helper, and add a new Config.InsecureSkipVerify bool that flows through to tls.Config.InsecureSkipVerify. This unblocks enterprise environments (e.g. Microsoft AD CS with internal CAs) where the server certificate chain cannot be imported into the system trust store. Behavior is identical when InsecureSkipVerify is false (the default) - pure refactor + plumbing. The helper is unit-testable without a live server, which is why it is extracted. Closes https://github.com/MHSanaei/3x-ui/issues/5538 * feat(settings): add LdapInsecureSkipVerify setting Plumb the new LDAP skip-TLS-verify toggle through the settings stack: - AllSetting struct field (json/form tag: ldapInsecureSkipVerify) - defaultValueMap default ("false") - GetLdapInsecureSkipVerify() getter - ldap_sync_job wiring into ldaputil.Config (FetchVlessFlags path) - panel/user.go wiring into ldaputil.Config (AuthenticateUser path; the original issue's file list missed this) Persistence is handled by UpdateAllSetting's reflect loop, matching the existing pattern used by ldapUseTLS (no explicit setter). Closes https://github.com/MHSanaei/3x-ui/issues/5538 * feat(ui): add Skip TLS verification switch in LDAP settings Wire the new ldapInsecureSkipVerify setting into the hand-written frontend model and Zod schema, and render it as a new Switch in GeneralTab right under "Use TLS (LDAPS)". The switch is disabled when TLS is off (the setting is meaningless without LDAPS) and shows an insecure-warning description to make the security implication visible to operators. Also adds a Vitest round-trip test pinning schema acceptance and model default-to-false behavior. Closes https://github.com/MHSanaei/3x-ui/issues/5538 * chore(i18n): add Skip TLS verification strings to all locales Add pages.settings.ldap.skipTlsVerify and skipTlsVerifyDesc to all 13 backend-served translation files, matching the existing repo convention of keeping LDAP keys present in every locale (en-US, fa-IR, ru-RU, zh-CN, zh-TW, pt-BR, ar-EG, uk-UA, id-ID, tr-TR, vi-VN, ja-JP, es-ES). No translation-parity test exists in CI, but every other LDAP key is replicated across all files, so this keeps the invariant intact. Closes https://github.com/MHSanaei/3x-ui/issues/5538 * chore(codegen): regenerate frontend artifacts Regenerate frontend/src/generated/{zod,types,schemas,examples}.ts and frontend/public/openapi.json via `npm run gen` to reflect the new ldapInsecureSkipVerify field. The codegen CI job runs `git diff --exit-code` on these files; failing to commit them would break the build. Closes https://github.com/MHSanaei/3x-ui/issues/5538 |
||
|
|
9c8cd08f90 |
feat(wireguard): multi-client support
WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing. Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription. Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales. Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics. |
||
|
|
9b8a0c9b17 |
feat(groups): reset group traffic without touching client counters
The group page shows traffic counting per group, but the only reset available zeroed every member client's up/down counters (and their quotas) via bulkResetTraffic. Group traffic is a derived sum of client traffic, so zeroing the group display previously required mutating the clients themselves. Add a display-only baseline: ClientGroup gains reset_up/reset_down columns (additive, handled by AutoMigrate). ResetGroupTraffic snapshots the group's current up/down sum into the baseline, and ListGroups now reports max(0, sum - baseline). Client counters are left untouched and no Xray restart is triggered. A new POST /panel/api/clients/groups/ resetTraffic endpoint drives it, creating the client_groups row when the group exists only as a derived label. The groups page action now calls the new endpoint; confirm/success strings updated across all 13 locales to reflect group-only semantics. |
||
|
|
439245d42b |
feat(inbounds): apply remark template to Export all inbound links
Export-all now renders links through the subscription engine via a new GET /panel/api/inbounds/allLinks endpoint, so the configured remark template (name-only display part) is applied per client -- matching the client info/QR pages. Previously it generated links client-side with a hardcoded inbound-email remark. Host-aware: managed Host endpoints win over the plain link, so HOST and per-host variants render; duplicate client JSON entries are deduped by email and the list is scoped to the logged-in user. |
||
|
|
6964d84742 |
feat(reality): add live REALITY target scanner with IP/CIDR discovery
Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate. - Single-domain validate auto-fills target and serverNames from the cert SAN - Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe - New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries - Add scanner strings to all 13 locales - Replace deprecated AntD Alert message prop with title across the panel |
||
|
|
e64e998194 |
feat(clients): add bulk enable/disable and move selection actions into More menu
Add bulkEnable/bulkDisable named endpoints backed by a shared internal impl, and consolidate the per-selection actions (attach, detach, add to group, ungroup, enable, disable, adjust, sub links) into the clients table's More dropdown so the toolbar only shows the selection count and delete. Translate the new enable/disable confirm dialogs and toasts across all 13 locales. |