Commit Graph
96 Commits
Author SHA1 Message Date
Kuzz007 878ee839db Split the client edit form's AllowedIPs into per-protocol fields
A client attached to both WireGuard and AmneziaWG shared one AllowedIPs
form field with a dynamically-switching label, so its two genuinely
different addresses could never both be shown or edited correctly.
Worse, Update/Create broadcast that one shared value to every attached
wg/awg inbound with no subnet-fit check, so an ordinary edit save could
silently overwrite one protocol's address with the other's -- the same
bug class already fixed for Attach, but reachable from any client edit.

model.Client gains an optional AllowedIPsByInbound map so a caller can
send distinct values per inbound; Update/Create honor it and, when it's
absent, clear a shared value that doesn't fit an AmneziaWG inbound's own
subnet instead of writing it through. A new TunnelAllowedIPsByInbound
read path feeds the real per-inbound address to the client edit form via
GET, which now renders two separate, correctly-labeled fields whenever
both protocols are attached (unchanged single dynamic field otherwise).
2026-08-04 14:13:20 +03:00
Kuzz007 569b7fbc6b Give WireGuard an explicit, admin-configurable subnet field
WireGuard previously had no configurable subnet at all -- only an
implicit one, either inferred from existing clients' own addresses
(wireguardAllocationBase) or a hardcoded 10.0.0.0/24 fallback when
none exist yet. AmneziaWG, by contrast, has always had a real
server.subnetIp/subnetCidr field in its settings, editable in the
UI. User request: give WireGuard the same treatment.

Backend: explicitWireguardSubnetBase reads an optional subnetIp/
subnetCidr pair from the inbound's own settings JSON (mirroring
AmneziaWG's defaultAmneziaWGSubnetBases). defaultWireguardClients
checks it first; only when unset does it fall back to today's
inference-from-existing-clients behavior, so an inbound saved before
this field existed keeps working exactly as it always has.

