mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 18:07:14 +00:00
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:
-265
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user