Commit Graph

3345 Commits

Author SHA1 Message Date
Kuzz007 9f96fdb281 Give AmneziaWG its own case in client add/update protocol switches
AmneziaWG previously fell into these switches' default branch (just
checking client.ID isn't empty), unlike WireGuard's own dedicated
case validating PublicKey -- even though AmneziaWG clients need a
real key exactly like WireGuard ones (defaultAmneziaWGClients already
auto-generates/derives PublicKey the same way). Worked in practice
only because the real UI form happens to always populate a UUID
regardless of protocol; a minimal API payload with just an email and
no key would incorrectly pass validation and no clientId for update
lookups.

Mirror the existing wireguard cases exactly: validate PublicKey on
add, use Email as the update-lookup id (matching is always by email
regardless, per the existing comment -- this only affects the
non-empty check).
2026-08-04 03:07:27 +03:00
Kuzz007 3d4fda9a0d Drain buffered TUN packets in one Read call, not just one at a time
stackTun.Read always returned exactly one packet per call regardless
of how many the caller's buf could hold. amneziawg-go's
RoutineReadFromTUN sizes its buffers to device.BatchSize(), which on
Linux is the UDP bind's own batch size (128, conn.IdealBatchSize) --
so real batch capacity was already there and going unused on the
download path.

The UDP bind's Send/Receive both genuinely batch via recvmmsg/
sendmmsg (conn/bind_std.go, confirmed in vendored source). The upload
path exploits this end to end: bind.Receive returns up to 128
datagrams per syscall, decrypt processes them as a batch, and
stackTun.Write already loops over its whole buf. The download path
never reached that batching at all: capped to 1 packet at the TUN
read step, every downstream stage (peer lookup, per-peer staging,
eventual UDP send) paid a full cycle per packet instead of amortizing
it across up to 128.

Have Read block for the first packet, then opportunistically drain
whatever's already buffered (non-blocking), up to len(buf). This is
the second half of the throughput-asymmetry fix (see 6436fd9c, which
fixed the channel being fully unbuffered and blocking the producer on
every packet) -- confirmed live on a real test connection: download
went from 30-40 Mbit/s to 130-250 after the channel-buffering fix,
with a real-network-plausible sequential-speedtest gap remaining
against upload's 300+. This closes the remaining structural gap
between the two directions' per-packet processing cost.

