feat(amneziawg): retire the kernel-module OS-shellout code and install.sh path

Hard cutover, part 3: everything that only ever existed to drive the
kernel-module (DKMS) + awg-quick + TPROXY architecture is gone now that
internal/amneziawgnet's embedded path is wired in as the real thing.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-08-02 20:01:08 +03:00
parent f78dfa6f67
commit 59dff059c1
11 changed files with 312 additions and 2091 deletions
-265
View File
@@ -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
+143
View File
@@ -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 ""
}
+122
View File
@@ -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)
}
}
File diff suppressed because it is too large Load Diff
-566
View File
@@ -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: "<r 64>"}
}
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)
}
}
+8 -7
View File
@@ -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 <ext6>`, 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
-9
View File
@@ -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)
}
}
+15 -89
View File
@@ -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
}
-83
View File
@@ -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
}
+23 -26
View File
@@ -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
+1 -1
View File
@@ -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