Frontend: subnetIp/subnetCidr added to WireguardInboundSettingsSchema
and the inbound form (mirroring AmneziaWG's own field layout/labels),
with a real default (10.0.0.0/24, the same value the backend already
fell back to) seeded for newly created inbounds so the field starts
populated and editable rather than blank. Translated across all 13
locales.

This also structurally closes the class of bug fixed in
82cc69f5/291c47b3: with wg and awg subnets explicit and
independently controllable, an admin who wants matching addresses
across both protocols can configure them to actually agree, instead
of one silently inheriting the other's incompatible range.
2026-08-04 11:27:51 +03:00
Kuzz007 291c47b3fb Attach: never inherit an address that doesn't fit the target inbound
hasTunnelAttachment (from the earlier fix, commit 51067f16) only
asked "does this identity have ANY tunnel attachment", treating that
as license to reuse its stored address verbatim on every inbound
being attached. Real production case this missed: an identity's
stored address came from WireGuard's own fallback subnet
(10.0.0.0/24, used when that inbound has no other clients to infer a
base from), then got attached to a second, AmneziaWG inbound
configured for a completely different subnet (10.8.1.0/24).
defaultAmneziaWGClients's already-set-AllowedIPs branch only checks
for collisions, never subnet membership, so the mismatched address
was accepted silently -- producing a peer that can never actually
connect, since an AmneziaWG address must fall inside the kernel
interface's own configured subnet to be routable at all.

Add addressesFitAmneziaWGInbound, checked per inbound inside Attach's
loop: if the inherited address doesn't fit the SPECIFIC inbound being
attached, clear it just for that one so it gets a fresh, valid
allocation instead, while other already-attached inbounds keep their
existing values. WireGuard has no equivalent strict subnet
requirement (allocateWireguardAddress can widen to a fallback pool
for it), so this only ever constrains AmneziaWG targets.
2026-08-04 11:01:51 +03:00
Kuzz007 8d83a51668 Fix TestOtherTunnelAllowedIPsExcludesSelfEmail's own test setup
CI caught this: the "genuinely different client" (other@wg) was
seeded onto the SAME inbound passed as excludeID, which
otherTunnelAllowedIPs already excludes entirely regardless of the
selfEmails fix -- so the assertion that its address is still
reported could never have passed, proving nothing either way. Move
it onto the sibling inbound alongside shared@id, which is what the
test actually needs to exercise (two clients on one sibling, one
excluded by email, one not).
2026-08-04 10:36:23 +03:00
Kuzz007 51067f162f Attach: allocate fresh when re-attaching with no active tunnel
The previous fix (82cc69f5) made Attach's own address-reuse
correctly not collide with itself across inbounds -- but it still
always reused an identity's stored AllowedIPs verbatim, even when
that identity currently has zero WireGuard/AmneziaWG attachments at
all. A real report from testing this live: an identity fully
detached from both its wg and awg inbounds, then re-attached, got
its old address back even though several lower addresses were free
-- because nothing about being fully detached ever cleared the
stored value Attach copies from.

Add hasTunnelAttachment, checked once against the identity's CURRENT
inbound set before Attach's loop runs: if none of its current
inbounds is WireGuard/AmneziaWG, clear the stored AllowedIPs so this
attach allocates fresh (matching what a brand-new client would get)
instead of resurrecting an address nothing reserves anymore. Left
alone when the identity already has an active tunnel elsewhere, so
extending it to a second protocol still keeps a consistent address.
2026-08-04 10:28:56 +03:00
Kuzz007 82cc69f5e9 Fix Attach reusing one identity's address across wg/awg inbounds
ClientService.Attach deliberately copies one identity's stored
AllowedIPs into every WireGuard/AmneziaWG inbound it's attached to
in the same call, so the same person gets the same tunnel address
on every protocol they use. Its loop calls addInboundClient once per
inbound, and each of those independently computes
otherTunnelAllowedIPs -- so by the second inbound in the batch, the
first inbound's just-written copy of this identity's own address
looked like a cross-inbound collision against itself.

Real production symptom this caused: detaching then re-attaching a
client to both wg and awg failed with "wireguard: allowedIPs entry
X is already used by a client on inbound 'awg' (#N)" -- the exact
address the identity is supposed to keep, rejected as if it belonged
to someone else.

Add a selfEmails exclusion to otherTunnelAllowedIPs and populate it
from the client(s) being processed at the one real call site. Safe
unconditionally: ClientRecord.Email is globally unique, so a match
can only ever be this same identity's own entry on a sibling inbound,
never a genuine different client's address.

Reproduced the underlying mechanism live (manual entry correctly
rejected as a cross-inbound collision; fresh auto-allocation
correctly avoided a used address) before writing the fix, to confirm
the guard itself works and the bug is specifically in how Attach's
per-inbound calls interact with it.
2026-08-04 10:25:31 +03:00
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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007andClaude Sonnet 5 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
Kuzz007 d3da7abdf0 fix: port the PR #6105 review-round fixes into this fork's own AmneziaWG code
Same 8 findings fixed on upstream-pr/amneziawg, ported here since this
fork's internal/amneziawg + related web/service files predate that PR
branch's own fix-up commits:

1. hostRulesFingerprint now folds in a peer's IPv4 whenever
   ForwardedPorts is set, not only when RouteThroughXray is on, so a
   re-IP forces the bounce needed to move the DNAT rule too.
2. ValidateConfigValue (new, params.go) rejects control characters in
   server/client keys, email and I1 at save time; sanitizeConfigValue
   strips them defensively at .conf-render time.
3. checkForwardedPortsConflict now scopes to node_id IS NULL and takes
   a pre-loaded portConflictContext (loadPortConflictContext), so a
   port used only on another node isn't a false collision and an
   inbound with N clients costs one query instead of N.
4. PostDown commands are now best-effort (appendOrTrue) so an external
   firewall flush can't abort the rest of the teardown chain.
5. The "ip rule list | grep -q" existence check now uses
   grep -c >/dev/null, avoiding a pipefail/SIGPIPE false negative that
   could re-add a duplicate rule.
6. route_egress.go's stale "always present, no opt-in" comment
   corrected to describe the real RouteThroughXray-gated behavior.
   (This fork's genAmneziaWGLink already emits vpn://, and there's no
   upstream-facing docs page here, so neither needed the PR branch's
   Finding 6 docs/link-format changes.)
7. install.sh: Arch's ndppd install uses pacman -Sy, not -Syu, matching
   every other pacman call in the script; should_install_amneziawg
   short-circuits to yes when awg is already installed, so `x-ui
   update` doesn't re-prompt -- this fork's own opt-out-by-default
   philosophy for should_install_amneziawg is unchanged, only the
   redundant-reprompt behavior is fixed.
8. CollectTraffic checks pointer identity before writing back a
   traffic-counter baseline, so a concurrent restart's freshly-reset
   (empty) baseline can't be clobbered by stale pre-restart counters.
   sweepOrphansLocked no longer permanently disables itself on a
   transient os.ReadDir failure.

go build/vet/test and frontend typecheck/lint/build/vitest all pass.
2026-07-29 01:21:03 +03:00
Kuzz007 a903ca6793 chore(release): bump version to 3.5.0-awg.5 2026-07-28 00:46:16 +03:00
Kuzz007 bb6a874dc5 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.
2026-07-28 00:27:45 +03:00
Kuzz007 5b58369af2 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.
2026-07-27 23:36:00 +03:00
Kuzz007 89f17f6fef 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.
2026-07-27 23:00:04 +03:00
Kuzz007 f70f7cabf8 chore(release): bump version to 3.5.0-awg.4 2026-07-27 22:41:42 +03:00
Kuzz007 fef9a4b20a 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.
2026-07-27 22:33:09 +03:00
Kuzz007andClaude Sonnet 5 bdee0a2068 fix(xray): force a full restart for TPROXY inbounds, never hot-add them
Real incident: an AmneziaWG inbound with RouteThroughXray enabled lost
all internet on that connection after a migration. Root-caused on the
live box -- iptables TPROXY counters were incrementing (packets
correctly redirected to 127.0.0.1:63110), but nothing was actually
listening there (ss showed nothing on that port) until a full
`systemctl restart x-ui`, after which the bridge came up immediately.

Xray-core's gRPC AddInbound reports success for a new sockopt.tproxy
inbound (internal/amneziawg's own Xray egress bridge is the only kind
this fork ever generates) but doesn't reliably bind a working listener
for it outside of process startup -- the bridge silently never comes
up, and RouteThroughXray traffic goes nowhere until the next full
restart happens to occur for an unrelated reason.

diffInbounds already has this exact defensive pattern for REALITY
inbounds ("a gRPC remove+add does not reliably rebuild the REALITY
authenticator"), just never extended to TPROXY, and only in the
already-existing-then-changed branch -- the "brand new inbound" branch
had no such guard at all, which is exactly the path a freshly-enabled
RouteThroughXray bridge takes. Added inboundUsesTproxy and wired it
into both branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:26:10 +03:00
Kuzz007andClaude Sonnet 5 037ca7330f fix(install): make AmneziaWG the default, opt-out instead of opt-in
AmneziaWG is this fork's whole reason for existing, so requiring an
explicit flag/prompt answer to actually get it on every fresh install,
migration, or update was the wrong default -- confirmed today by a real
migration where it silently stayed uninstalled (interactive default was
"no", and non-interactive/unattended installs skipped it outright with
nothing to answer the prompt).

should_install_amneziawg now defaults to yes in both the interactive
prompt and the non-interactive/unattended path; XUI_INSTALL_AMNEZIAWG
still works as an explicit override in either direction (true/false),
so anyone who genuinely doesn't want the DKMS module + host-wide IP
forwarding can still opt out.

Also fixed the printed opt-out hint to show the actual working syntax
for setting the variable before a later re-run -- `VAR=val curl ... |
bash` only exports VAR into curl's environment, not bash's, since
they're separate processes joined by a pipe. Hit this exact gotcha
live: XUI_INSTALL_AMNEZIAWG=true prefixed onto the curl command was
silently ignored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:33:32 +03:00
Kuzz007andClaude Sonnet 5 d7a922dcf3 fix(frontend): point remaining GitHub repo links at this fork
The sidebar's version badge and the subscription-templates docs link
still hardcoded MHSanaei/3x-ui. install.sh and every README already
point at Kuzz007/3x-ui; these were the last two functional (non-credit)
repo links left pointing upstream. DONATE_URL/DOCS_URL intentionally
left alone -- those point at the original author's own donation page
and docs site, which is deliberate per this fork's existing convention
of crediting/linking upstream for general (non-fork-specific) info.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:25:57 +03:00
Kuzz007andClaude Sonnet 5 ee47aa2608 fix(install): preserve custom bin/ files (e.g. hand-added geoip) across updates
Every reinstall/update wipes /usr/local/x-ui/ wholesale and re-extracts
the release tarball, which only ships known assets (xray/mtg binaries,
the bundled geoip*/geosite*.dat sets). A user-reported real incident:
a hand-placed custom geoip file referenced from a routing rule via
"ext:<file>:<code>" got silently deleted on update, and Xray refused
to start at all afterward ("failed to open <file>: no such file or
directory"), taking down every inbound until the file was manually
restored from the user's own backup.

install_x-ui now backs up the old bin/ before the wipe and restores,
after extraction, only the files the fresh release doesn't provide --
bundled assets still get the newer per-release copy, nothing custom
silently disappears. Verified in isolation: standard files (geoip.dat,
the xray binary) end up as the fresh release's copy; a custom file
absent from the release survives untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:46:19 +03:00
Kuzz007andClaude Sonnet 5 196ec792d4 ci: remove the Docs Deploy (GitHub Pages) workflow
GitHub Pages was never enabled in this fork's repo settings, and the
workflow's own comment ties it to docs.sanaei.dev, upstream's custom
domain, not something this fork has. Failed both times it ever ran
(including once before today, unrelated to any of this session's
other changes) with "Ensure GitHub Pages has been enabled". Docs CI
(docs-ci.yml, lint/build-check only, no deploy) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 01:23:33 +03:00
Kuzz007andClaude Sonnet 5 ea00e69d4d chore: remove Docker support entirely from this fork
Never used by this fork's own distribution (install.sh/x-ui.sh is the
only supported install path), AmneziaWG structurally can't run in the
Alpine-based image anyway, and Docker Hub publishing was failing on
every release for lack of configured credentials. Removed Dockerfile,
docker-compose.yml, DockerEntrypoint.sh, DockerInit.sh, .dockerignore,
and the docker.yml CI workflow; dropped the now-dead "Docker" README
subsection (all 7 languages), the "Docker image"/"Docker Compose"
options from the issue/PR templates, and corrected claude-bot.yml's
now-stale references to the deleted files, the never-actually-ours
ghcr.io/mhsanaei/3x-ui image, and (caught in passing) an already-stale
claim that Windows is a supported platform.

Left untouched: generic container-runtime adaptations that apply
regardless of image source (x-ui.sh's running-in-docker detection,
the virtual-interface-name filters, the DNS-over-container-network
note) and deploy/test/smoke-noninteractive.sh, which uses Docker only
as its own test sandbox, not as something this repo ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 01:13:04 +03:00
Kuzz007andClaude Sonnet 5 ea43571037 chore(release): bump version to 3.5.0-awg.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 00:55:33 +03:00
Kuzz007andClaude Sonnet 5 f1f68e6653 ci: trigger CI on workflow-file changes too
The push/pull_request paths filter didn't include .github/workflows/**,
so the previous commit (fixing the Audit step) never actually ran
through CI -- editing the workflow itself silently skipped the trigger.
A broken workflow-syntax change could merge untested the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 00:35:50 +03:00
Kuzz007andClaude Sonnet 5 422e193b21 fix(ci): actually make the Audit step green, not just documented as accepted
npm audit --audit-level=high doesn't read code comments -- it was still
exiting 1 on the one remaining, already-investigated-as-unfixable advisory
(eslint-plugin-jsx-a11y's own pinned minimatch, GHSA-mh99-v99m-4gvg), so
every run on main has actually been red despite the prior commit's comment
calling it "known-accepted". That advisory lives entirely in
devDependencies (lint tooling, never shipped), so --omit=dev is the
correct signal for "doesn't apply to what we ship" -- verified locally,
0 vulnerabilities. Runtime dependencies are still audited at high severity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 00:30:17 +03:00
Kuzz007andClaude Sonnet 5 70d80fbe8d fix(amneziawg): use the real vpn:// share-link scheme
The AmneziaWG share link (both the panel's per-client copy-link/QR and
the subscription endpoint) used an invented amneziawg://user@host:port
URI the real AmneziaVPN app can't parse -- it only recognizes its own
vpn:// scheme. Reverse-engineered the real app's import path (reading
amnezia-vpn/amnezia-client's own source) and confirmed it just needs
base64url(no padding) of a plain AmneziaWG .conf text -- no JSON schema
or qCompress framing to replicate, since qUncompress falls back to the
raw bytes for plain text and the parser reads a flat "Key = Value" bag
regardless of section. Both link generators now wrap the same .conf
text their own "download config" feature already produces correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 00:16:00 +03:00
Kuzz007andClaude Sonnet 5 3760e01806 fix(clients): remove the redundant download button on the SUB link row
Downloading the raw subscription URL as a .txt file duplicates the
adjacent copy button for the vast majority of actual usage, and the
row already has a real, useful download further down (the per-client
config, e.g. AmneziaWG's own .conf). Left the JSON/Clash rows' download
buttons alone -- only the plain SUB row's was removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 23:52:12 +03:00
Kuzz007andClaude Sonnet 5 032dbabc39 fix(deps): migrate off abandoned react-router-dom, fix eslint's own brace-expansion
Two real, forward-compatible fixes for npm audit's high-severity
advisories (not the downgrades npm audit fix --force offers):

- react-router (GHSA-qwww-vcr4-c8h2, RSC CSRF bypass): react-router-dom
  is frozen at 7.18.1, pinning the vulnerable react-router@7.18.1 -- no
  newer react-router-dom release exists pointing at the fixed line.
  react-router itself has shipped the real fix at 8.3.0. Migrated the 9
  files importing from react-router-dom (all using plain
  createBrowserRouter/RouterProvider/useLocation/useNavigate/Outlet, no
  RSC anywhere) to import from react-router directly instead.

- brace-expansion/minimatch (GHSA-mh99-v99m-4gvg): fixed for eslint's
  own dependency chain (minimatch@10.2.5, used by eslint itself,
  storybook, typescript-eslint, swagger-client) via a scoped
  "minimatch@^10" override forcing brace-expansion to the now-published
  5.0.8 patch -- within the range minimatch@10.2.5 already declares
  wanting (^5.0.5), so this isn't a version-pin workaround, just
  unblocking a patch release npm's resolver hadn't picked up.

One advisory remains genuinely unfixable from our side:
eslint-plugin-jsx-a11y pins minimatch@^3.1.2 (old major, never
patched); forcing it to the 10.x line via override breaks npm's own
dependency-tree validation (a real incompatibility, not just an npm
quirk), so this needs an eslint-plugin-jsx-a11y release bumping its own
minimatch. Lint-time only, no untrusted input reaches it -- ci.yml's
Audit step comment updated to reflect the new, smaller remaining scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 23:31:33 +03:00
Kuzz007andClaude Sonnet 5 f70002d9a6 Revert "fix(routing): insert new rules after api instead of appending at the end"
This reverts commit 638458bd. Reordering new rules to the top by
creation-time was a blunt heuristic: it fixed a rule silently shadowed
by an unrestricted catch-all above it, but breaks the opposite,
equally valid setup -- e.g. a pre-existing "youtube -> interfaceA" rule
(no inboundTag restriction, meant to apply everywhere) followed by a
new "amneziawg -> interfaceB" rule. Under the reverted logic the newer,
broader-matching amneziawg rule would jump above the youtube rule and
shadow it for amneziawg-sourced traffic -- the exact same class of bug
this was meant to fix, just aimed the other way.

Xray's rule order encodes real admin intent (which rule should win for
overlapping traffic) that creation time can't safely stand in for.
Back to plain append-at-the-end; admins reorder manually with the
existing moveUp/moveDown controls, same as upstream's own behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:14:56 +03:00
Kuzz007andClaude Sonnet 5 638458bdea fix(routing): insert new rules after api instead of appending at the end
A new routing rule created through the Routing tab was always appended
to the end of the list. Xray matches rules top-to-bottom, first hit
wins, so any pre-existing broader rule (e.g. a block rule with no
inboundTag restriction) silently shadows a newly created, more specific
rule forever -- it looks saved and enabled but never actually fires.

Root-caused this from a report that an AmneziaWG inbound routed through
a real outbound lost all connectivity while routing it "direct" worked
fine: the AmneziaWG bridge's own TPROXY/interface code turned out to be
entirely fine (tunnel stayed up, client stayed online) -- the new rule
was just sitting below existing RU-IP/bittorrent/domain block rules
with no inbound restriction, so those matched first. Confirmed directly:
manually dragging the rule to sit right after the pinned api rule fixed
it. This isn't AmneziaWG-specific -- any protocol's newly added rule can
be shadowed the same way.

New rules now insert right after the pinned api rule (or at the very
top if it's absent) instead of at the end, so a newly created rule takes
effect by default; drag it lower afterward if a lower priority is
actually wanted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 19:06:27 +03:00
Kuzz007andClaude Sonnet 5 1aa81428b8 fix(traffic): show live Speed for AmneziaWG and MTProto inbounds/clients
The Speed column showed "--" for AmneziaWG (and MTProto, which has the
identical gap) even while cumulative traffic totals were correct.
XrayTrafficJob drives live speed by querying xray-core's own stats API
and broadcasting the delta over websocket -- but AmneziaWG/MTProto never
run inside xray-core's own runtime inbounds, so they're invisible to
that API. Their own jobs already compute the same per-poll delta shape
(that's what keeps cumulative totals correct) but never broadcast it.

Reusing the existing "traffics"/"clientTraffics" broadcast would have
two real bugs: the frontend's existing scope/replace logic would let
each side clobber the other's speed on its next unrelated tick, and the
websocket hub's per-message-type throttle is keyed only by message type,
not caller -- since both sidecar jobs run on identical "@every 10s"
grids registered milliseconds apart, one would silently lose almost
every broadcast if both protocols were ever configured together.

Fixed with a small unthrottled broadcast path (both sidecar jobs are
already self-rate-limited by their own cron cadence) and protocol-
namespaced wire keys, tracked in their own frontend state and merged
into the existing inboundSpeed/clientSpeed only at read time -- so every
existing consumer needs zero changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:27:23 +03:00
Kuzz007andClaude Sonnet 5 0436ac641f docs: add an "Other changes in this fork" section to all READMEs
The fork's README only ever documented AmneziaWG as "what's different
here" -- the new geosite/geoip routing-rule autocomplete had nowhere
appropriate to go. Added a dedicated, appendable section for smaller
fork-specific improvements beyond AmneziaWG, and described the new
autocomplete feature there in all 7 languages. Future fork-specific
features should get a short entry here too, in every README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 17:33:48 +03:00
Kuzz007andClaude Sonnet 5 2736f9beb3 feat(xray): autocomplete geosite/geoip categories in the routing rule editor
Writing a routing rule today means remembering exact geosite:/geoip:/
ext:file:code syntax by hand, with no way to discover what categories
actually exist in the .dat files sitting in the bin folder -- including
custom ones like geosite_roscom.dat added via the Geodata auto-update
feature. The Domain/IP fields in the rule editor now suggest categories
as you type (e.g. "you" -> "geosite:youtube"), built live from whatever
.dat files are actually on disk, while still accepting any free-typed
value exactly as before.

Backend: GET /panel/api/xray/getGeodataCategories scans the bin folder,
parses matched geosite*/geoip*.dat files via xray-core's own exported
protobuf types, and formats each category as the exact rule syntax
xray-core's parser accepts -- geosite:/geoip: for the default files,
ext:<file>:<code> for anything else (there's no shorthand for custom
files). Cached in memory keyed by each file's (name, size, modTime) so
a request-time scan is cheap until a file actually changes.

Frontend: the Domain/IP inputs become Select "tags" fields fed by a new
useGeodataCategories() query hook, with an explicit substring filter so
"you" matches "geosite:youtube" (not a prefix). The array<->CSV-string
adapter lives entirely at the FormField transform boundary, so the
underlying form schema and saved rule shape are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 17:13:07 +03:00
Kuzz007andClaude Sonnet 5 c282c17b5a chore(release): bump version to 3.5.0-awg.2
The published v3.5.0-awg.1 release was built before 814369da, so it still
ships the version-comparison bug that commit fixed. Cutting a new tag from
the corrected tree so the panel's own self-check is actually accurate for
anyone who updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 12:44:47 +03:00
Kuzz007andClaude Sonnet 5 814369da38 fix(panel): recognize this fork's -awg.N tags in version comparison
internal/config/version was never bumped past 3.5.0 when v3.5.0-awg.1 was
tagged, so a freshly-updated panel kept reporting its own version as the
plain upstream base. On top of that, parseVersionParts (Go and its
TypeScript mirror) required exactly 3 dot-separated numeric parts, so it
rejected the -awg.N suffix entirely and fell back to a raw string
inequality that reports "update available" any time the strings merely
differ -- which they always do here, even when already on the latest tag.

Bump the embedded version to 3.5.0-awg.1 and extend both parsers to treat
"-awg.N" as an optional 4th, lower-priority component (defaulting to 0 for
a plain tag), so e.g. 3.5.0-awg.2 > 3.5.0-awg.1 > 3.5.0 and a matching tag
compares equal instead of always looking outdated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 12:42:20 +03:00
Kuzz007andClaude Sonnet 5 0372515aa2 docs(ci): document the 2 known-accepted npm audit advisories
Neither is exploitable in this app (react-router CVE is RSC-only, we use
createBrowserRouter; jsx-a11y's brace-expansion chain only runs against our
own lint globs), and npm's suggested fixes are both downgrades with no real
forward patch published yet -- left as-is rather than trading a working
version for one that doesn't fix anything reachable here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 12:42:02 +03:00
Kuzz007 972514f279 docs: add the stable install command to Quick Start in all READMEs
v3.5.0-awg.1 is now tagged and promoted to Latest, so the plain
no-argument install.sh command resolves to something real for the first
time. Restructured Quick Start in all 7 READMEs to lead with it, followed
by the explicit-version and dev-channel variants (matching upstream's own
three-command Quick Start layout), replacing the now-outdated "this fork
only ever publishes dev-latest" note.
2026-07-26 12:03:43 +03:00
Kuzz007andClaude Sonnet 5 1ed9cd8ea1 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>
2026-07-26 11:42:24 +03:00
Kuzz007andClaude Sonnet 5 ba1a33f307 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>
2026-07-26 11:42:11 +03:00
Kuzz007 79cfcb9966 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.
2026-07-26 11:27:55 +03:00
Kuzz007andClaude Sonnet 5 df6d2f7652 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>
2026-07-26 11:16:39 +03:00
Kuzz007andClaude Sonnet 5 71dc453970 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>
2026-07-26 00:39:32 +03:00
Kuzz007andClaude Sonnet 5 c41f97cf86 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>
2026-07-26 00:16:20 +03:00
Kuzz007andClaude Sonnet 5 f5543047b7 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>
2026-07-25 23:55:11 +03:00
Kuzz007 65513b4854 docs: rewrite all README translations for the AmneziaWG fork
Fa/Ar/Zh/Es/Tr READMEs still described upstream verbatim (generic 3X-UI,
no AmneziaWG section, upstream install command, Windows listed as
supported, Contributing/donation/stargazers sections). Rewrite each to
match the already fork-ified README.md/README.ru_RU.md: fork framing,
the "What's different: AmneziaWG" section, dev-latest install command,
Windows removed from supported platforms, leaner developer-notes/credit
sections. Also restore the 7-language switcher line on all files now
that they're consistent again.
2026-07-25 23:45:07 +03:00
Kuzz007 510b43f32d 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.
2026-07-25 23:27:57 +03:00
Kuzz007andClaude Sonnet 5 db8253421a refactor(amneziawg): route via Xray through the stock Routing page, not custom toggles
Simplifies RouteViaXray after realizing the panel already has everything
needed: the Routing page already lets an admin pick a source inbound tag
and a target outbound (plus, if they want it, a specific source IP) for
any protocol. Bolting a parallel routeThroughXray/routeOutboundTag pair
onto both the client and inbound forms duplicated that mechanism instead
of using it.

Removed entirely: Client/ClientRecord/ServerSettings/Peer's
RouteThroughXray + RouteOutboundTag fields, the effective-routing OR/
fallback logic in InstanceFromInbound, and the Switch+Select UI on both
forms. Nothing configures "route via Xray" as a setting anymore.

In its place, every enabled AmneziaWG inbound now gets its own Xray
TPROXY bridge unconditionally, by default, no toggle:

- internal/amneziawg: every peer's traffic is always TPROXY'd into that
  instance's own bridge (defaultPostUpDown, port derived from the
  inbound's id via EgressPortForInbound so the kernel side and the
  Xray-config side never need to negotiate a runtime value). Since the
  TPROXY rule is now tied to a peer's mere presence rather than an
  opt-in flag, hostRulesFingerprint now covers every peer unconditionally
  (add/remove/re-IP forces a restart, the same way ForwardedPorts always
  did) instead of skipping peers with nothing to opt into.
- internal/web/service/xray.go's injectAmneziawgEgress creates one
  dokodemo-door bridge per qualifying inbound, tagged with that inbound's
  own real tag — the same trick injectMtprotoEgress already uses (reusing
  a real inbound's tag), which is why it's already selectable in the
  panel's Routing page: InboundService.GetInboundTags() is a plain,
  protocol-blind SELECT over every inbound row's tag, no dedicated UI
  plumbing needed. The function never generates a routing rule itself
  anymore — where (if anywhere) that traffic goes is entirely up to
  whatever rules the admin adds through the existing Routing UI.

Frontend: no new UI at all. Tests rewritten to match — one bridge per
inbound with its own tag/port, no rule generation, no opt-in gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:37:08 +03:00
Kuzz007andClaude Sonnet 5 909feefd1d fix(amneziawg): make RouteViaXray an inbound-level option too
RouteThroughXray/RouteOutboundTag were client-only, but the more common
case is "route this whole AmneziaWG server's traffic through Xray", not
configuring every peer individually. Add the same pair to ServerSettings
(inbound-level) while keeping the per-client fields as an override —
matching how ExternalInterface/IPv6Enabled already work at the server
level next to per-client settings like ForwardedPorts.

Effective per-peer decision (computed once, in InstanceFromInbound, not
duplicated at each consumer):
  - routed = client.RouteThroughXray || server.RouteThroughXray
  - outbound tag = client's own if set, else the server's default

This means a peer can be routed by the inbound-wide default with no
config of its own, opt in on its own even when the default is off, or
keep the default's on/off but pick a different outbound than everyone
else.

internal/web/service/xray.go's injectAmneziawgEgress now calls
amneziawg.InstanceFromInbound instead of re-parsing InboundSettings and
reading model.Client fields directly — the same effective-routing
computation the kernel-side TPROXY rules use, so the two independent
reconcile loops (Xray-config generation and the AWG manager) can never
quietly disagree about which peers are actually routed.

Frontend: Switch + conditional outbound Select added to the AWG inbound
form (mirroring the client-form version and mtproto's own UI), plus the
inbound-defaults.ts default-object fix that's bitten this project's CI
before (Phase 2a) whenever ServerSettings gains a new required-shaped
field.

Test fixtures in xray_config_inject_test.go needed a real Server block
and PublicKey once injectAmneziawgEgress started requiring a usable
InstanceFromInbound result — both were implicit fixture gaps, not
behavior the old tests were actually asserting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:01:31 +03:00
Kuzz007andClaude Sonnet 5 d1b77b2aa4 feat(amneziawg): Phase 2c — RouteViaXray (TPROXY into Xray)
Per-client toggle (RouteThroughXray + RouteOutboundTag) that TPROXYs a
peer's traffic into Xray instead of NAT'ing it straight out the host's
network interface, so it can egress through any configured Xray
outbound (or balancer) — a VLESS/proxy chain, WARP, etc.

Discovered mid-design that internal/mtproto already solved the "let a
native sidecar's traffic egress through Xray" problem once, via
routeThroughXray/routeXrayPort/outboundTag + injectMtprotoEgress: a
loopback bridge inbound plus a routing rule. AmneziaWG can't reuse it
directly — mtg is a userspace process that dials *out* through a local
SOCKS proxy, while AmneziaWG is a kernel tunnel interface with no
process of its own to redirect. The Xray-side shape carries over
almost exactly, the kernel-side plumbing is new:

- internal/amneziawg/route_egress.go: EgressPort/EgressTag/EgressFwmark/
  EgressTable are one shared constant set, not one bridge per peer.
  Every routed peer, across every AmneziaWG instance, TPROXYs into the
  *same* loopback dokodemo-door bridge; the per-peer distinction happens
  downstream, in Xray's own router, matched against each peer's
  TPROXY-preserved source IP (Xray's field-rule `source` matcher — a
  capability the router already had). This avoids two independent
  reconcile loops (the AWG manager and the Xray-config generator) ever
  having to agree on a dynamically-picked port for each peer.
- manager.go's defaultPostUpDown emits a per-peer mangle-table TPROXY
  rule (matched by tunnel source IP) for each opted-in peer, plus the
  fwmark->table->local-everywhere policy route TPROXY needs to deliver
  those packets to the bridge. That policy route is system-wide, not
  interface-specific, so — like the existing IPv6-forwarding sysctl —
  it's added idempotently and never torn down in PostDown; a second
  AmneziaWG instance with its own routed peers must find it already in
  place, not race to remove what the first still needs.
- The existing portForwardFingerprint became hostRulesFingerprint,
  covering both ForwardedPorts and RouteThroughXray/RouteOutboundTag:
  both only ever take effect through PostUp/PostDown, which `awg
  syncconf` never re-runs, so either one changing must force the same
  full interface bounce.
- internal/web/service/xray.go's new injectAmneziawgEgress mirrors
  injectMtprotoEgress/injectPanelEgress's safety rules, adapted for one
  bridge serving many peers: an invalid or missing outbound target
  skips only that one peer's rule (not the whole bridge, since other
  peers may still need it), while the bridge itself is skipped
  entirely when nothing needs it or its tag is already taken by a real
  inbound.

Frontend: a Switch + conditional outbound Select on the client form
(showAmneziawg only), mirroring mtproto's own routeThroughXray UI and
reusing its useOutboundTags hook. install.sh now modprobes the
mainline TPROXY modules (xt_TPROXY, nf_tproxy_ipv4/ipv6) alongside the
existing AmneziaWG setup — ordinary upstream kernel modules, no
DKMS/PPA needed unlike the AmneziaWG module itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 18:34:37 +03:00
Kuzz007andClaude Sonnet 5 69de904bf6 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>
2026-07-25 17:54:05 +03:00
Kuzz007andClaude Sonnet 5 9eaae5fd6a 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>
2026-07-25 17:29:30 +03:00
Kuzz007andClaude Sonnet 5 ef13e8567e 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>
2026-07-25 17:25:58 +03:00
Kuzz007andClaude Sonnet 5 a76f0ab233 fix(ci): drop the now-impossible .zip glob from the dev-latest upload
Removing build-windows (previous commit) means dev-artifacts/*.zip
never matches anything — gh release upload treats an unmatched glob as
fatal, so every dev-latest publish since has failed outright (verified:
this run's publish-dev job errored "no matches found for
`dev-artifacts/*.zip`" on all 5 retries). Only the *.tar.gz glob remains.

Also manually deleted the stale x-ui-windows-amd64.zip asset from the
current dev-latest release — it would never be refreshed again
otherwise, and silently going stale forever is worse than not being
there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:08:25 +03:00
Kuzz007andClaude Sonnet 5 ad7361549a ci: drop the Windows build from release.yml
AmneziaWG (awg-quick, the DKMS kernel module) and this whole panel's
real deployment target here are Linux servers/routers — the Windows
job (MSYS2 + CGO cross-build, its own Xray/mtg-multi asset download
step) never served a purpose for this fork and just spent CI minutes
producing a binary nobody uses. Removed build-windows entirely and
dropped it from publish-dev's needs.

Also updated both READMEs' Supported Platforms line to match — this
fork's CI no longer produces a Windows binary, upstream's still does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:01:18 +03:00
Kuzz007andClaude Sonnet 5 9581c34529 docs(readme): rewrite README.md/README.ru_RU.md for this fork
Was still MHSanaei/3x-ui's own upstream README verbatim — wrong install
URL (mhsanaei/master, no AmneziaWG mention), badges pointing at a repo
this fork doesn't publish releases/downloads on, stargazer chart that
would just show a near-flat personal-fork history.

Rewritten to lead with what's actually different here (native
AmneziaWG: no Docker, DKMS-installed by install.sh, full 2.0 obfuscation
params), point Quick Start at Kuzz007/3x-ui's dev-latest channel, note
where AmneziaWG needs Linux/Secure-Boot-off/native-not-Docker, and credit
the base project + the two AmneziaWG references this fork's
implementation was ported from (PR #6086, coinman-dev/3ax-ui).

The other 5 language READMEs (fa_IR/ar_EG/zh_CN/es_ES/tr_TR) still
describe upstream verbatim and are no longer linked from the language
switcher here — left as a follow-up rather than guessing translations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 11:08:05 +03:00
Kuzz007andClaude Sonnet 5 7619c61863 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>
2026-07-25 10:49:02 +03:00
Kuzz007andClaude Sonnet 5 6053ebedfd 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>
2026-07-25 10:28:36 +03:00
Kuzz007andClaude Sonnet 5 5617cdcf31 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>
2026-07-25 10:20:52 +03:00
Kuzz007andClaude Sonnet 5 f075cd75d6 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>
2026-07-25 02:33:51 +03:00
Kuzz007andClaude Sonnet 5 83a68634bb chore: trigger CI to publish the dev-latest release
Empty commit — no code change. Actions on this fork needed a manual
wake-up (workflow_dispatch) before push-triggered runs would fire at
all; that manual run already validated the build (all 8 platforms
succeeded) but couldn't publish dev-latest since that job is gated on
event_name == 'push'. This commit exists purely to produce that push
event now that Actions are confirmed working.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 02:32:37 +03:00
Kuzz007andClaude Sonnet 5 12b002b090 chore: point install/update/self-update at this fork instead of upstream
install.sh, update.sh, and x-ui.sh all hardcoded MHSanaei/3x-ui as the
release/raw-file source (release tarball downloads, the self-update
script fetch, service unit fallback downloads). Left as-is, merging
AmneziaWG into main and cutting a release here would still silently
install upstream's stock binary — none of our work would ever reach a
machine that runs the curl-from-GitHub one-liner.

Repointed all of it at Kuzz007/3x-ui, plus two more functional (not
just doc/attribution) spots the same grep turned up: the panel's own
in-app update checker (internal/web/service/panel/panel.go's
panelUpdaterURL and the GitHub releases API calls in
fetchPanelRelease) and deploy/cloud-init/cloud-init.yaml's
auto-provisioning curl line. Left README/docs/CONTRIBUTING/FUNDING
attribution to the upstream project untouched — this is still a fork
of MHSanaei's work, that credit stays.

Third-party dependency downloads in .github/workflows/release.yml
(Xray-core, geoip/geosite data, mtg-multi) intentionally still point
at their own respective upstream projects, not this fork.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 02:23:26 +03:00
Kuzz007 d3db90c8f1 Merge branch 'feat/amneziawg'
Native AmneziaWG protocol support (backend + frontend) for this fork.
See the three merged commits for details.
2026-07-25 02:19:34 +03:00
Kuzz007andClaude Sonnet 5 266f40d79f 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>
2026-07-25 02:06:03 +03:00
Kuzz007andClaude Sonnet 5 19082fdfe9 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>
2026-07-25 01:33:18 +03:00
Kuzz007andClaude Sonnet 5 83cc545953 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>
2026-07-25 00:39:41 +03:00