Regression tests confirm the drain behavior directly (not just "it
doesn't crash"): stashed this fix alone and re-ran both new tests to
confirm they fail with the exact expected message first.
2026-08-04 02:29:00 +03:00
Kuzz007 6436fd9c5c Fix download/upload throughput asymmetry in embedded AmneziaWG
stackTun's incomingPacket channel (the handoff from gVisor's internal
sender to amneziawg-go's TUN-reading/encrypt/UDP-send goroutine) was
unbuffered. WriteNotify pushes into it synchronously from whatever
gVisor goroutine is sending TCP data, so every single outbound
(download/server->client) packet forced a full blocking round trip
between gVisor and the single RoutineReadFromTUN goroutine, one packet
at a time with no pipelining.

The upload/client->server direction has no equivalent stall: Write
-> InjectInbound -> DeliverNetworkPacket hands off into gVisor's own
~1MB per-connection TCP receive buffer and returns immediately. That
asymmetry -- not a tunable window size -- is what produced observed
download throughput far below upload on a real test connection.

Give incomingPacket the same queue depth as the channel endpoint's
own outbound queue (1024) so gVisor can get meaningfully ahead of the
encrypt/send loop instead of blocking on every packet.
2026-08-04 02:05:32 +03:00
Kuzz007 738163699e fix(amneziawgnet): stop resetting live peer sessions on every reconcile tick
Real production bug, found via a live test connection that reset every
~10 seconds: ensureLocked's reconfigure-in-place branch called IpcSet
unconditionally on every Ensure, including AmneziaWGJob's routine 10s
reconcile tick even when nothing in the DB had changed. amneziawg-go's
IpcSet always includes replace_peers=true (buildUAPIConfig), and its own
handling of that op is device.RemoveAllPeers() -- unconditional, even
when the new peer list is byte-identical to the old one. So every tick
tore down and recreated every peer's live handshake/session state, and
no connection could ever survive past one reconcile cycle.

Root-caused with AMNEZIAWGNET_DEBUG (previous commit) showing "UAPI:
Removing all peers" + peer Stopping/Starting exactly ~10s after a real
handshake completed, matching AmneziaWGJob's own cadence precisely.

Fixed by comparing the freshly rendered UAPI config string against what
was last actually applied and skipping IpcSet entirely when identical --
reusing buildUAPIConfig's own exhaustive field coverage instead of a
hand-maintained fingerprint that could drift out of sync with it.

TestEnsureUnchangedInstanceDoesNotResetLivePeers verifies via
device.LookupPeer pointer identity (confirmed to fail without this fix,
not just pass trivially with it).
2026-08-04 01:46:32 +03:00
Kuzz007 b0c29b7caa feat(amneziawgnet): opt-in verbose device logging via AMNEZIAWGNET_DEBUG
The embedded amneziawg-go Device is silent by design (DeviceOptions'
Logger defaults to LogLevelSilent) -- real protocol-level diagnostics
(handshake progress, decrypt/MAC errors, keepalive state) were completely
unavailable while debugging a live "handshake happens, then goes silent"
report on a real test box, with nothing useful in the panel's own logs.
Setting AMNEZIAWGNET_DEBUG on the host now switches every embedded
interface to LogLevelVerbose. Deliberately env-var-gated, not a permanent
level bump: this logging has no per-peer filtering, so it's meant for
targeted investigation, not routine operation.
2026-08-04 01:35:36 +03:00
Kuzz007 bde5686401 fix(amneziawg): flag Xray for resync when a peer edit changes qualifying state
updateAmneziaWGInbound/AddInbound/DelInbound only ever updated the embedded
amneziawgnet Device -- they never called SetNeedRestart the way every other
protocol's mutation path does. injectAmneziawgnetSocks's Xray-side relay
inbound depends on InstanceFromInbound finding at least one qualifying peer,
so an edit that flips that (first peer added, last one removed, or the
inbound re-enabled) previously required a full panel restart before the
relay actually got created or torn down, with no error or signal anywhere.
Confirmed as the root cause of a real, separate bug in this fork's retired
kernel-module architecture (same missing-flag shape, different manager) --
fixing it here too before this embedded path is ever deployed for real.
2026-08-03 22:06:50 +03:00
Kuzz007 e7c6f92e7f fix(clients): reject AllowedIPs already used on another WireGuard/AmneziaWG inbound
defaultWireguardClients/defaultAmneziaWGClients only ever checked uniqueness
against their own inbound's client list, so two inbounds sharing a subnet
(same protocol or not) could silently hand out or accept the same address --
the exact scenario behind a real duplicate-IP incident where a WireGuard and
an AmneziaWG client both ended up on the same address. otherTunnelAllowedIPs
now collects every address already claimed on every other tunnel inbound and
folds it into both the auto-allocation pool and the manual-entry collision
check, naming the other inbound in the error when it fires.
2026-08-03 22:06:40 +03:00
Kuzz007 966bab0d71 fix(frontend): add the missing AmneziaWG config download on the sub page
The subscription page already gave WireGuard links their own "Config"
block (copy/download/QR of the actual .conf, via wireguardConfigFromLink
reversing the wireguard:// query params) but had no equivalent for
AmneziaWG's vpn:// links -- its isWireguardLink gate never matched them,
and no reverse-parse helper existed for this page specifically. Every
other surface (InboundInfoModal, ClientInfoModal, ClientQrModal) already
had this parity; this was the one page that didn't.

Fixed by adding amneziawgConfigFromLink (inbound-link.ts), simpler than
its WireGuard counterpart since a vpn:// payload already *is* the plain
.conf text -- just base64url-decode it, no query-param reconstruction
needed -- and wiring it into SubPage.tsx alongside the existing WireGuard
block, reusing the same pages.clients.amneziaWgConfig label the other
three surfaces already use.
2026-08-03 21:22:00 +03:00
Kuzz007 4a9c2e0b04 fix(install.sh): never delete the live install before the new one is verified
Real production incident: the previous update flow stopped the service and
rm -rf'd the existing installation, then extracted the downloaded archive
straight into place -- when that extraction failed, the panel was left
completely gone with no way back short of manual recovery. Now extracts
into a staging directory first, fully verifies and prepares it there (arch
rename, chmod), and only swaps it into place -- stopping the service and
removing the old install -- once that verification has already passed. A
bad download/extraction now just fails the update; the running install is
never touched.

Also added explicit error handling on both cd calls in this function.
Root-caused the original incident to exactly this: if the first cd ever
silently fails, later steps that build paths by absolute string
concatenation still land correctly, but the tar extraction's bare relative
filename resolves against whatever the previous cwd was instead --
producing a confusing "No such file or directory" far from its real cause.

Verified via bash -n, shellcheck -S warning (no new findings beyond this
file's existing ones), and live repro runs against an isolated
XUI_MAIN_FOLDER on a real box: the happy path installs cleanly, and a
forced cd failure now fails loudly instead of corrupting the extraction.
2026-08-03 20:18:48 +03:00
Kuzz007 7e439db065 Merge branch 'phase3-hard-cutover' (SOCKS5 hot-apply fix) 2026-08-03 11:56:16 +03:00
Kuzz007 d36c1217c8 fix(xray): force a full restart for password-auth SOCKS5 hot-apply
Real production incident: editing a client under an AmneziaWG inbound
left its embedded SOCKS5 relay's settings byte-different (a new account
list), and Xray's gRPC remove+add hot swap silently dropped the account
for a peer whose email contained non-ASCII characters -- its tunnel kept
handshaking fine but all its traffic got rejected at the SOCKS5 layer,
while every other peer on the same relay was unaffected. A full restart
(reading the same JSON straight from disk) always produced the correct
account list. socks isn't in userDiffableProtocols (that only covers
vless/vmess/trojan's clients+email shape, not accounts+user), so any
settings drift on this inbound fell through to the generic remove+add
path. Forces a restart instead, the same defensive choice already made
for REALITY and TPROXY -- scoped to auth:"password" specifically so the
other, noauth SOCKS5 bridges (panel/node/mtproto egress) keep the cheaper
hot path.
2026-08-03 11:55:19 +03:00
Kuzz007 9a262252b4 Merge branch 'phase3-hard-cutover'
AmneziaWG: replace kernel-module bridge with embedded SOCKS5 relay (Phase 3, in progress)
2026-08-03 11:05:04 +03:00
Kuzz007 3a8738c958 chore(frontend): regenerate schemas/openapi after Phase 3.5 doc-comment update
npm run gen output following the ExternalInterface/IPv6Enabled/
IPv6ExternalInterface doc-comment rewrite in internal/amneziawg/types.go.
2026-08-03 10:44:55 +03:00
Kuzz007 1d39de4d13 feat(amneziawg): restore per-client public IPv6 identity (Phase 3.5)
Adds internal/amneziawg.FirstIPv6 and a new internal/amneziawgnet/v6alias.go
that aliases each IPv6-enabled peer's own address onto the host NIC
(ip -6 addr add), wired into the Manager's Ensure/Remove/Reconcile/StopAll
lifecycle. internal/web/service/xray.go's new injectAmneziawgV6Egress gives
each such peer a dedicated freedom outbound (sendThrough) plus a routing
rule matching its own email, so its outbound connections carry a distinct
public source address again -- restoring what the embedded-architecture
hard cutover temporarily dropped. Scoped to outbound source identity only
(not unsolicited inbound/port-forwarding, which stays the separate Phase
3.6); no frontend changes needed since IPv6Enabled/IPv6ExternalInterface
were already in the UI and per-peer opt-in is just an IPv6 AllowedIPs entry,
same as today.
2026-08-03 10:41:58 +03:00
Kuzz007 ab39f14b18 docs: update all 7 READMEs for the embedded AmneziaWG architecture
Replace the kernel-module/DKMS/awg-quick description (What's different,
Supported Platforms, Acknowledgment) with the embedded amneziawg-go +
gVisor architecture, and note the temporarily-unsupported per-client
IPv6 identity and port-forwarding gap until their fast-follow releases.
2026-08-02 20:34:57 +03:00
Kuzz007 7550072c7b feat(amneziawg): remove the vestigial routeThroughXray toggle from the UI
Hard cutover, part 4: the embedded path has no opt-in gate for Xray
routing at all (every peer's traffic already goes through Xray's own
SOCKS5 relay unconditionally -- see xray.go's injectAmneziawgnetSocks), so
a toggle that no longer does anything would just confuse admins. Removed
from the form (amneziawg.tsx), the per-protocol Zod schema and its
new-inbound default, and the routeThroughXray/routeThroughXrayHint i18n
strings across all 13 locales.

The Go-side field stays (see internal/amneziawg's ServerSettings/Instance,
already annotated as vestigial in the previous commit) for backward
compatibility with existing stored settings -- z.object's default
unknown-key stripping means the form simply drops it from an existing
inbound's settings on its next save, no migration needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:20:42 +03:00
Kuzz007 e4fc9d5031 chore(frontend): regenerate schemas/types/openapi after the amneziawg trim
npm run gen, matching CI's own codegen check: picks up the updated
ServerSettings/Instance doc comments (types.go) and drops ensureAction
(the old kernel-module Manager's now-deleted internal enum, which
tools/openapigen was scanning and emitting bindings for even though it was
never meant to be part of the public API surface).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:05:10 +03:00
Kuzz007 59dff059c1 feat(amneziawg): retire the kernel-module OS-shellout code and install.sh path
Hard cutover, part 3: everything that only ever existed to drive the
kernel-module (DKMS) + awg-quick + TPROXY architecture is gone now that
internal/amneziawgnet's embedded path is wired in as the real thing.

internal/amneziawg/manager.go -> instance.go (renamed, ~90% smaller): kept
InstanceFromInbound and its direct helpers (interfaceNameForID,
serverAddress, serverAddressV6) plus the exported FirstIPv4 (still used by
server.go's access-log email index) -- all pure, protocol-shape-only code
with no OS dependency, reused by both the old and new paths historically.
Deleted the old Manager (GetManager/Ensure/Reconcile/StopAll/CollectTraffic/
the fingerprint methods), generateServerConfig and everything under it
(writeObfuscation, defaultPostUpDown, appendOrTrue, detectDefaultInterface),
and process control (interfaceUp/Down, syncConfig, getPeerStats,
IsAwgInstalled). route_egress.go deleted entirely (the TPROXY bridge's
port/fwmark/table constants and rule-rendering, fully superseded by
internal/amneziawgnet's SOCKSPortForInbound/SocksPassword). portfwd.go
trimmed to just the parsing/validation half (ForwardedPortsInclude, still
used for save-time conflict checks); the iptables DNAT rendering half is
gone -- per-client port-forwarding has no equivalent under the embedded
path yet (tracked as Phase 3.6).

install.sh: removed install_ndppd, enable_ipv6_forwarding,
enable_tproxy_support, should/install_amneziawg, and check_secure_boot (and
their call sites) -- roughly 265 lines. No more DKMS build, PPA/keyring
setup, TPROXY kernel module loading, or Secure Boot warning: the embedded
path needs none of it.

Not in this commit (tracked as an explicit follow-up, not silently
dropped): the frontend's routeThroughXray toggle is now vestigial (the
field stays in the Go/JSON schema for backward compat with existing stored
settings, see types.go) but its UI/schema removal needs the frontend
type-regen + openapi.json hand-patch dance this fork always does for a
settings-shape change, which is its own separate pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 20:01:08 +03:00
Kuzz007 f78dfa6f67 feat(amneziawg): swap the app's integration points to the embedded manager
Hard cutover, part 2: every real call site that used to drive
internal/amneziawg's kernel-module Manager now drives
internal/amneziawgnet's instead --

- internal/web/job/amneziawg_job.go: the reconcile cron job. Traffic/
  online-status accounting is dropped entirely (not ported) -- once a
  peer's traffic is relayed through Xray's own SOCKS5 inbound, it's an
  ordinary Xray user and XrayTrafficJob's existing generic stats polling
  already handles it, with zero AmneziaWG-specific code.
- internal/web/runtime/local.go: the immediate-apply CRUD path
  (AddInbound/DelInbound/updateAmneziaWGInbound).
- internal/web/web.go: panel shutdown's StopAll.

internal/amneziawgnet.Manager gains Remove(id) to match the kernel-module
Manager's shape at these call sites (Reconcile alone doesn't cover a
single-inbound removal outside a full reconcile pass).

internal/web/service/inbound_amneziawg.go's applyLocalAmneziaWG needed no
change: it already goes through runtime.Runtime.UpdateInbound, which now
resolves to the updated local.go path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 15:04:51 +03:00
Kuzz007 efca370bfc fix(amneziawg): satisfy golangci-lint in relay.go
errcheck: explicitly discard io.Copy's error in the two fire-and-forget
relay goroutines -- a copy error there just means the connection closed,
which is the expected/normal way this loop ends, not something to handle
further.

noctx: net.DialTimeout must not be called per this repo's lint config; use
(*net.Dialer).DialContext with Timeout set instead, same as the rest of the
codebase already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 14:49:53 +03:00
Kuzz007 124665c5ef feat(amneziawg): auto-wire the SOCKS5 relay into every embedded Device
Manager.ensureLocked now attaches AttachTCPForwarder/AttachUDPHandler to
every Device it builds, relaying into that instance's own loopback SOCKS5
inbound automatically -- no caller needs to know relay.go exists at all.
Peer identity is re-looked-up via Manager.Lookup on every connection rather
than captured once at attach time, so a reconfigure-in-place (peers added/
removed without a full rebuild) doesn't leave the forwarder working off a
stale peer index.

Added TestManagerEnsureAutomaticallyWiresRelay: drives this through the
real Manager.Ensure entry point (not manual wiring like the existing
relay_e2e_test.go) against a real xray-core process, confirming the
automatic attachment and the port/password Manager derives internally
actually agree with what a real SOCKS5 inbound expects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 14:42:00 +03:00
Kuzz007 3450d872d9 feat(amneziawg): replace the TPROXY bridge with a SOCKS5 relay inbound (Phase 3 start)
Hard cutover, part 1: injectAmneziawgnetSocks replaces injectAmneziawgEgress
as the AmneziaWG-side Xray config injector. Every enabled AmneziaWG inbound
now gets an always-on loopback SOCKS5 inbound (built by
amneziawgnet.SocksInboundSettings) instead of an opt-in dokodemo-door TPROXY
bridge -- there's no RouteThroughXray gate anymore since the embedded path
has no alternative datapath once traffic is decapsulated in gVisor. Reuses
the real inbound's own tag, same as before, so per-inbound stats totals
keep matching.

internal/amneziawgnet gains SOCKSPortForInbound (deterministic port
derivation, its own range distinct from the kernel-module bridge's) and
SocksPassword (a process-wide, lazily-generated, not-persisted password --
this traffic never leaves loopback).

port_conflict.go's port-reservation check is updated to match: the new
SOCKS5 relay port is reserved unconditionally for every qualifying
AmneziaWG inbound, not gated on RouteThroughXray.

Not yet done (tracked in the migration plan): swapping the actual manager
call sites (cron job, immediate-apply CRUD, shutdown) from the kernel-module
Manager to amneziawgnet's, and deleting the now-dead TPROXY/awg-quick code.
This commit could not be locally verified beyond internal/amneziawgnet
itself (this machine has no C compiler, so internal/database and anything
that imports it -- including internal/web/service -- can't be built or
vetted here); pushing for real CI feedback before continuing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 14:33:35 +03:00
Kuzz007 d163e6ac2d feat(amneziawg): add SOCKS5 relay for the embedded amneziawg-go path (Phase 2)
relay.go relays a recovered tunnel connection into Xray's own stock SOCKS5
inbound, authenticating as the peer's email -- the mechanism that gives
embedded AmneziaWG traffic real Xray stats/routing/sniffing with no
Xray-core fork. TCP goes through golang.org/x/net/proxy; UDP needed a
hand-rolled SOCKS5 UDP ASSOCIATE client since neither that package nor
xray-core's own internal socks client expose one.

Verified end-to-end against a real xray-core process (gated behind
XRAY_E2E_BINARY, matching internal/xray's own e2e test convention): a real
TCP and UDP round trip through the whole chain, plus real per-peer stats
counters in Xray's own log.

Xray-config auto-injection (a real SOCKS5 inbound wired into the generated
panel config) is deliberately not part of this commit -- it would require
deciding which AmneziaWG inbounds run on the kernel-module path vs. this
one, and that's an explicit later decision, not something to back into here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:59:13 +03:00
Kuzz007 58671533bb feat(amneziawg): add embedded amneziawg-go device package (Phase 1)
New internal/amneziawgnet package: builds a real amneziawg-go Device over a
gVisor netstack from an existing amneziawg.Instance, with a TCP/UDP
forwarder that recovers each tunnel connection's real destination and a
peer-identity index keyed by AllowedIPs. This is the foundation for
migrating AmneziaWG off the kernel-module+TPROXY path (see the AmneziaWG-go
vs kernel-module decision) -- nothing wires into live traffic yet, that's
Phase 2 (relay into Xray's own SOCKS5 inbound).

Covered by three real end-to-end tests: a genuine handshake + TCP forwarder
+ identity resolution, the same for UDP (including a reply routed back
through the tunnel), and the manager's reconfigure-in-place vs. rebuild
lifecycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:48:13 +03:00
Kuzz007 59dc94a288 chore: bump version to 3.6.0-awg.2 2026-08-02 00:03:48 +03:00
Kuzz007 fb93ed772e fix(frontend): recognize AmneziaWG's vpn:// scheme in share-link labels
The shared link-tag/label helper (used by the client info modal, QR
modal, and subscription page) had no entry for the vpn:// scheme
AmneziaWG links use, so it fell through to the generic fallback: a
plain "Vpn" tag with no color, and an empty remark/port that made the
row's title fall back to "Link N" instead of the inbound's actual
name:port — unlike every other protocol, which shows its real tag and
label.

vpn:// links are base64url of a plain .conf text (matching the real
AmneziaVPN app's own share-link format), not a structured URL, so
there's no query string or #hash to read a remark/port from. Decode
the payload and pull the remark/endpoint back out of the .conf text
directly instead.
2026-08-01 23:51:05 +03:00
Kuzz007 0ea0e4512d fix: update inbound_amneziawg.go to the split buildInboundForLocalRuntime
Fork-only file, invisible to upstream's own rename of
buildRuntimeInboundForAPI into buildInboundForNodePush /
buildInboundForLocalRuntime (part of the node-sync client-deletion fix).
Every other call site was migrated by that commit; this was the one
straggler, caught by CI after the 3.6.0 sync landed on main.
2026-08-01 22:36:28 +03:00
Kuzz007 31dda04064 chore: regenerate package-lock.json after the 3.6.0 merge
npm install to reconcile the lockfile with the merged package.json
(version 0.4.3 -> 0.6.0, plus the various dependency range bumps that
came in cleanly from upstream's own routine dependency refreshes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 22:02:16 +03:00
Kuzz007 c97ab56245 Merge remote-tracking branch 'upstream/main' into sync-3.6.0
# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
2026-08-01 21:59:29 +03:00
Sanaei 5bc81dfd1d fix(node): stop the node sync from deleting clients it never meant to
A client that hit its quota or expiry was disabled, then destroyed on both
panels a few seconds later. Five defects fed the same hard delete.

ReconcileNode pushed buildRuntimeInboundForAPI, which strips disabled
clients. Every other call site targets an in-memory Xray config, where
dropping a user is harmless; a node target is a peer panel's DATABASE, so
the node deleted the row, stopped reporting it, and the master mirrored that
deletion back. Split the builder in two: buildInboundForNodePush injects
fallbacks only, buildInboundForLocalRuntime adds the strip on top. The names
now say which targets they are safe for.

setRemoteTrafficLocked trusted a config_dirty the caller sampled before the
snapshot round-trip. A client added inside that window commits on the same
serialized writer and marks the node dirty, but the merge still treated the
older snapshot as authoritative and deleted it. Re-read the flag inside the
writer.

In "selected" sync mode, FilterNodeSnapshot strips a deselected tag, but the
sweep loaded every inbound with node_id set, so deselecting a tag read as
"the node deleted it" and wiped an inbound the node still serves. Skip tags
outside the node's managed set.

A failed SyncInbound was logged and swallowed; on SQLite the transaction
still commits, and the sweep then deleted the innocent clients whose links
that failure had left unbuilt. Skip the sweep for such an inbound, and close
the trigger: SyncInbound now stores the trimmed email it looks up by, and
email validation rejects every unicode space rather than only U+0020.

ClientService.Delete tombstones up front and deliberately keeps the record
when an inbound fails, so the next attempt can retry the leftovers. The
tombstone did not lift with it, so the next merge dropped the client from
the synced settings and finished the deletion this path had refused. Add
withdrawClientTombstones on every failure path, in BulkDelete too.

Finally, make the sweep itself recoverable. "Ended the merge unattached" is
true for a real remote deletion and equally true for a bad merge, so it now
stamps sync_orphaned_at instead of deleting; any later merge that sees the
client attached clears the mark, and a reaper removes only what stayed
orphaned past the grace period. The traffic row survives that window too, or
a reclaimed client would come back with its usage, quota and expiry reset.
The mark is written by this sweep alone, so orphans from any other cause
keep their existing manual-cleanup semantics.
2026-08-01 15:19:08 +02:00
Sanaei f4b7b08e08 fix(ldap): stop auto-delete from wiping every client on an empty directory
FetchVlessFlags returns (empty map, nil) whenever the bind succeeds but the
search yields nothing usable — a renamed OU, a service account that lost read
on the user attribute, a filter that stopped matching. The only guard on the
destructive half of the sync was `err != nil`, so that answer was read as
"every user is gone" and the job detached every client from the configured
inbounds, once a minute, for as long as the directory stayed broken.

Gate auto-delete behind autoDeleteSafeForFetch: refuse an empty fetch, and
refuse one that collapsed below half of the last successful sync, which is a
misconfigured directory far more often than real churn.

Also stop splitCsv from defaulting an empty string to DefaultTruthyValues.
That default belongs to the truthy-value setting, but splitCsv is also what
parses ldapInboundTags, so an unconfigured tag list silently resolved to
["true","1","yes","on"]. It only ever bounded the blast radius by accident.
2026-08-01 15:18:50 +02:00
Sanaei 1ff90c5b66 docs(claude): bound comment length, fix size, and test value
Three agent-facing rules, each written after the same mistake showed up in
review.

Comments were banned outright, which the codebase itself contradicts on
almost every file — the ban pushed real invariants out of the code entirely.
Allow them, but cap a block at 2 lines and spend those lines on the *why* a
name cannot carry.

Add a scope rule: the fix must be the smallest change that removes the root
cause. A small bug does not earn new columns, jobs, abstractions or config;
if it genuinely needs architecture, agree on that first instead of shipping
it alongside the fix.

Add two testing rules: a test must go red when its fix is reverted, and it
must cover something that can actually break. A test that passes either way
certifies nothing and is then cited as proof the fix works.
2026-08-01 15:18:35 +02:00
dependabot[bot] 138e1bd840 chore(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.0 (#6162)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 18:30:43 +02:00
Isuru Sampath 31c1eed5dc fix dead code, typo, and minor bugs in main.go, process.go and index.go (#6167)
Fixes several small issues found during code review:
- fix(xray): return explicit nil instead of stale err in getLogPath
- fix(xray): remove duplicate doc comment on GetErrorLogPath
- refactor: remove unreachable return after log.Fatalf (×4)
- fix(cli): add missing newline to listen IP success message
- fix(cli): typo "form" → "from" in migrate help text
- refactor: simplify var+assign to short declaration for server/subServer
- fix(controller): return error from getTwoFactorEnable instead of swallowing it
2026-07-31 18:27:46 +02:00
PathGao 264f61eb90 Merge pull request #6161 from PathGao/feat-sidebar-pinning
feat(ui): let users pin the sidebar
2026-07-30 23:37:47 +08:00
Kuzz007 8daf1d844d fix: harden the bin/ snapshot-and-restore against the review round on #6152
- Replace the mktemp+cp snapshot with a same-filesystem mv of bin/ aside:
  an unchecked mktemp failure previously made the very next line copy
  bin/'s contents into "/" (empty custom_bin_backup + trailing slash),
  and a silently-ignored cp failure (stderr redirected, exit code never
  checked) could leave a truncated custom geo file that gets "restored"
  as if it were intact. A rename is atomic and needs no extra disk space,
  removing both failure modes at once; if it fails, back off cleanly and
  say so instead of proceeding as if a backup exists.
- Add a trap so an interrupted update (Ctrl-C, signal) between the
  backup and the restore doesn't leave the snapshot (which contains
  bin/config.json and every mtproto client's FakeTLS secret) sitting
  around indefinitely; the two exit-path cleanups this replaces are gone
  since the trap now covers those exits too.
- Move the restore below the arm arch-rename/chmod block instead of
  before it, so xray-linux-arm32/mtg-linux-arm already exist under their
  final names and don't get needlessly restored-then-overwritten and
  misreported as "custom".
- Exclude bin/config.json and bin/mtproto/*.toml from the restore: those
  are the panel's own generated runtime state (internal/xray/process.go,
  internal/mtproto/manager.go), not admin-placed files, and restoring a
  stale one only resurrects dead state or recreates bin/mtproto/ with the
  wrong (more permissive) directory mode.
- Match symlinks in the restore's find, not just plain files -- cp -a
  already preserves them in the snapshot, but the restore loop was
  silently dropping them, which is exactly the failure mode (a geo file
  symlinked in from elsewhere) this PR set out to fix.
- Quote the two new xui_folder expansions.
- Extend the non-interactive smoke test to reinstall over an existing
  install with a sentinel file in bin/, asserting it survives and that
  the bundled geoip.dat is still the release's own copy -- the update
  path this PR touches had no CI coverage at all before this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 10:45:46 +03:00
PathGao ac584cfc90 fix(ui): reserve space for pinned sidebar
Keep page content accessible when the desktop sidebar remains expanded and cover the complete pin lifecycle.
2026-07-30 14:52:38 +08:00
PathGao 91c5d7b19f style(ui): preserve sidebar header spacing
Keep the original title alignment while fitting the pin with the existing header actions.
2026-07-30 14:48:03 +08:00
PathGao b2fe233108 fix(ui): align sidebar pin controls
Keep the pin with the expanded header actions and center the collapsed version link with the navigation rail.
2026-07-30 14:46:43 +08:00
PathGao 5373786faa feat(ui): let users pin the sidebar
Restore a persistent expanded-sidebar choice while preserving the compact hover rail as the default.
2026-07-30 14:39:55 +08:00
Sanaei c377dca27c v3.6.0 v3.6.0 2026-07-30 03:15:28 +02:00
Sanaei c56f6447a8 chore: refresh dependencies and modernize Go test idioms
Frontend deps: @hookform/resolvers 5.4.3 -> 5.5.7, Storybook 10.5.4 -> 10.5.5
across the four packages we declare, globals 17.7.0 -> 17.8.0, and jsdom
29.1.1 -> 30.0.1. The jsdom major replaces its CSS and selector stack --
@asamuzakjp/css-color 5 -> 6, @asamuzakjp/dom-selector 7 -> 8, undici 7 -> 8,
nwsapi and generational-cache folded into their parents, whatwg-url 17 nested
underneath. Nothing in the Vitest suites reaches those directly and the whole
frontend gate (typecheck, lint, tests, build, Storybook compile) is green.
Panel frontend version to 0.6.0.

Backend deps: mattn/go-sqlite3 1.14.48 -> 1.14.49 and valyala/fasthttp
1.72.0 -> 1.73.0, plus the golang.org/x/exp and genproto/googleapis/rpc
indirect bumps that came with them.

Go tests: modernize -fix output, covering range-over-int, sync.WaitGroup.Go
in place of manual Add/Done pairs, maps.Copy, and Go 1.26 new(expr) for
pointer-to-value in the forwarded-trust table. The storedAs helper is deleted
instead of being left behind a //go:fix inline directive -- keeping it that way
fails govet on the one call site the rewrite did not reach, and every caller now
takes new(...) directly. Behaviour is unchanged.

DnsTab: the hosts-sync effect tested dns while declaring dnsEnabled in its
dependency array. Both carry the same truth value, so this is exhaustive-deps
hygiene rather than a behaviour change.
2026-07-30 03:14:22 +02:00
PathGao 66740b7ef4 fix(frontend): preserve edited server drafts (#6156)
* fix(frontend): preserve edited server drafts

* fix(frontend): retain Xray server projections

* fix(frontend): keep draft controls internal

* fix(frontend): rehydrate saved redacted settings

* fix(frontend): order saved draft hydration

* fix(frontend): preserve draft baselines on security saves

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-30 02:59:53 +02:00
PathGao 8d02ae28f5 fix(frontend): preserve theme body classes (#6157)
* fix(storybook): preserve preview body classes

* fix(frontend): retain theme body classes

* fix(storybook): mirror panel theme attributes

* test(storybook): cover theme switches

* test(storybook): strengthen theme DOM coverage

* fix(frontend): preserve message container classes

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-30 02:59:35 +02:00
PathGao 2c943da3e0 fix(frontend): keep DNS hosts synchronized (#6158)
* fix(frontend): keep DNS hosts synchronized

* fix(frontend): preserve incomplete DNS hosts

* fix(frontend): reset DNS host drafts when disabled

* fix(frontend): clear DNS host drafts when disabled

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
2026-07-30 02:58:51 +02:00
Sanaei 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.
2026-07-30 02:49:32 +02:00
Kuzz007 9e42ec82c6 test: wrap renderWithProviders in QueryClientProvider
The upstream PR branch's copy of this helper already wraps
QueryClientProvider (needed by any test rendering a component that uses
react-query), but this fork's own main never picked that up -- until the
just-ported rule-form-geodata-tags.test.tsx became the first test here to
render a component (RuleFormModal) whose hooks call useQuery, failing
immediately with "No QueryClient set". Backward compatible: all 12
existing callers pass unchanged since QueryClientProvider is a no-op for
components that don't touch react-query.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 01:25:50 +03:00
Kuzz007 34c43c8a9d fix: address the automated review round on PR #6154
- Replace parseGeodataFile's full proto.Unmarshal with a protowire-based
  scan that reads only each entry's Code, skipping every Domain/CIDR
  payload without allocating it -- the actual bulk of a real
  geoip.dat/geosite.dat. Also caps the file read at 256 MiB.
- Hold geodataMu across the full scan-and-maybe-parse in
  GetGeodataCategories instead of releasing it around the parse, so
  concurrent cache misses (e.g. several browser tabs) can't all
  independently re-parse every file; clone the cached slices before
  returning them so a caller mutating its result can't corrupt the cache.
- Gate useGeodataCategories on the rule editor's own `open` state instead
  of firing on every visit to the Routing tab.
- formatGeodataSuggestion now compares filenames with strings.EqualFold,
  matching scanGeodataFiles' own case-insensitive match -- a file that IS
  the default one on a case-insensitive filesystem (e.g. Windows) no
  longer gets the long ext: form.
- Fix a real bug the review's hypothesis led to: Select mode="tags" only
  commits the search text on Enter/comma, so clicking Save right after
  typing (a blur, not an Enter) silently dropped the value entirely, with
  no domain/ip key at all in the saved rule. Wrap it in a small
  TagsAutocomplete that also commits on blur. Same autocomplete now
  applies to sourceIP, which accepts geoip:/ext: too.
- Guard useGeodataCategories' fetch per-field with Array.isArray instead
  of a single top-level `?? EMPTY_CATEGORIES`, since parseMsg returns the
  original unvalidated obj (not null) on a schema mismatch.
- Test fixes: exact slices.Equal instead of slices.Contains-only
  assertions, t.Run subtests, a cache-hit-skips-reparse test (via a
  test-only parse counter), a returns-independent-slices test, a
  file-size-cap test, and four new frontend tests covering the tags
  round-trip including the blur-commit regression above.
- GeodataCategories now goes through the same generated-example path as
  every other response type (StructAllow + example: tags + responseSchema
  in endpoints.ts) instead of a hand-written response string. The
  existing hand-written GeodataCategoriesSchema in schemas/routing.ts is
  unrelated to this and is left alone -- CLAUDE.md is explicit that Zod
  schemas under src/schemas/ are the source of truth and only the
  generated example/openapi path comes from Go example: tags.
- Drop the two PR-illustration screenshots from media/ -- nothing in the
  repo referenced them; they only ever needed to exist in the PR
  description itself.

Not changed: leaving geodataFileKind's leak into generated/{types,zod}.ts
as-is. internal/web/service's openapigen request has no AliasAllow at
all, so every non-struct type in the package already leaks this way
(e.g. staticEgressResolver, transportBits predate this PR) -- scoping an
AliasAllow for the whole package is a real cleanup but a separate, wider
change than this PR's own footprint, and needs checking nothing already
depends on those existing generated aliases first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 01:18:00 +03:00
Kuzz007 98dce6e5d4 docs: catch up the "Other changes" changelog for two shipped fixes
The vpn:// share-link fix and the live-Speed-for-sidecar-protocols fix
(both shipped a few days ago) never got their changelog bullet despite
the fork's own standing rule to always document fork-specific changes
here. Also documents the bin/-preservation fix on install.sh, shipped
today and proposed upstream as MHSanaei/3x-ui#6152.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 00:35:34 +03:00
Kuzz007 1b07dadfb9 fix(install.sh): check the live sysctl value, not sysctl.conf text
Same fix as the upstream PR (#6105) review round: 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 or match a disabled/commented line, leaving
forwarding silently off either way. Query the live value via
`sysctl -n` instead. Applied to both the IPv6 and IPv4 checks.
2026-07-29 23:18:41 +03:00