From 59dff059c146ab514f76cc91a1473b201c2a90d9 Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 2 Aug 2026 20:01:08 +0300 Subject: [PATCH] feat(amneziawg): retire the kernel-module OS-shellout code and install.sh path Hard cutover, part 3: everything that only ever existed to drive the kernel-module (DKMS) + awg-quick + TPROXY architecture is gone now that internal/amneziawgnet's embedded path is wired in as the real thing. internal/amneziawg/manager.go -> instance.go (renamed, ~90% smaller): kept InstanceFromInbound and its direct helpers (interfaceNameForID, serverAddress, serverAddressV6) plus the exported FirstIPv4 (still used by server.go's access-log email index) -- all pure, protocol-shape-only code with no OS dependency, reused by both the old and new paths historically. Deleted the old Manager (GetManager/Ensure/Reconcile/StopAll/CollectTraffic/ the fingerprint methods), generateServerConfig and everything under it (writeObfuscation, defaultPostUpDown, appendOrTrue, detectDefaultInterface), and process control (interfaceUp/Down, syncConfig, getPeerStats, IsAwgInstalled). route_egress.go deleted entirely (the TPROXY bridge's port/fwmark/table constants and rule-rendering, fully superseded by internal/amneziawgnet's SOCKSPortForInbound/SocksPassword). portfwd.go trimmed to just the parsing/validation half (ForwardedPortsInclude, still used for save-time conflict checks); the iptables DNAT rendering half is gone -- per-client port-forwarding has no equivalent under the embedded path yet (tracked as Phase 3.6). install.sh: removed install_ndppd, enable_ipv6_forwarding, enable_tproxy_support, should/install_amneziawg, and check_secure_boot (and their call sites) -- roughly 265 lines. No more DKMS build, PPA/keyring setup, TPROXY kernel module loading, or Secure Boot warning: the embedded path needs none of it. Not in this commit (tracked as an explicit follow-up, not silently dropped): the frontend's routeThroughXray toggle is now vestigial (the field stays in the Go/JSON schema for backward compat with existing stored settings, see types.go) but its UI/schema removal needs the frontend type-regen + openapi.json hand-patch dance this fork always does for a settings-shape change, which is its own separate pass. Co-Authored-By: Claude Sonnet 5 --- install.sh | 265 ------ internal/amneziawg/instance.go | 143 +++ internal/amneziawg/instance_test.go | 122 +++ internal/amneziawg/manager.go | 1045 ---------------------- internal/amneziawg/manager_test.go | 566 ------------ internal/amneziawg/params.go | 15 +- internal/amneziawg/params_test.go | 9 - internal/amneziawg/portfwd.go | 104 +-- internal/amneziawg/route_egress.go | 83 -- internal/amneziawg/types.go | 49 +- internal/web/service/inbound_protocol.go | 2 +- 11 files changed, 312 insertions(+), 2091 deletions(-) create mode 100644 internal/amneziawg/instance.go create mode 100644 internal/amneziawg/instance_test.go delete mode 100644 internal/amneziawg/manager.go delete mode 100644 internal/amneziawg/manager_test.go delete mode 100644 internal/amneziawg/route_egress.go diff --git a/install.sh b/install.sh index 75d302919..71b2bbf77 100644 --- a/install.sh +++ b/install.sh @@ -123,234 +123,6 @@ install_base() { esac } -url_reachable() { - curl --connect-timeout 5 --max-time 10 -sSIL -o /dev/null "$1" 2>/dev/null -} - -# Probes URL reachability before relying on it (namely the AmneziaWG PPA host, -# which hosting providers — especially Russian VPS — frequently block). -# Non-interactive installs always skip-and-continue rather than block on a -# prompt; interactive installs ask, defaulting to skip so a flaky network -# doesn't abort the whole run over one optional feature. -check_url_or_skip() { - local url="$1" - local label="$2" - if url_reachable "$url"; then - return 0 - fi - echo "" - echo -e "${yellow}══════════════════════════════════════════════════════${plain}" - echo -e "${yellow} Failed to reach: ${url}${plain}" - echo -e "${yellow} Module / file: ${label}${plain}" - echo -e "${yellow}══════════════════════════════════════════════════════${plain}" - if [[ "$NONINTERACTIVE" == "1" ]]; then - echo -e "${yellow}Non-interactive install: skipping ${label}.${plain}" - return 1 - fi - read -rp "Continue without it? [Y/n]: " __skip_choice - case "${__skip_choice,,}" in - n | no) - echo -e "${red}Aborted by user.${plain}" - exit 1 - ;; - *) - echo -e "${yellow}Skipping ${label}.${plain}" - return 1 - ;; - esac -} - -# Installs ndppd (IPv6 NDP proxy), used by a future AmneziaWG IPv6 mode so -# clients can get a native public IPv6 address without NAT66. Not wired into -# the panel yet (tracked separately) — installed now so it's already in place -# once that lands. Best-effort: never fatal. -install_ndppd() { - case "${release}" in - ubuntu | debian | armbian) - apt-get install -y -q ndppd 2>/dev/null || true - ;; - fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos) - dnf install -y ndppd 2>/dev/null || yum install -y ndppd 2>/dev/null || true - ;; - arch | manjaro | parch) - # -Sy (not -Syu): every other pacman call in this script only - # refreshes the package database, never does a full system - # upgrade as a side effect of installing one package. - pacman -Sy --noconfirm ndppd 2>/dev/null || true - ;; - esac -} - -# Persists IPv4/IPv6 forwarding across reboots. AmneziaWG's own PostUp already -# sets net.ipv4.ip_forward=1 for the current boot (see -# internal/amneziawg/manager.go's defaultPostUpDown), so this is a belt-and- -# suspenders persistence step, not the only place it's set. -enable_ipv6_forwarding() { - # Checking /etc/sysctl.conf by name is not reliable: many distros split - # sysctl settings across /etc/sysctl.d/*.conf, and /etc/sysctl.conf is - # sometimes just a symlink into that directory, so grep can miss an - # already-active setting (false negative -> harmless duplicate line) or - # match a disabled/commented one (false positive -> forwarding silently - # stays off). Querying the live value directly is accurate regardless of - # which file actually set it. - if [ "$(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null)" != "1" ]; then - echo "net.ipv6.conf.all.forwarding = 1" >> /etc/sysctl.conf - fi - if [ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null)" != "1" ]; then - echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf - fi - sysctl -p >/dev/null 2>&1 || true -} - -# Loads the mainline TPROXY kernel modules, used by AmneziaWG's optional -# per-client "route via Xray" toggle (see internal/amneziawg's EgressPort and -# defaultPostUpDown's `-j TPROXY` rules). Unlike the AmneziaWG module itself, -# these are standard upstream modules present on any modern distro kernel — -# no DKMS/PPA needed, just loading them. Best-effort: a panel without them -# still works fine, that one toggle just won't redirect traffic until -# they're available. -enable_tproxy_support() { - modprobe xt_TPROXY 2>/dev/null || true - modprobe nf_tproxy_ipv4 2>/dev/null || true - modprobe nf_tproxy_ipv6 2>/dev/null || true -} - -# Installs the AmneziaWG DKMS kernel module + amneziawg-tools (awg/awg-quick) -# so an AmneziaWG inbound created in the panel can actually bring up an -# interface. Best-effort and never fatal to the overall x-ui install: the -# panel works fine without it, an AmneziaWG inbound just won't start its -# tunnel until the module is installed (surfaced in the panel/logs, not here). -# AmneziaWG is this fork's signature feature, so it installs by default on -# every install/migration/update (see should_install_amneziawg below) -- -# opt-out, not opt-in, via XUI_INSTALL_AMNEZIAWG=false for anyone who -# specifically doesn't want the DKMS kernel module + host-wide IPv4/IPv6 -# forwarding it brings. -# -# should_install_amneziawg decides whether to run install_amneziawg at all. -# Short-circuits to yes when awg is already on PATH, so `x-ui update` on a -# host that already has it doesn't re-prompt an admin who already answered -# this once -- install_amneziawg's own case statement would just skip the -# actual DKMS/package work again anyway, but the interactive prompt itself -# still fired every run, and answering "n" out of habit (since AmneziaWG is -# already installed and working) skipped the harmless modprobe/ndppd/sysctl -# refresh that same case statement also does unconditionally. -# XUI_INSTALL_AMNEZIAWG=true/false answers it outright (for non-interactive/ -# cloud-init runs); otherwise an interactive install prompts (default: yes), -# and a non-interactive one with nothing to answer the prompt defaults to -# installing it too. -should_install_amneziawg() { - command -v awg &>/dev/null && return 0 - case "${XUI_INSTALL_AMNEZIAWG:-}" in - true | TRUE | 1 | yes | y | Y) return 0 ;; - false | FALSE | 0 | no | n | N) return 1 ;; - esac - if [[ "$NONINTERACTIVE" == "1" ]]; then - return 0 - fi - local reply - read -rp "Install native AmneziaWG support (WireGuard + DPI-resistant obfuscation)? This builds a DKMS kernel module and enables host-wide IP forwarding. (Y/n): " reply - [[ -z "$reply" || "$reply" == "y" || "$reply" == "Y" ]] -} - -# ppa:amnezia/ppa (Ubuntu/Debian/Armbian) is the primary, tested path; other -# distros fall back to plain wireguard-tools with a manual-install pointer. -# See https://github.com/amnezia-vpn/amneziawg-linux-kernel-module. -# -# Also requires Secure Boot to be OFF (checked separately, see -# check_secure_boot below) — a DKMS-built module is unsigned and the kernel -# refuses to load it while Secure Boot is enforced. -install_amneziawg() { - if command -v awg &>/dev/null; then - echo -e "${green}AmneziaWG (awg) already installed.${plain}" - modprobe amneziawg 2>/dev/null || true - install_ndppd - enable_ipv6_forwarding - enable_tproxy_support - return - fi - - echo -e "${green}Installing AmneziaWG...${plain}" - export DEBIAN_FRONTEND=noninteractive - export DEBCONF_NONINTERACTIVE_SEEN=true - - case "${release}" in - ubuntu | debian | armbian) - if ! check_url_or_skip "https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu/dists/focal/Release" "AmneziaWG (ppa.launchpadcontent.net)"; then - echo -e "${yellow}Install it manually later if needed:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - install_ndppd - return - fi - echo -e "${yellow}Installing amneziawg from ppa:amnezia/ppa...${plain}" - apt-get install -y -q software-properties-common python3-launchpadlib gnupg2 "linux-headers-$(uname -r)" 2>/dev/null || true - # Ensure deb-src is present (required for the PPA's DKMS build). - if ! grep -q "^deb-src" /etc/apt/sources.list 2>/dev/null; then - grep "^deb " /etc/apt/sources.list | sed 's/^deb /deb-src /' >> /etc/apt/sources.list - fi - if [[ "${release}" == "ubuntu" ]]; then - add-apt-repository -y ppa:amnezia/ppa 2>/dev/null && - apt-get update -q && - apt-get install -y amneziawg && - echo -e "${green}AmneziaWG installed successfully via PPA.${plain}" || - echo -e "${red}PPA install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - else - # apt-key is deprecated/removed on Debian 12+ and Ubuntu 24.04; - # fetch the key into its own keyring file and reference it via - # signed-by= instead of the removed system-wide trust store. - local amneziawg_keyring="/etc/apt/keyrings/amneziawg.gpg" - local amneziawg_list_entry="deb [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" - local amneziawg_src_entry="deb-src [signed-by=${amneziawg_keyring}] https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu focal main" - install -d -m 755 /etc/apt/keyrings - gpg --no-default-keyring --keyring "$amneziawg_keyring" --keyserver keyserver.ubuntu.com --recv-keys 57290828 2>/dev/null || true - # Guarded so a retried install (the PPA step failed last time, - # or install.sh simply ran again) doesn't keep appending - # duplicate sources.list entries. - grep -qxF "$amneziawg_list_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_list_entry" >> /etc/apt/sources.list - grep -qxF "$amneziawg_src_entry" /etc/apt/sources.list 2>/dev/null || echo "$amneziawg_src_entry" >> /etc/apt/sources.list - apt-get update -q && - apt-get install -y amneziawg && - echo -e "${green}AmneziaWG installed successfully.${plain}" || - echo -e "${red}Install failed. Install amneziawg manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - fi - modprobe amneziawg 2>/dev/null || true - install_ndppd - ;; - fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol | centos) - echo -e "${yellow}AmneziaWG has no prebuilt package for ${release}. Installing WireGuard as a fallback...${plain}" - dnf install -y -q wireguard-tools 2>/dev/null || yum install -y wireguard-tools 2>/dev/null || true - echo -e "${yellow}Note: for full AmneziaWG (obfuscated) support, install amneziawg-tools manually:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - install_ndppd - ;; - arch | manjaro | parch) - pacman -Sy --noconfirm wireguard-tools 2>/dev/null || true - if command -v yay &>/dev/null; then - yay -S --noconfirm amneziawg-dkms amneziawg-tools 2>/dev/null || true - elif command -v paru &>/dev/null; then - paru -S --noconfirm amneziawg-dkms amneziawg-tools 2>/dev/null || true - else - echo -e "${yellow}Install an AUR helper (yay/paru) for amneziawg-dkms, or build it manually:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - fi - install_ndppd - ;; - *) - echo -e "${yellow}${release}: no automated AmneziaWG install path. Install it manually if needed:${plain}" - echo -e "${yellow} https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" - ;; - esac - - if command -v awg &>/dev/null; then - echo -e "${green}awg: $(awg --version 2>/dev/null || echo 'installed')${plain}" - else - echo -e "${yellow}Warning: 'awg' binary not found. The panel will work, but an AmneziaWG${plain}" - echo -e "${yellow}inbound's tunnel will not start until you install it manually.${plain}" - fi - - enable_ipv6_forwarding - enable_tproxy_support -} - gen_random_string() { local length="$1" openssl rand -base64 $((length * 2)) \ @@ -1966,41 +1738,4 @@ install_x-ui() { echo -e "${green}Running...${plain}" install_base -if should_install_amneziawg; then - install_amneziawg -else - echo -e "${yellow}Skipping AmneziaWG setup. To install it later, re-run with the variable${plain}" - echo -e "${yellow}exported first (a piped 'VAR=val curl ... | bash' only sets it for curl,${plain}" - echo -e "${yellow}not for bash -- export it in the current shell instead):${plain}" - echo -e "${yellow} export XUI_INSTALL_AMNEZIAWG=true${plain}" - echo -e "${yellow} curl -fsSL https://raw.githubusercontent.com/Kuzz007/3x-ui/main/install.sh | bash${plain}" - echo -e "${yellow}...or install it manually: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module${plain}" -fi install_x-ui $1 - -# Secure Boot blocks the AmneziaWG DKMS module from loading (it's unsigned). -# Try mokutil first, fall back to reading the EFI variable directly. -check_secure_boot() { - if command -v mokutil &>/dev/null; then - mokutil --sb-state 2>/dev/null | grep -q "SecureBoot enabled" - return $? - fi - local sb_var - sb_var=$(find /sys/firmware/efi/efivars -name "SecureBoot-*" 2>/dev/null | head -1) - if [[ -n "$sb_var" ]]; then - [[ "$(od -An -tu1 -j4 -N1 "$sb_var" 2>/dev/null | tr -d ' ')" == "1" ]] - return $? - fi - return 1 -} - -if command -v awg &>/dev/null && check_secure_boot; then - echo -e "" - echo -e "${red}[!] WARNING: Secure Boot is ENABLED${plain}" - echo -e "${yellow}AmneziaWG's kernel module is unsigned and cannot load while Secure Boot${plain}" - echo -e "${yellow}is active — AmneziaWG tunnels will NOT work until it is disabled.${plain}" - echo -e "${yellow}Fix: turn off Secure Boot in your VPS provider's control panel, or in${plain}" - echo -e "${yellow}the VM's firmware/BIOS settings, then reboot. No reinstall needed${plain}" - echo -e "${yellow}afterward — AmneziaWG will start working on its own.${plain}" - echo -e "" -fi diff --git a/internal/amneziawg/instance.go b/internal/amneziawg/instance.go new file mode 100644 index 000000000..c6459b0d4 --- /dev/null +++ b/internal/amneziawg/instance.go @@ -0,0 +1,143 @@ +// Package amneziawg holds the AmneziaWG protocol's shared, DB-backed shapes +// (Instance, Peer, Obfuscation20, 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 by internal/web/service/server.go's +// amneziawgEmailIndex to derive a peer's tunnel IPv4 address for the panel's +// access-log viewer. +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 "" +} diff --git a/internal/amneziawg/instance_test.go b/internal/amneziawg/instance_test.go new file mode 100644 index 000000000..1ec382b36 --- /dev/null +++ b/internal/amneziawg/instance_test.go @@ -0,0 +1,122 @@ +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 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) + } +} diff --git a/internal/amneziawg/manager.go b/internal/amneziawg/manager.go deleted file mode 100644 index 90206ec0a..000000000 --- a/internal/amneziawg/manager.go +++ /dev/null @@ -1,1045 +0,0 @@ -package amneziawg - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "maps" - "net" - "net/netip" - "os" - "os/exec" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" -) - -// configDir is where awg-quick expects to find .conf, matching -// the AmneziaWG DKMS package's own layout. -const configDir = "/etc/amnezia/amneziawg" - -// onlineWindow is how recent a peer's last handshake must be to count it as -// online, matching the typical WireGuard rekey interval (every 120s) plus -// margin. -const onlineWindow = 180 * time.Second - -// 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". -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 -} - -// structuralFingerprint changes whenever a value that requires a full -// interface bounce (awg-quick down + up) changes. -func (inst Instance) structuralFingerprint() string { - o := inst.Obfuscation - parts := []string{ - inst.InterfaceName, - strconv.Itoa(inst.ListenPort), - inst.PrivateKey, - strings.Join(inst.Address, ","), - strconv.Itoa(inst.MTU), - strconv.Itoa(o.Jc), strconv.Itoa(o.Jmin), strconv.Itoa(o.Jmax), - strconv.Itoa(o.S1), strconv.Itoa(o.S2), strconv.Itoa(o.S3), strconv.Itoa(o.S4), - o.H1, o.H2, o.H3, o.H4, o.I1, - inst.ExternalInterface, - strconv.FormatBool(inst.IPv6Enabled), - inst.IPv6ExternalInterface, - strconv.FormatBool(inst.RouteThroughXray), - } - return strings.Join(parts, "|") -} - -// peersFingerprint identifies the reloadable peer set regardless of order, so -// a reordered clients array in the stored settings does not read as a -// change. It moves whenever a peer is added, removed, disabled, re-keyed, or -// re-addressed — all of which `awg syncconf` applies in place. Deliberately -// excludes ForwardedPorts: those live in PostUp/PostDown, not the WireGuard -// peer table, so a ports-only change needs hostRulesFingerprint's full -// bounce instead of a syncconf reload. -func (inst Instance) peersFingerprint() string { - pairs := make([]string, 0, len(inst.Peers)) - for _, p := range inst.Peers { - pairs = append(pairs, fmt.Sprintf("%s=%s;psk=%s;ips=%s", p.Email, p.PublicKey, p.PresharedKey, strings.Join(p.AllowedIPs, ","))) - } - slices.Sort(pairs) - return strings.Join(pairs, "|") -} - -// hostRulesFingerprint identifies per-peer state that only ever takes effect -// through PostUp/PostDown shell rules — forwarded ports (whose DNAT rules -// are keyed on the peer's IPv4 address, the same as the TPROXY rule below); -// when RouteThroughXray is on, every peer's IPv4 address (the TPROXY rule -// into this instance's own Xray bridge is keyed on it); and when IPv6 is -// enabled, the peer's IPv6 address (its NDP-proxy PostUp/PostDown entry) — -// rather than the WireGuard peer table itself. It is checked separately from -// peersFingerprint because `awg syncconf` never re-runs PostUp/PostDown, so -// a change here must force a full interface bounce (ensureRestart) to -// actually take effect, unlike a key-only change that syncconf can apply in -// place. The IPv4 component is included whenever RouteThroughXray is on OR -// the peer has forwarded ports — either one means PostUp/PostDown text is -// keyed on that address, so a re-IP with either feature off must still force -// a bounce (otherwise the old DNAT/TPROXY rule survives pointed at an -// address the reconciler is now free to hand to a different peer). The IPv6 -// component stays IPv6Enabled-gated only, matching the single feature that -// reads it. Skipping both entirely when neither applies preserves the -// syncconf fast path for a plain instance's peer add/remove/re-IP. -func (inst Instance) hostRulesFingerprint() string { - pairs := make([]string, 0, len(inst.Peers)) - for _, p := range inst.Peers { - v := fmt.Sprintf("%s=fwd:%s", p.Email, p.ForwardedPorts) - if inst.RouteThroughXray || p.ForwardedPorts != "" { - v += ";ip:" + FirstIPv4(p.AllowedIPs) - } - if inst.IPv6Enabled { - v += ";ip6:" + firstIPv6(p.AllowedIPs) - } - pairs = append(pairs, v) - } - slices.Sort(pairs) - return strings.Join(pairs, "|") -} - -// peerCounters is the last-seen cumulative transfer counters for one peer, -// used to compute per-poll deltas the same way mtproto tracks per-secret -// counters. -type peerCounters struct { - rx int64 - tx int64 -} - -type managed struct { - inst Instance - structuralFP string - peersFP string - hostRulesFP string - last map[string]peerCounters // keyed by peer public key -} - -// Manager owns the set of running AmneziaWG interfaces keyed by inbound id. -type Manager struct { - mu sync.Mutex - ifaces map[int]*managed - // swept records that the one-time startup cleanup of orphaned interfaces - // (survivors of a previous x-ui run) has already run. - swept bool -} - -var ( - managerOnce sync.Once - manager *Manager -) - -// GetManager returns the process-wide AmneziaWG manager singleton. -func GetManager() *Manager { - managerOnce.Do(func() { - manager = &Manager{ifaces: map[int]*managed{}} - }) - return manager -} - -// ensureAction is what ensureLocked must do to move a running interface to a -// desired instance: leave it alone, hot-reload just its peers, or fully -// bounce it. -type ensureAction int - -const ( - ensureNoop ensureAction = iota - ensureReload - ensureRestart -) - -// ensureActionFor decides how to apply a desired instance to the currently -// managed interface. A structural change, a host-rules change (forwarded -// ports, or simply a peer's presence/IP — its always-on TPROXY rule only -// lives in PostUp/PostDown), or a down interface all force a restart; a -// peers-only change (keys only, no IP/presence change) is a candidate for -// an in-place `syncconf`; identical fingerprints on an up interface need -// nothing. -func ensureActionFor(up bool, curStructFP, curHostRulesFP, curPeersFP, newStructFP, newHostRulesFP, newPeersFP string) ensureAction { - if !up || curStructFP != newStructFP || curHostRulesFP != newHostRulesFP { - return ensureRestart - } - if curPeersFP != newPeersFP { - return ensureReload - } - return ensureNoop -} - -// Ensure brings one interface to its desired state, or restarts/reloads it -// when its configuration changed. A no-op when it already matches. -func (m *Manager) Ensure(inst Instance) error { - m.mu.Lock() - defer m.mu.Unlock() - return m.ensureLocked(inst) -} - -func (m *Manager) ensureLocked(inst Instance) error { - structFP := inst.structuralFingerprint() - hostRulesFP := inst.hostRulesFingerprint() - peersFP := inst.peersFingerprint() - - cur, exists := m.ifaces[inst.Id] - action := ensureRestart - if exists { - action = ensureActionFor(isInterfaceUp(cur.inst.InterfaceName), cur.structuralFP, cur.hostRulesFP, cur.peersFP, structFP, hostRulesFP, peersFP) - } - - switch action { - case ensureNoop: - cur.inst = inst - return nil - case ensureReload: - if err := writeConfigFile(inst); err != nil { - return err - } - if err := syncConfig(inst); err != nil { - return err - } - case ensureRestart: - // Checked against the interface's actual kernel state, not `exists`: - // after an ungraceful exit (kill -9, OOM, panic) the previous - // process's interface can still be up even though this fresh - // Manager has never seen it (exists is always false on a cold - // start). Skipping the teardown in that case would send - // interfaceUp straight into "ip link add" against a name that - // already exists, which fails and leaves this inbound stuck - // retrying every reconcile forever. - if isInterfaceUp(inst.InterfaceName) { - _ = interfaceDown(inst.InterfaceName) - } - if err := writeConfigFile(inst); err != nil { - return err - } - if err := interfaceUp(inst.InterfaceName); err != nil { - return err - } - logger.Infof("amneziawg: started interface %s for inbound %d", inst.InterfaceName, inst.Id) - } - - last := map[string]peerCounters{} - if exists { - last = nextTrafficBaseline(action, cur.last) - } - m.ifaces[inst.Id] = &managed{inst: inst, structuralFP: structFP, hostRulesFP: hostRulesFP, peersFP: peersFP, last: last} - return nil -} - -// nextTrafficBaseline decides what per-peer traffic counters ensureLocked -// should carry into the next managed entry. Only a reload (awg syncconf) -// preserves the kernel's own per-peer transfer counters; a full down+up -// zeroes them. Carrying the old baseline forward after a restart would make -// the next CollectTraffic compute a large negative delta (clamped to 0 by -// the caller), silently discarding whatever the peers transferred since the -// previous poll instead of just resuming the count from zero. -func nextTrafficBaseline(action ensureAction, prev map[string]peerCounters) map[string]peerCounters { - if action == ensureReload { - return prev - } - return map[string]peerCounters{} -} - -// Remove tears down and forgets the interface for an inbound id. -func (m *Manager) Remove(id int) { - m.mu.Lock() - defer m.mu.Unlock() - if cur, ok := m.ifaces[id]; ok { - _ = interfaceDown(cur.inst.InterfaceName) - removeConfigFile(cur.inst.InterfaceName) - delete(m.ifaces, id) - logger.Infof("amneziawg: stopped interface %s for inbound %d", cur.inst.InterfaceName, id) - } -} - -// sweepOrphansLocked tears down any AmneziaWG interface and config file left -// behind by a previous x-ui process whose inbound is no longer in the -// current desired set — most commonly because it was deleted from the -// database entirely while the panel was down, so it will never again appear -// in any future Reconcile call and would otherwise never be discovered (it -// has no entry in m.ifaces for the per-id cleanup loop below to catch, -// because that map always starts empty on a fresh process). Runs once per -// process lifetime, mirroring mtproto.Manager.sweepOrphansLocked. -// -// Deliberately only called from Reconcile, not Ensure: Ensure only ever -// carries a single instance, and a `want` set of just that one id would -// misidentify every other still-desired-but-not-yet-reconciled-this-process -// interface as an orphan. A crashed-but-still-wanted interface is instead -// recovered normally by ensureLocked's ensureRestart branch, which checks -// the interface's actual kernel state rather than this manager's in-memory -// bookkeeping. -func (m *Manager) sweepOrphansLocked(want map[int]struct{}) { - if m.swept { - return - } - entries, err := os.ReadDir(configDir) - if err != nil { - // Left false on purpose: a transient error (the directory not existing - // yet, a momentary filesystem hiccup) should let the next Reconcile - // tick retry the sweep, rather than permanently disabling it for this - // process's whole lifetime over a failure that may not recur. - return - } - m.swept = true - names := make([]string, 0, len(entries)) - for _, entry := range entries { - if !entry.IsDir() { - names = append(names, entry.Name()) - } - } - for _, ifaceName := range orphanedInterfaces(names, want) { - if isInterfaceUp(ifaceName) { - _ = interfaceDown(ifaceName) - logger.Warningf("amneziawg: tore down orphaned interface %s (its inbound no longer exists)", ifaceName) - } - removeConfigFile(ifaceName) - } -} - -// orphanedInterfaces returns the interface names among confFileNames (the -// basenames of configDir's entries) whose parsed inbound id is not present -// in want — the pure decision sweepOrphansLocked acts on. -func orphanedInterfaces(confFileNames []string, want map[int]struct{}) []string { - var out []string - for _, name := range confFileNames { - if !strings.HasSuffix(name, ".conf") { - continue - } - ifaceName := strings.TrimSuffix(name, ".conf") - id, ok := inboundIDForInterfaceName(ifaceName) - if !ok { - continue - } - if _, wanted := want[id]; wanted { - continue - } - out = append(out, ifaceName) - } - return out -} - -// inboundIDForInterfaceName parses the inbound id back out of an interface -// name produced by interfaceNameForID, e.g. "awg42" -> 42, ok=true. Requires -// the suffix to be all decimal digits so a stray or hand-crafted file name -// (e.g. "awg-1.conf") can never resolve to a negative id. -func inboundIDForInterfaceName(name string) (int, bool) { - suffix, ok := strings.CutPrefix(name, "awg") - if !ok || suffix == "" { - return 0, false - } - for _, r := range suffix { - if r < '0' || r > '9' { - return 0, false - } - } - id, err := strconv.Atoi(suffix) - if err != nil { - return 0, false - } - return id, true -} - -// Reconcile drives the running set toward the desired instances: it tears -// down interfaces that are no longer wanted and ensures the rest. Used at -// boot and periodically to recover from crashes or an out-of-band `awg-quick -// down`. -func (m *Manager) Reconcile(desired []Instance) { - m.mu.Lock() - defer m.mu.Unlock() - want := make(map[int]struct{}, len(desired)) - for _, inst := range desired { - want[inst.Id] = struct{}{} - } - m.sweepOrphansLocked(want) - for id, cur := range m.ifaces { - if _, ok := want[id]; !ok { - _ = interfaceDown(cur.inst.InterfaceName) - removeConfigFile(cur.inst.InterfaceName) - delete(m.ifaces, id) - logger.Infof("amneziawg: stopped interface %s for removed inbound %d", cur.inst.InterfaceName, id) - } - } - for _, inst := range desired { - if err := m.ensureLocked(inst); err != nil { - logger.Warningf("amneziawg: reconcile failed for inbound %d: %v", inst.Id, err) - } - } -} - -// 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 { - _ = interfaceDown(cur.inst.InterfaceName) - delete(m.ifaces, id) - } -} - -// HasRunning reports whether any managed interface is currently up. -func (m *Manager) HasRunning() bool { - m.mu.Lock() - defer m.mu.Unlock() - for _, cur := range m.ifaces { - if isInterfaceUp(cur.inst.InterfaceName) { - return true - } - } - return false -} - -// Traffic is a per-peer traffic delta scraped from `awg show dump`. -// Tag is the owning inbound's tag and Email is the client the bytes belong -// to. -type Traffic struct { - Tag string - Email string - Up int64 - Down int64 -} - -// CollectTraffic polls `awg show dump` for every running interface -// and returns the per-peer byte deltas since the previous poll, plus the -// emails of peers with a handshake inside onlineWindow. -func (m *Manager) CollectTraffic() ([]Traffic, []string) { - type snap struct { - id int - inst Instance - last map[string]peerCounters - // entry is the exact *managed snapshotted below, kept so the - // write-back can detect a concurrent ensureRestart/ensureReload - // (which replaces the map entry with a fresh pointer, see - // ensureLocked) that happened while getPeerStats ran lock-free. - entry *managed - } - m.mu.Lock() - snaps := make([]snap, 0, len(m.ifaces)) - for id, cur := range m.ifaces { - lastCopy := make(map[string]peerCounters, len(cur.last)) - maps.Copy(lastCopy, cur.last) - snaps = append(snaps, snap{id: id, inst: cur.inst, last: lastCopy, entry: cur}) - } - m.mu.Unlock() - - var out []Traffic - var online []string - now := time.Now() - - for _, s := range snaps { - stats, err := getPeerStats(s.inst.InterfaceName) - if err != nil { - continue - } - emailByKey := make(map[string]string, len(s.inst.Peers)) - for _, p := range s.inst.Peers { - emailByKey[p.PublicKey] = p.Email - } - - newLast := make(map[string]peerCounters, len(stats)) - for _, st := range stats { - email, ok := emailByKey[st.publicKey] - if !ok || email == "" { - continue - } - newLast[st.publicKey] = peerCounters{rx: st.rx, tx: st.tx} - if st.latestHandshake > 0 && now.Sub(time.Unix(st.latestHandshake, 0)) < onlineWindow { - online = append(online, email) - } - prev, had := s.last[st.publicKey] - if !had { - continue - } - du := st.rx - prev.rx // client upload = bytes the server received - dd := st.tx - prev.tx // client download = bytes the server sent - if du < 0 { - du = 0 - } - if dd < 0 { - dd = 0 - } - if du > 0 || dd > 0 { - out = append(out, Traffic{Tag: s.inst.Tag, Email: email, Up: du, Down: dd}) - } - } - - m.mu.Lock() - // Only write back if this is still the exact entry snapshotted above: - // getPeerStats ran without the lock held, so ensureLocked could have - // restarted (or reloaded) this same interface in the meantime, - // replacing the map entry with a fresh *managed and, for a restart, - // resetting last to empty (kernel counters zero on down+up). Writing - // newLast back over that unconditionally would silently resurrect the - // pre-restart counters as the new baseline, making the next poll - // compute a negative delta and clamp a real poll's worth of traffic - // to zero. - if cur, ok := m.ifaces[s.id]; ok && cur == s.entry { - cur.last = newLast - } - m.mu.Unlock() - } - return out, online -} - -// --- config rendering --- - -// generateServerConfig builds the awg-quick .conf content for an interface: -// its own [Interface] block (keys, address, obfuscation, NAT PostUp/PostDown) -// followed by one [Peer] block per client. -func generateServerConfig(inst Instance) string { - var b strings.Builder - - b.WriteString("[Interface]\n") - fmt.Fprintf(&b, "PrivateKey = %s\n", sanitizeConfigValue(inst.PrivateKey)) - if len(inst.Address) > 0 { - fmt.Fprintf(&b, "Address = %s\n", strings.Join(inst.Address, ", ")) - } - fmt.Fprintf(&b, "ListenPort = %d\n", inst.ListenPort) - if inst.MTU > 0 { - fmt.Fprintf(&b, "MTU = %d\n", inst.MTU) - } - writeObfuscation(&b, inst.Obfuscation) - - ext := inst.ExternalInterface - if ext == "" { - ext = detectDefaultInterface() - } - postUp, postDown := defaultPostUpDown(inst, ext) - fmt.Fprintf(&b, "PostUp = %s\n", postUp) - fmt.Fprintf(&b, "PostDown = %s\n", postDown) - - for _, p := range inst.Peers { - b.WriteString("\n[Peer]\n") - if p.Email != "" { - fmt.Fprintf(&b, "# %s\n", sanitizeConfigValue(p.Email)) - } - fmt.Fprintf(&b, "PublicKey = %s\n", sanitizeConfigValue(p.PublicKey)) - if p.PresharedKey != "" { - fmt.Fprintf(&b, "PresharedKey = %s\n", sanitizeConfigValue(p.PresharedKey)) - } - fmt.Fprintf(&b, "AllowedIPs = %s\n", strings.Join(p.AllowedIPs, ", ")) - } - - return b.String() -} - -// sanitizeConfigValue strips newlines, carriage returns, and other control -// characters from a value about to be interpolated into the generated -// .conf. ValidateConfigValue rejects these at save time, but a row that -// predates that validation (an upgrade, a node sync, a restored backup, a -// direct DB edit) would otherwise still reach awg-quick's parser, where a -// newline lets a later line re-open a new section and smuggle in a hook -// awg-quick executes as root. This is the render-time backstop; it -// silently drops the offending bytes rather than failing the whole config -// build, matching how hOrDefault degrades a blank H value instead of -// emitting an invalid line. -func sanitizeConfigValue(v string) string { - return strings.Map(func(r rune) rune { - if r == '\n' || r == '\r' || r < 0x20 || r == 0x7f { - return -1 - } - return r - }, v) -} - -// writeObfuscation writes the AmneziaWG obfuscation parameters that must be -// identical on both ends of a tunnel. S3/S4 and I1 are emitted only when set, -// so a plain 1.x-equivalent set (S3=S4=0, I1="") produces the classic -// generator's output; a 2.0 set adds the extra padding, header ranges and CPS -// packet. -func writeObfuscation(b *strings.Builder, o Obfuscation20) { - fmt.Fprintf(b, "Jc = %d\n", o.Jc) - fmt.Fprintf(b, "Jmin = %d\n", o.Jmin) - fmt.Fprintf(b, "Jmax = %d\n", o.Jmax) - fmt.Fprintf(b, "S1 = %d\n", o.S1) - fmt.Fprintf(b, "S2 = %d\n", o.S2) - if o.S3 > 0 { - fmt.Fprintf(b, "S3 = %d\n", o.S3) - } - if o.S4 > 0 { - fmt.Fprintf(b, "S4 = %d\n", o.S4) - } - fmt.Fprintf(b, "H1 = %s\n", hOrDefault(o.H1, "1")) - fmt.Fprintf(b, "H2 = %s\n", hOrDefault(o.H2, "2")) - fmt.Fprintf(b, "H3 = %s\n", hOrDefault(o.H3, "3")) - fmt.Fprintf(b, "H4 = %s\n", hOrDefault(o.H4, "4")) - if o.I1 != "" { - fmt.Fprintf(b, "I1 = %s\n", sanitizeConfigValue(o.I1)) - } -} - -// hOrDefault returns def when v is blank, guarding against an empty H value -// (which would emit an invalid "H1 = " line) on legacy/partial records. -func hOrDefault(v, def string) string { - if strings.TrimSpace(v) == "" { - return def - } - return v -} - -// defaultPostUpDown returns NAT + forwarding rules: MASQUERADE the tunnel -// subnet out the external interface, accept forwarded traffic in both -// directions, and — when the instance has IPv6 enabled — the IPv6-forward -// rules, proxy_ndp sysctl, and one `ip -6 neigh add proxy` entry per enabled -// peer with an IPv6 address, so upstream routers see each client's IPv6 as -// directly reachable on the LAN without NAT66. Also emits DNAT+FORWARD rules -// for each enabled peer with a non-empty ForwardedPorts spec, and — only -// when the instance has RouteThroughXray enabled — a mangle-table TPROXY -// rule redirecting every peer's traffic into this instance's own Xray -// bridge (see EgressPortForInbound), plus the one-time policy route TPROXY -// needs to deliver it there. RouteThroughXray is off by default: a plain -// AmneziaWG tunnel has no Xray dependency at all unless the admin opts in. -// When it is on, it is entirely up to the admin's own Xray Routing rules -// (targeting this inbound's own tag, which injectAmneziawgEgress reuses for -// the bridge) whether that traffic ever actually goes anywhere beyond -// Xray's default routing. -func defaultPostUpDown(inst Instance, ext string) (postUp, postDown string) { - iface := inst.InterfaceName - up := []string{ - fmt.Sprintf("iptables -A FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("iptables -A FORWARD -o %s -j ACCEPT", iface), - } - down := []string{ - fmt.Sprintf("iptables -D FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("iptables -D FORWARD -o %s -j ACCEPT", iface), - } - if subnet := firstAddress(inst.Address); subnet != "" && ext != "" { - up = append([]string{fmt.Sprintf("iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, up...) - down = append([]string{fmt.Sprintf("iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE", subnet, ext)}, down...) - } - - if inst.IPv6Enabled { - ext6 := inst.IPv6ExternalInterface - if ext6 == "" { - ext6 = ext - } - up = append(up, - fmt.Sprintf("ip6tables -A FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -A FORWARD -o %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -A FORWARD -i %s -o %s -j ACCEPT", ext6, iface), - "sysctl -w net.ipv6.conf.all.forwarding=1", - fmt.Sprintf("sysctl -w net.ipv6.conf.%s.proxy_ndp=1", ext6), - ) - down = append(down, - fmt.Sprintf("ip6tables -D FORWARD -i %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -D FORWARD -o %s -j ACCEPT", iface), - fmt.Sprintf("ip6tables -D FORWARD -i %s -o %s -j ACCEPT", ext6, iface), - ) - for _, p := range inst.Peers { - ip6 := firstIPv6(p.AllowedIPs) - if ip6 == "" { - continue - } - up = append(up, fmt.Sprintf("ip -6 neigh add proxy %s dev %s", ip6, ext6)) - down = append(down, fmt.Sprintf("ip -6 neigh del proxy %s dev %s", ip6, ext6)) - } - } - - for _, p := range inst.Peers { - if p.ForwardedPorts == "" { - continue - } - clientIP := FirstIPv4(p.AllowedIPs) - if clientIP == "" { - continue - } - up = append(up, portForwardLines("-A", ext, iface, clientIP, p.Email, p.ForwardedPorts)...) - down = append(down, portForwardLines("-D", ext, iface, clientIP, p.Email, p.ForwardedPorts)...) - } - - if inst.RouteThroughXray { - egressPort := EgressPortForInbound(inst.Id) - anyPeerTproxied := false - for _, p := range inst.Peers { - clientIP := FirstIPv4(p.AllowedIPs) - if clientIP == "" { - continue - } - up = append(up, routeEgressLines("-A", iface, clientIP, p.Email, egressPort)...) - down = append(down, routeEgressLines("-D", iface, clientIP, p.Email, egressPort)...) - anyPeerTproxied = true - } - if anyPeerTproxied { - // The fwmark->table->local-everywhere policy route is what lets TPROXY - // deliver a peer's packets to this instance's own Xray bridge even - // though their destination is never one of this host's own addresses. - // It is system-wide, not interface-specific, so — like the - // IPv6-forwarding sysctl above — it is added idempotently here and - // never torn down in PostDown; a second AmneziaWG instance must find - // it already in place, not race to remove what the first still needs. - // "ip rule add" is not itself idempotent (a second call inserts a - // duplicate rather than deduplicating), and hostRulesFingerprint keys - // on every peer's presence/IP when RouteThroughXray is on, so PostUp - // re-runs on any client add/remove/re-IP — without the existence - // check below, "ip rule show" would accumulate one duplicate entry - // per bounce forever. - // - // TPROXY never rewrites the packet's own destination address — only - // the routing decision changes, via the fwmark+table trick above — so - // by the time this packet reaches the host's own INPUT chain, its - // destination still looks like some remote address (e.g. 8.8.8.8), - // never this host's own. A default-deny firewall whose INPUT chain - // sanity-checks "is this destination actually local" (UFW's - // ufw-not-local, using addrtype --dst-type LOCAL, is exactly this) can - // never see it as legitimate and silently drops it before Xray's - // socket ever sees a single byte — TPROXY's own counters keep - // incrementing the whole time, making this look like a Xray-side bug - // even though Xray never gets the chance to fail. The fix is the same - // shape as the policy route above: an idempotent, never-torn-down, - // system-wide accept for this fwmark, inserted at the very front of - // the base INPUT chain so it runs before any such sanity check, - // regardless of which firewall manager (ufw, firewalld, bare - // iptables) owns the rest of that chain. - up = append(up, - // grep -c (not -q): -q exits as soon as it matches, so "ip rule - // list" can take SIGPIPE; under `set -o pipefail` the pipeline then - // reports 141 even though the rule WAS found, and "ip rule add" - // below runs anyway -- reintroducing the exact duplicate-rule - // accumulation this existence check exists to prevent. -c reads - // every line to completion and still exits 1 on no match. - fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null || ip rule add fwmark %#x lookup %d", EgressFwmark, EgressTable, EgressFwmark, EgressTable), - fmt.Sprintf("ip route replace local 0.0.0.0/0 dev lo table %d", EgressTable), - fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT 2>/dev/null || iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark, EgressFwmark), - ) - } - } - - up = append(up, "sysctl -w net.ipv4.ip_forward=1") - return strings.Join(up, "; "), strings.Join(appendOrTrue(down), "; ") -} - -// appendOrTrue suffixes every command with " || true", making the whole -// PostDown chain best-effort. wg-quick/awg-quick joins hook commands with -// "; " and runs the result under `set -e -o pipefail`, so the first non-zero -// command aborts everything after it. On teardown that matters: if -// something has already flushed the filter table out from under the -// interface (a ufw/firewalld reload, fail2ban rebuilding its chains), the -// first "-D" fails and every command after it — including the nat-table DNAT -// deletes a flush does NOT remove — is skipped, and the next PostUp re-adds -// them, accumulating one set per bounce. PostUp is left alone: a real setup -// failure there should still surface, not be silently swallowed. -func appendOrTrue(cmds []string) []string { - out := make([]string, len(cmds)) - for i, c := range cmds { - out[i] = c + " || true" - } - return out -} - -// firstAddress returns the first configured interface address, used as the -// NAT source subnet for PostUp/PostDown. -func firstAddress(addresses []string) string { - if len(addresses) == 0 { - return "" - } - return addresses[0] -} - -// firstIPv6 returns the first IPv6 address (mask stripped) among allowedIPs, -// or "" if none — used to build one NDP proxy PostUp/PostDown entry per peer. -func firstIPv6(allowedIPs []string) string { - for _, a := range allowedIPs { - if prefix, err := netip.ParsePrefix(a); err == nil { - if prefix.Addr().Is6() { - return prefix.Addr().String() - } - continue - } - if addr, err := netip.ParseAddr(a); err == nil && addr.Is6() { - return addr.String() - } - } - return "" -} - -// FirstIPv4 returns the first IPv4 address (mask stripped) among allowedIPs, -// or "" if none — used as the DNAT target for a peer's forwarded ports and, -// by internal/web/service's injectAmneziawgEgress, as the source-IP match for -// a routed peer's Xray rule. Exported so both packages derive a peer's -// tunnel IPv4 address the exact same way. -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 "" -} - -// detectDefaultInterface returns the first non-loopback, non-tunnel, UP -// interface that has a routable IPv4 address. Falls back to "eth0" only if -// nothing is found. -func detectDefaultInterface() string { - ifaces, err := net.Interfaces() - if err != nil { - return "eth0" - } - for _, iface := range ifaces { - if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { - continue - } - if strings.HasPrefix(iface.Name, "awg") || strings.HasPrefix(iface.Name, "wg") || - strings.HasPrefix(iface.Name, "docker") || strings.HasPrefix(iface.Name, "br-") || - strings.HasPrefix(iface.Name, "veth") { - continue - } - addrs, err := iface.Addrs() - if err != nil || len(addrs) == 0 { - continue - } - for _, addr := range addrs { - if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLinkLocalUnicast() && ipNet.IP.To4() != nil { - return iface.Name - } - } - } - return "eth0" -} - -// --- process control --- - -func configPath(interfaceName string) string { - return filepath.Join(configDir, interfaceName+".conf") -} - -// writeConfigFile renders and persists the .conf file awg-quick reads. -func writeConfigFile(inst Instance) error { - if err := os.MkdirAll(configDir, 0o700); err != nil { - return fmt.Errorf("amneziawg: create config dir: %w", err) - } - if err := os.WriteFile(configPath(inst.InterfaceName), []byte(generateServerConfig(inst)), 0o600); err != nil { - return fmt.Errorf("amneziawg: write config for %s: %w", inst.InterfaceName, err) - } - return nil -} - -// removeConfigFile deletes the config file for an interface, best-effort. -func removeConfigFile(interfaceName string) { - if err := os.Remove(configPath(interfaceName)); err != nil && !os.IsNotExist(err) { - logger.Warningf("amneziawg: failed to remove config file for %s: %v", interfaceName, err) - } -} - -// awgCommandTimeout bounds every short-lived awg/awg-quick invocation so a -// hung command (e.g. a stuck kernel module operation) can't block the -// reconcile job indefinitely. -const awgCommandTimeout = 30 * time.Second - -// interfaceUp brings an AmneziaWG interface up via awg-quick. -func interfaceUp(interfaceName string) error { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "awg-quick", "up", configPath(interfaceName)).CombinedOutput() - if err != nil { - return fmt.Errorf("awg-quick up %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err) - } - return nil -} - -// interfaceDown takes an AmneziaWG interface down via awg-quick. -func interfaceDown(interfaceName string) error { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "awg-quick", "down", configPath(interfaceName)).CombinedOutput() - if err != nil { - return fmt.Errorf("awg-quick down %s failed: %s: %w", interfaceName, strings.TrimSpace(string(out)), err) - } - return nil -} - -// isInterfaceUp checks whether the named AmneziaWG interface currently -// exists. -func isInterfaceUp(interfaceName string) bool { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - return exec.CommandContext(ctx, "awg", "show", interfaceName).Run() == nil -} - -// syncConfig applies a peers-only config change without dropping existing -// connections on other peers, falling back to a full restart when the live -// interface won't accept the diff (or isn't up yet). -func syncConfig(inst Instance) error { - if !isInterfaceUp(inst.InterfaceName) { - return interfaceUp(inst.InterfaceName) - } - - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - stripped, err := exec.CommandContext(ctx, "awg-quick", "strip", configPath(inst.InterfaceName)).Output() - if err != nil { - logger.Warningf("amneziawg: awg-quick strip failed for %s, restarting: %v", inst.InterfaceName, err) - return restartInterface(inst.InterfaceName) - } - - syncCtx, syncCancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer syncCancel() - sync := exec.CommandContext(syncCtx, "awg", "syncconf", inst.InterfaceName, "/dev/stdin") - sync.Stdin = bytes.NewReader(stripped) - if out, err := sync.CombinedOutput(); err != nil { - logger.Warningf("amneziawg: awg syncconf failed for %s, restarting: %s: %v", inst.InterfaceName, strings.TrimSpace(string(out)), err) - return restartInterface(inst.InterfaceName) - } - return nil -} - -// restartInterface performs a full down+up cycle. -func restartInterface(interfaceName string) error { - _ = interfaceDown(interfaceName) - return interfaceUp(interfaceName) -} - -// peerStat is one peer's runtime stats parsed from `awg show dump`. -type peerStat struct { - publicKey string - latestHandshake int64 // unix seconds - rx int64 // bytes received from the peer (its upload) - tx int64 // bytes sent to the peer (its download) -} - -// getPeerStats parses `awg show dump`. The dump format is -// tab-separated: line 1 is the interface (private-key, public-key, -// listen-port, fwmark); each following line is one peer (public-key, -// preshared-key, endpoint, allowed-ips, latest-handshake, transfer-rx, -// transfer-tx, persistent-keepalive). -func getPeerStats(interfaceName string) ([]peerStat, error) { - ctx, cancel := context.WithTimeout(context.Background(), awgCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, "awg", "show", interfaceName, "dump").Output() - if err != nil { - return nil, fmt.Errorf("awg show %s dump failed: %w", interfaceName, err) - } - - var stats []peerStat - scanner := bufio.NewScanner(bytes.NewReader(out)) - first := true - for scanner.Scan() { - if first { - first = false - continue - } - fields := strings.Split(scanner.Text(), "\t") - if len(fields) < 8 { - continue - } - handshake, _ := strconv.ParseInt(fields[4], 10, 64) - rx, _ := strconv.ParseInt(fields[5], 10, 64) - tx, _ := strconv.ParseInt(fields[6], 10, 64) - stats = append(stats, peerStat{publicKey: fields[0], latestHandshake: handshake, rx: rx, tx: tx}) - } - return stats, nil -} - -// IsAwgInstalled reports whether the awg and awg-quick binaries are on PATH. -func IsAwgInstalled() bool { - _, err1 := exec.LookPath("awg") - _, err2 := exec.LookPath("awg-quick") - return err1 == nil && err2 == nil -} diff --git a/internal/amneziawg/manager_test.go b/internal/amneziawg/manager_test.go deleted file mode 100644 index 6a6d1e061..000000000 --- a/internal/amneziawg/manager_test.go +++ /dev/null @@ -1,566 +0,0 @@ -package amneziawg - -import ( - "encoding/json" - "fmt" - "slices" - "strings" - "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 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) - } - } -} - -// fixedObfuscation is a deterministic Obfuscation20 for tests that compare -// two instances for equality — GenerateObfuscation20 is randomized per call -// by design (see its doc comment) and must never be used where the test -// expects two "identical" instances to actually match. -func fixedObfuscation() Obfuscation20 { - return Obfuscation20{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: ""} -} - -func baseInstance() Instance { - return Instance{ - Id: 1, - Tag: "awg-1", - InterfaceName: "awg1", - ListenPort: 51820, - PrivateKey: "priv", - PublicKey: "pub", - Address: []string{"10.8.1.1/24"}, - Obfuscation: fixedObfuscation(), - Peers: []Peer{ - {Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}}, - {Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}}, - }, - } -} - -func TestStructuralFingerprintStableAndSensitive(t *testing.T) { - a := baseInstance() - b := baseInstance() - if a.structuralFingerprint() != b.structuralFingerprint() { - t.Fatal("identical instances must produce the same structural fingerprint") - } - b.ListenPort = 51821 - if a.structuralFingerprint() == b.structuralFingerprint() { - t.Fatal("a listen port change must change the structural fingerprint") - } - c := baseInstance() - c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"} - if a.structuralFingerprint() != c.structuralFingerprint() { - t.Fatal("a peer-only change must NOT change the structural fingerprint") - } - - d := baseInstance() - d.IPv6Enabled = true - if a.structuralFingerprint() == d.structuralFingerprint() { - t.Fatal("enabling IPv6 must change the structural fingerprint") - } - - e := baseInstance() - e.IPv6Enabled = true - f := baseInstance() - f.IPv6Enabled = true - f.IPv6ExternalInterface = "eth1" - if e.structuralFingerprint() == f.structuralFingerprint() { - t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op") - } - - g := baseInstance() - g.RouteThroughXray = true - if a.structuralFingerprint() == g.structuralFingerprint() { - t.Fatal("toggling RouteThroughXray must change the structural fingerprint -- it changes whether PostUp/PostDown contain any TPROXY rules at all") - } -} - -func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) { - a := baseInstance() - reordered := baseInstance() - reordered.Peers[0], reordered.Peers[1] = reordered.Peers[1], reordered.Peers[0] - if a.peersFingerprint() != reordered.peersFingerprint() { - t.Fatal("reordering peers must not change the peers fingerprint") - } - - changed := baseInstance() - changed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if a.peersFingerprint() == changed.peersFingerprint() { - t.Fatal("changing a peer's AllowedIPs must change the peers fingerprint") - } - - fewer := baseInstance() - fewer.Peers = fewer.Peers[:1] - if a.peersFingerprint() == fewer.peersFingerprint() { - t.Fatal("removing a peer must change the peers fingerprint") - } -} - -func TestEnsureActionFor(t *testing.T) { - cases := []struct { - name string - up bool - curStruct, curHostRules, curPeers string - newStruct, newHostRules, newPeers string - want ensureAction - }{ - {"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart}, - {"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart}, - {"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart}, - {"peer-ip change (its TPROXY rule) forces restart", true, "s", "ip:old", "p", "s", "ip:new", "p", ensureRestart}, - {"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload}, - {"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := ensureActionFor(c.up, c.curStruct, c.curHostRules, c.curPeers, c.newStruct, c.newHostRules, c.newPeers) - if got != c.want { - t.Errorf("ensureActionFor() = %v, want %v", got, c.want) - } - }) - } -} - -func TestNextTrafficBaseline(t *testing.T) { - prev := map[string]peerCounters{"pubA": {rx: 100, tx: 200}} - - if got := nextTrafficBaseline(ensureReload, prev); len(got) != 1 || got["pubA"] != prev["pubA"] { - t.Errorf("a reload must preserve the previous baseline (syncconf never resets kernel counters), got %v", got) - } - if got := nextTrafficBaseline(ensureRestart, prev); len(got) != 0 { - t.Errorf("a restart must reset the baseline to empty (awg-quick down+up zeroes kernel counters), got %v", got) - } -} - -func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) { - a := baseInstance() - b := baseInstance() - if a.hostRulesFingerprint() != b.hostRulesFingerprint() { - t.Fatal("identical instances must produce the same host-rules fingerprint") - } - - forwarded := baseInstance() - forwarded.Peers[0].ForwardedPorts = "80,443" - if a.hostRulesFingerprint() == forwarded.hostRulesFingerprint() { - t.Fatal("adding ForwardedPorts must change the host-rules fingerprint") - } - - fewer := baseInstance() - fewer.Peers = fewer.Peers[:1] - if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() { - t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer peer entry exists regardless of what's tracked per peer") - } - - // RouteThroughXray off (baseInstance's default): no TPROXY rule depends - // on a peer's IPv4 address, so re-IPing one must NOT force a bounce -- - // this is the whole point of making the bridge opt-in: an instance that - // never uses it keeps the syncconf fast path for a plain re-IP. - reIPedNoRoute := baseInstance() - reIPedNoRoute.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if a.hostRulesFingerprint() != reIPedNoRoute.hostRulesFingerprint() { - t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint") - } - - // A peer with forwarded ports has its DNAT rule keyed on its IPv4 address - // too, regardless of RouteThroughXray -- re-IPing it must force a bounce, - // or the old DNAT rule survives pointed at an address a different peer - // can be handed next. - forwardedReIPed := baseInstance() - forwardedReIPed.Peers[0].ForwardedPorts = "80,443" - forwardedBase := baseInstance() - forwardedBase.Peers[0].ForwardedPorts = "80,443" - forwardedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if forwardedBase.hostRulesFingerprint() == forwardedReIPed.hostRulesFingerprint() { - t.Fatal("with RouteThroughXray off but ForwardedPorts set, changing a peer's IP must change the host-rules fingerprint -- its DNAT rule is keyed on that IP") - } - - // RouteThroughXray on: now the TPROXY rule really is keyed on the IP. - routed := baseInstance() - routed.RouteThroughXray = true - routedReIPed := baseInstance() - routedReIPed.RouteThroughXray = true - routedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"} - if routed.hostRulesFingerprint() == routedReIPed.hostRulesFingerprint() { - t.Fatal("with RouteThroughXray on, changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP") - } - - // IPv6Enabled off (baseInstance's default): no NDP-proxy entry depends - // on a peer's IPv6 address either, so adding one must not force a bounce. - ip6AddedNoIPv6 := baseInstance() - ip6AddedNoIPv6.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"} - if a.hostRulesFingerprint() != ip6AddedNoIPv6.hostRulesFingerprint() { - t.Fatal("with IPv6Enabled off, adding a peer's IPv6 address must NOT change the host-rules fingerprint") - } - - ip6Base := baseInstance() - ip6Base.IPv6Enabled = true - ip6Added := baseInstance() - ip6Added.IPv6Enabled = true - ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"} - if ip6Base.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() { - t.Fatal("with IPv6Enabled on, adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp") - } -} - -func TestRouteEgressComment(t *testing.T) { - if got := routeEgressComment(""); got != "awg-route" { - t.Errorf("empty email must fall back to awg-route, got %q", got) - } - a := routeEgressComment("a@x") - b := routeEgressComment("b@x") - if a == b { - t.Fatal("different emails must produce different comment tags") - } - if a != routeEgressComment("a@x") { - t.Fatal("the same email must always produce the same comment tag") - } -} - -func TestRouteEgressLines(t *testing.T) { - up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x", 63101) - if len(up) != 2 { - t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up) - } - for _, proto := range []string{"tcp", "udp"} { - found := false - for _, l := range up { - if !strings.Contains(l, "-p "+proto) { - continue - } - found = true - if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") || - !strings.Contains(l, "--on-port 63101") || - !strings.Contains(l, "--on-ip 127.0.0.1") || - !strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) || - !strings.Contains(l, "-A PREROUTING") { - t.Errorf("%s line missing expected fields: %s", proto, l) - } - } - if !found { - t.Errorf("missing a %s TPROXY line in %v", proto, up) - } - } - if strings.Contains(up[0], "10.8.1.2/32") { - t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0]) - } - - down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x", 63101) - if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") { - t.Fatalf("expected symmetric -D lines, got %v", down) - } - - if got := routeEgressLines("-A", "awg1", "", "a@x", 63101); got != nil { - t.Errorf("empty clientIP must yield no lines, got %v", got) - } -} - -func TestEgressPortForInbound(t *testing.T) { - if got := EgressPortForInbound(1); got != EgressBasePort+1 { - t.Errorf("EgressPortForInbound(1) = %d, want %d", got, EgressBasePort+1) - } - if EgressPortForInbound(1) == EgressPortForInbound(2) { - t.Fatal("different inbound ids must derive different ports") - } -} - -func TestDefaultPostUpDownOmitsTproxyWhenRouteThroughXrayOff(t *testing.T) { - inst := baseInstance() // RouteThroughXray defaults to false - up, down := defaultPostUpDown(inst, "eth0") - - if strings.Contains(up, "TPROXY") || strings.Contains(up, "ip rule add fwmark") { - t.Errorf("RouteThroughXray off must emit no TPROXY/policy-route lines in PostUp, got:\n%s", up) - } - if strings.Contains(down, "TPROXY") { - t.Errorf("RouteThroughXray off must emit no TPROXY lines in PostDown, got:\n%s", down) - } -} - -func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testing.T) { - inst := baseInstance() // two peers, a@x and b@x - inst.RouteThroughXray = true - up, down := defaultPostUpDown(inst, "eth0") - - wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id)) - if !strings.Contains(up, "TPROXY") || !strings.Contains(up, wantPort) { - t.Errorf("expected TPROXY rules targeting this instance's own bridge port in PostUp, got:\n%s", up) - } - if !strings.Contains(down, "TPROXY") { - t.Errorf("expected matching TPROXY removals in PostDown, got:\n%s", down) - } - if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) { - t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up) - } - // grep -c, not -q: -q's early exit can SIGPIPE "ip rule list" and, under - // pipefail, make the existence check itself report failure even when the - // rule was found -- which would re-run "ip rule add" and reintroduce the - // exact duplicate this check exists to prevent. - if wantCheck := fmt.Sprintf("ip rule list | grep -c 'fwmark %#x lookup %d' >/dev/null", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) { - t.Errorf("expected a pipefail-safe existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up) - } - if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") { - t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it") - } - // Both peers get TPROXY'd once opted in: 2 peers * 2 protocols. - if got := strings.Count(up, "TPROXY"); got != 4 { - t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up) - } - - none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all - upNone, _ := defaultPostUpDown(none, "eth0") - if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") { - t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone) - } -} - -// TPROXY never rewrites a packet's own destination address -- only the -// routing decision changes -- so a default-deny INPUT chain that sanity-checks -// "is this destination actually local" (e.g. UFW's ufw-not-local, via -// addrtype --dst-type LOCAL) drops it before Xray's socket ever sees it, even -// though TPROXY's own mangle-table counters keep incrementing the whole time. -// This was a real, hard-to-diagnose production outage: RouteThroughXray -// looked fully configured (TPROXY rule present, Xray socket listening with -// IP_TRANSPARENT set) yet every peer's traffic silently vanished. -func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *testing.T) { - inst := baseInstance() // two peers, a@x and b@x - inst.RouteThroughXray = true - up, down := defaultPostUpDown(inst, "eth0") - - wantCheck := fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT", EgressFwmark) - wantInsert := fmt.Sprintf("iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark) - if !strings.Contains(up, wantCheck) || !strings.Contains(up, wantInsert) { - t.Errorf("expected an idempotent INPUT accept for the shared fwmark in PostUp, got:\n%s", up) - } - if strings.Contains(down, "-m mark --mark") { - t.Error("the shared INPUT accept must never be removed in PostDown -- other instances may still need it, same as the policy route") - } - - none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all - upNone, _ := defaultPostUpDown(none, "eth0") - if strings.Contains(upNone, "-m mark --mark") { - t.Errorf("an instance with no peers must not emit the INPUT accept either, got:\n%s", upNone) - } -} - -// PostDown is joined with "; " and run under `set -e`, so one command that -// fails because something already flushed the firewall state out from under -// the interface (a ufw/firewalld reload) would otherwise abort every command -// after it -- including the nat-table DNAT deletes a filter-table flush does -// NOT remove, which then survive and accumulate across bounces. Every -// teardown command must be best-effort; PostUp must not be. -func TestDefaultPostUpDownMakesEveryTeardownCommandBestEffort(t *testing.T) { - inst := baseInstance() // two peers, a@x and b@x - inst.RouteThroughXray = true - inst.IPv6Enabled = true - inst.Peers[0].ForwardedPorts = "80,443" - up, down := defaultPostUpDown(inst, "eth0") - - for _, cmd := range strings.Split(down, "; ") { - if !strings.HasSuffix(cmd, "|| true") { - t.Errorf("every PostDown command must end with '|| true' so a flushed firewall doesn't abort the rest, got: %q", cmd) - } - } - if strings.Contains(up, "|| true") { - t.Error("PostUp must stay strict -- a real setup failure there should surface, not be silently swallowed") - } -} - -func TestGenerateServerConfigContainsExpectedLines(t *testing.T) { - inst := baseInstance() - inst.ExternalInterface = "eth0" - cfg := generateServerConfig(inst) - - want := []string{ - "[Interface]", - "PrivateKey = priv", - "Address = 10.8.1.1/24", - "ListenPort = 51820", - "[Peer]", - "PublicKey = pubA", - "PresharedKey = pskA", - "AllowedIPs = 10.8.1.2/32", - "PublicKey = pubB", - "AllowedIPs = 10.8.1.3/32", - "MASQUERADE", - } - for _, w := range want { - if !strings.Contains(cfg, w) { - t.Errorf("generated config missing %q\n---\n%s", w, cfg) - } - } - // The second peer has no PresharedKey — its block must not emit the field at all. - if strings.Count(cfg, "PresharedKey") != 1 { - t.Errorf("expected exactly one PresharedKey line (peer b@x has none), got config:\n%s", cfg) - } -} - -func TestWriteObfuscationDefaultsBlankH(t *testing.T) { - var b strings.Builder - writeObfuscation(&b, Obfuscation20{}) - out := b.String() - for i, want := range []string{"H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4"} { - if !strings.Contains(out, want) { - t.Errorf("blank H%d must fall back to default %q, got:\n%s", i+1, want, out) - } - } - // S3/S4/I1 are zero-valued here and must be omitted entirely. - if strings.Contains(out, "S3") || strings.Contains(out, "S4") || strings.Contains(out, "I1") { - t.Errorf("zero-valued S3/S4/I1 must be omitted, got:\n%s", out) - } -} - -func TestInterfaceNameForID(t *testing.T) { - if got := interfaceNameForID(42); got != "awg42" { - t.Errorf("interfaceNameForID(42) = %q, want awg42", got) - } -} - -func TestInboundIDForInterfaceName(t *testing.T) { - cases := []struct { - name string - wantID int - wantOK bool - }{ - {"awg42", 42, true}, - {"awg0", 0, true}, - {"awg", 0, false}, // no digits after the prefix - {"wg0", 0, false}, // wrong prefix entirely (plain WireGuard) - {"awgabc", 0, false}, // non-numeric suffix - {"awg-1", 0, false}, // Atoi rejects the leading '-' as part of TrimPrefix's leftover, but guard anyway - } - for _, c := range cases { - id, ok := inboundIDForInterfaceName(c.name) - if ok != c.wantOK || (ok && id != c.wantID) { - t.Errorf("inboundIDForInterfaceName(%q) = (%d, %v), want (%d, %v)", c.name, id, ok, c.wantID, c.wantOK) - } - } -} - -func TestOrphanedInterfaces(t *testing.T) { - confFiles := []string{ - "awg1.conf", // in want -> not orphaned - "awg2.conf", // not in want -> orphaned - "awg3.conf", // not in want -> orphaned - "notes.txt", // wrong suffix -> ignored - "awgxyz.conf", // unparseable id -> ignored - } - want := map[int]struct{}{1: {}} - - got := orphanedInterfaces(confFiles, want) - slices.Sort(got) - if wantOut := []string{"awg2", "awg3"}; !slices.Equal(got, wantOut) { - t.Errorf("orphanedInterfaces() = %v, want %v", got, wantOut) - } -} - -func TestOrphanedInterfacesEmptyWantOrphansEverything(t *testing.T) { - got := orphanedInterfaces([]string{"awg5.conf"}, map[int]struct{}{}) - if want := []string{"awg5"}; !slices.Equal(got, want) { - t.Errorf("orphanedInterfaces() = %v, want %v", got, want) - } -} diff --git a/internal/amneziawg/params.go b/internal/amneziawg/params.go index 02829fd69..5ae75607e 100644 --- a/internal/amneziawg/params.go +++ b/internal/amneziawg/params.go @@ -148,13 +148,14 @@ var interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.@:-]{1,15}$`) // ValidateInterfaceName rejects a value that isn't a plausible network // interface name before it's saved. ExternalInterface and -// IPv6ExternalInterface are interpolated unescaped into a shell-executed -// PostUp/PostDown line by generateServerConfig, so — unlike the client email -// (already hashed for exactly this reason, see routeEgressComment) — an -// unvalidated value here could carry a shell metacharacter straight into a -// root-executed command. A blank value is allowed: it means "auto-detect" -// for ExternalInterface, or "reuse ExternalInterface" for -// IPv6ExternalInterface. +// IPv6ExternalInterface are vestigial as of the hard cutover to the +// embedded path (see types.go's ServerSettings), but this validation stays: +// Phase 3.5's planned real-IPv6-address-alias mechanism will shell out to +// `ip -6 addr add ... dev `, and an unvalidated value here could carry +// a shell metacharacter straight into that root-executed command, the same +// risk the retired kernel-module PostUp/PostDown generator had. A blank +// value is allowed: it means "auto-detect" for ExternalInterface, or "reuse +// ExternalInterface" for IPv6ExternalInterface. func ValidateInterfaceName(name string) error { if name == "" { return nil diff --git a/internal/amneziawg/params_test.go b/internal/amneziawg/params_test.go index 05f90fa57..2829c3b6e 100644 --- a/internal/amneziawg/params_test.go +++ b/internal/amneziawg/params_test.go @@ -233,12 +233,3 @@ func TestValidateConfigValueRejectsControlCharacters(t *testing.T) { } } } - -func TestSanitizeConfigValueStripsControlCharactersOnly(t *testing.T) { - if got := sanitizeConfigValue("a@x\nPostUp = evil\r\n"); got != "a@xPostUp = evil" { - t.Errorf("sanitizeConfigValue must drop newlines/CR without altering the rest, got %q", got) - } - if got := sanitizeConfigValue("plain-value_123"); got != "plain-value_123" { - t.Errorf("sanitizeConfigValue must not touch an already-clean value, got %q", got) - } -} diff --git a/internal/amneziawg/portfwd.go b/internal/amneziawg/portfwd.go index be6847112..6650f3254 100644 --- a/internal/amneziawg/portfwd.go +++ b/internal/amneziawg/portfwd.go @@ -2,7 +2,6 @@ package amneziawg import ( "fmt" - "hash/fnv" "strconv" "strings" ) @@ -13,31 +12,10 @@ type portSpec struct { end int } -func (p portSpec) isRange() bool { return p.end > p.start } - -// dportArg returns the iptables --dport argument: "N" or "N:M". -func (p portSpec) dportArg() string { - if p.isRange() { - return fmt.Sprintf("%d:%d", p.start, p.end) - } - return strconv.Itoa(p.start) -} - -// dnatTarget returns the DNAT target: "ip:N" or "ip:N-M". -func (p portSpec) dnatTarget(clientIP string) string { - if p.isRange() { - return fmt.Sprintf("%s:%d-%d", clientIP, p.start, p.end) - } - return fmt.Sprintf("%s:%d", clientIP, p.start) -} - // 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. Every -// returned spec's bounds are integers in [1, 65535], so callers can safely -// embed them in a shell-executed PostUp/PostDown line without further -// escaping. +// a free-form text field and validation is best-effort by design. func parseForwardedPorts(input string) []portSpec { if input == "" { return nil @@ -91,13 +69,20 @@ func parsePortNumber(s string) (int, bool) { } // ForwardedPortsInclude reports whether port is covered by any spec in a raw -// ForwardedPorts string (a single port or an inclusive range). For callers -// outside this package that need to check a spec against something other -// than rendering it into iptables rules -- e.g. save-time validation that a -// client isn't about to hijack the panel's own port or another inbound's -// port (portForwardLines has no -d restriction, so a forwarded port that -// collides with one already in use on the host silently redirects it to the -// tunnel client instead). +// 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. +// +// The field itself is currently inert: per-client port-forwarding was +// implemented via PostUp/PostDown iptables DNAT rules under the retired +// kernel-module architecture (internal/amneziawg's old Manager), which had +// no equivalent under the embedded amneziawg-go path +// (internal/amneziawgnet) as of the hard cutover -- see the migration +// plan's Phase 3.6 for the panel-side relay design that will restore it. +// The field and this validation are kept so existing values aren't lost and +// re-validated identically once that phase lands, not because anything +// currently acts on them. func ForwardedPortsInclude(forwardedPorts string, port int) bool { for _, spec := range parseForwardedPorts(forwardedPorts) { if port >= spec.start && port <= spec.end { @@ -106,62 +91,3 @@ func ForwardedPortsInclude(forwardedPorts string, port int) bool { } return false } - -// portForwardComment returns a short, shell-safe iptables comment tag for one -// peer's forwarded-port rules, so PostDown removes exactly what PostUp added -// regardless of ordering. Derived from a hash of the peer's email rather than -// the email itself: email is admin/API-supplied free text that ends up -// embedded in a shell-executed PostUp/PostDown line, and a hash can never -// carry a shell metacharacter through. -func portForwardComment(email string) string { - if email == "" { - return "awg-fwd" - } - h := fnv.New32a() - _, _ = h.Write([]byte(email)) - return fmt.Sprintf("awg-fwd-%08x", h.Sum32()) -} - -// portForwardLines returns the PostUp ("-A") or PostDown ("-D") iptables -// lines for one peer's forwarded-ports spec: a DNAT rule (tcp and udp) per -// spec in the nat table, plus a matching FORWARD accept rule. UDP is -// included unconditionally since many common uses (games, P2P) need it. -// Returns nil when forwardedPorts has no valid spec or clientIP is empty. -func portForwardLines(action, extIface, tunIface, clientIP, email, forwardedPorts string) []string { - specs := parseForwardedPorts(forwardedPorts) - if len(specs) == 0 { - return nil - } - clientIP = stripCIDRMask(clientIP) - if clientIP == "" { - return nil - } - comment := portForwardComment(email) - - lines := make([]string, 0, len(specs)*4) - for _, spec := range specs { - dport := spec.dportArg() - target := spec.dnatTarget(clientIP) - for _, proto := range []string{"tcp", "udp"} { - nat := fmt.Sprintf("iptables -t nat %s PREROUTING -p %s", action, proto) - if extIface != "" { - nat += fmt.Sprintf(" -i %s", extIface) - } - nat += fmt.Sprintf(" --dport %s -m comment --comment %s -j DNAT --to-destination %s", dport, comment, target) - lines = append(lines, nat) - - fwd := fmt.Sprintf("iptables %s FORWARD -d %s -p %s -o %s --dport %s -m comment --comment %s -j ACCEPT", - action, clientIP, proto, tunIface, dport, comment) - lines = append(lines, fwd) - } - } - return lines -} - -// stripCIDRMask removes a "/N" suffix if present. -func stripCIDRMask(addr string) string { - if idx := strings.IndexByte(addr, '/'); idx >= 0 { - return addr[:idx] - } - return addr -} diff --git a/internal/amneziawg/route_egress.go b/internal/amneziawg/route_egress.go deleted file mode 100644 index c18d8f320..000000000 --- a/internal/amneziawg/route_egress.go +++ /dev/null @@ -1,83 +0,0 @@ -package amneziawg - -import ( - "fmt" - "hash/fnv" -) - -// EgressBasePort is the first loopback port used for an AmneziaWG inbound's -// own Xray TPROXY bridge. The bridge is opt-in per inbound, gated on -// Instance.RouteThroughXray (off by default): only when it's on does -// defaultPostUpDown's TPROXY rules redirect a peer's traffic there, and only -// then does internal/web/service's injectAmneziawgEgress create the matching -// dokodemo-door inbound, tagged with the AmneziaWG inbound's own real tag so -// it's already selectable in the panel's stock Routing page (the same -// mechanism that already makes an mtproto inbound's own bridge routable -// there — see injectMtprotoEgress). A plain AmneziaWG tunnel with routing -// left off never depends on Xray being up at all. Whether — and where — -// routed traffic actually goes anywhere beyond Xray's default routing is -// entirely up to whatever rules the admin adds on that page; this package -// and injectAmneziawgEgress never generate a routing rule themselves. -// -// EgressPortForInbound derives each inbound's own port deterministically -// from its id, so the two independent reconcile loops (this package's -// PostUp generator and the Xray-config generator, in a different package) -// never have to agree on a runtime-negotiated value. -const EgressBasePort = 63100 - -// EgressPortForInbound returns the loopback port of one AmneziaWG inbound's -// own Xray TPROXY bridge. -func EgressPortForInbound(inboundID int) int { - return EgressBasePort + inboundID -} - -// EgressFwmark and EgressTable are the fwmark and policy-routing table -// TPROXY needs to deliver a peer's packets to a local socket even though -// their destination is never one of this host's own addresses. Shared by -// every AmneziaWG instance's bridge — only the port differs per instance. -// Chosen to be distinctive; if either happens to collide with something else -// already using fwmarks/routing tables on the host, change the values here — -// nothing outside this package and its own PostUp/PostDown output depends on -// the actual numbers. -const ( - EgressFwmark = 0x2377 - EgressTable = 87 -) - -// routeEgressComment returns a short, shell-safe iptables comment tag for one -// peer's TPROXY rule, so PostDown removes exactly what PostUp added -// regardless of ordering. Derived from a hash of the peer's email for the -// same reason portForwardComment is: email is admin/API-supplied free text -// that ends up embedded in a shell-executed PostUp/PostDown line, and a hash -// can never carry a shell metacharacter through. -func routeEgressComment(email string) string { - if email == "" { - return "awg-route" - } - h := fnv.New32a() - _, _ = h.Write([]byte(email)) - return fmt.Sprintf("awg-route-%08x", h.Sum32()) -} - -// routeEgressLines returns the PostUp ("-A") or PostDown ("-D") mangle-table -// TPROXY lines that redirect one peer's traffic — matched by its tunnel -// source IP, arriving on tunIface — into that instance's own Xray bridge on -// port. Both TCP and UDP are covered since every peer's whole traffic is -// meant to reach the bridge, not just a specific protocol or port; which -// outbound (if any) it then takes is entirely up to the admin's own Routing -// rules. Returns nil when clientIP is empty. -func routeEgressLines(action, tunIface, clientIP, email string, port int) []string { - clientIP = stripCIDRMask(clientIP) - if clientIP == "" { - return nil - } - comment := routeEgressComment(email) - lines := make([]string, 0, 2) - for _, proto := range []string{"tcp", "udp"} { - lines = append(lines, fmt.Sprintf( - "iptables -t mangle %s PREROUTING -i %s -s %s -p %s -m comment --comment %s -j TPROXY --on-port %d --on-ip 127.0.0.1 --tproxy-mark %#x/%#x", - action, tunIface, clientIP, proto, comment, port, EgressFwmark, EgressFwmark, - )) - } - return lines -} diff --git a/internal/amneziawg/types.go b/internal/amneziawg/types.go index d08b7cbba..5964389ec 100644 --- a/internal/amneziawg/types.go +++ b/internal/amneziawg/types.go @@ -58,27 +58,29 @@ type Instance struct { Obfuscation Obfuscation20 Peers []Peer - // ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to. - // Empty means auto-detect at config-generation time. + // ExternalInterface named the host NIC PostUp/PostDown NAT rules + // attached to under the retired kernel-module architecture. Not read by + // the embedded path (internal/amneziawgnet) as of the hard cutover -- + // kept for Phase 3.5's planned real-IPv6-address-alias mechanism, which + // will need to know which host NIC to alias an address onto. ExternalInterface string - // IPv6Enabled turns on the per-peer NDP proxy PostUp/PostDown entries - // (ip -6 neigh add/del proxy) for peers that have an IPv6 AllowedIPs - // entry. IPv6ExternalInterface overrides ExternalInterface for those - // entries specifically; empty means reuse ExternalInterface. + // IPv6Enabled/IPv6ExternalInterface controlled the per-peer NDP proxy + // PostUp/PostDown entries (ip -6 neigh add/del proxy) under the retired + // kernel-module architecture. Not read by the embedded path as of the + // hard cutover -- distinct-per-peer public IPv6 identity is Phase 3.5, + // see the migration plan. IPv6Enabled bool IPv6ExternalInterface string - // RouteThroughXray gates the entire TPROXY-into-Xray bridge (see - // EgressPortForInbound / injectAmneziawgEgress) for this instance: off by - // default, so a plain AmneziaWG tunnel never depends on Xray being up at - // all. Turning it on makes every peer's traffic TPROXY'd into this - // instance's own loopback Xray bridge, tagged with the inbound's own - // tag; the actual routing decision from there is left entirely to the - // panel's stock Routing page (pick this inbound's tag as source, an - // outbound, and optionally a peer's IP), exactly like routing any other - // protocol -- only whether the bridge exists at all is a per-inbound - // choice. + // 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 } @@ -99,22 +101,17 @@ type ServerSettings struct { PrimaryDNS string `json:"primaryDns,omitempty"` SecondaryDNS string `json:"secondaryDns,omitempty"` - // ExternalInterface is the host NIC PostUp/PostDown NAT rules attach to. - // Empty means auto-detect. + // ExternalInterface, IPv6Enabled/IPv6Subnet/IPv6ExternalInterface, and + // RouteThroughXray are all vestigial as of the hard cutover to the + // embedded path (internal/amneziawgnet) -- see the matching fields on + // Instance for what each used to do under the retired kernel-module + // architecture and what (if anything) is planned to read them again. ExternalInterface string `json:"externalInterface,omitempty"` - // IPv6Enabled turns on native IPv6 for clients: an IPv6 host address is - // allocated from IPv6Subnet alongside each client's IPv4 one, and the - // server proxies NDP for each enabled client's address so upstream - // routers see it as directly reachable (no NAT66). IPv6ExternalInterface - // overrides ExternalInterface for the NDP-proxy PostUp/PostDown entries - // specifically; empty reuses ExternalInterface. IPv6Enabled bool `json:"ipv6Enabled,omitempty"` IPv6Subnet string `json:"ipv6Subnet,omitempty"` IPv6ExternalInterface string `json:"ipv6ExternalInterface,omitempty"` - // RouteThroughXray turns on this inbound's TPROXY-into-Xray bridge; see - // Instance.RouteThroughXray for what that means. Off by default. RouteThroughXray bool `json:"routeThroughXray,omitempty"` // Obfuscation20's fields, repeated flat (not embedded) rather than diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index 9325195f7..e4a99a7ea 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -57,7 +57,7 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool { // (frontend/src/pages/inbounds/form/InboundFormModal.tsx), which hides the // "Deploy To" node picker for anything not in this set. MTProto and // AmneziaWG are both sidecar-managed rather than plain Xray inbounds, and -// their reconcile loops (mtproto.Manager, amneziawg.Manager) only ever +// their reconcile loops (mtproto.Manager, amneziawgnet.Manager) only ever // query for NodeID IS NULL rows -- a node-assigned instance of either would // never be reconciled by the master, yet nothing previously stopped one // from being created that way (the frontend allowlist has no server-side