mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-28 00:34:59 +03:00
56ac8d0fc7
* feat(amneziawg): add opt-in Linux kernel datapath Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply. Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed. * fix(amneziawg): align readiness and endpoint reporting Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support. Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass. * style(amneziawg): format readiness test * test: reproduce kernel AWG client validation gaps * test(service): cover kernel AWG shared client fanout * fix(amneziawg): validate kernel client fanout and config values * test(amneziawgkern): cover owned interface lifecycle recovery * fix(amneziawgkern): recover owned interfaces on restart * test(amneziawgkern): retain visibility after recovered cleanup failure * fix(amneziawgkern): report recovered interfaces while cleanup retries * style(amneziawgkern): satisfy static analysis * test(amneziawgkern): cover long rendered peer comments * fix(amneziawgkern): accept long generated config comments * test(amneziawgkern): refuse foreign occupied interfaces Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries. * test(amneziawgkern): cover stale interface collisions Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown. * fix(amneziawgkern): verify interface ownership before mutation Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs. Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check. * build: update Go toolchain to 1.27.3 * test(amneziawg): reproduce unsafe key rotation recovery Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure. * fix(amneziawg): recover interrupted key rotations Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership. * fix(scanner): align Go image with module version * fix(docs): pin pnpm release with bootstrap fix pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely. * fix(xray): build core from pinned module The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets. * fix(tests): stabilize local network fixtures Keep the TUIC stream fragmentation check on a working UDP loopback path, allocate the AmneziaWG fixture ports dynamically, and wait for a real Xray stats response before starting API mutation assertions. Recreate failed readiness channels so an early connection cannot stall the probe. Verified host and offline parallel Go suites, repeated occupied-port and UDP tests, race/shuffle runs, and golangci-lint. Production behavior and dependency versions are unchanged. * fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK main gained the sk-SK locale after this branch added its kernel-mode keys to the other 13, so i18n-dead-keys.test.ts failed on the merged tree. * refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick awg-quick evaluates PostUp/PostDown through a root shell, so every value the panel rendered into its config was one validation slip away from root command execution. The interface is now built with direct ip/awg/nft execs from a setconf-only file: link add, awg setconf, address, mtu, an atomically replaced nftables table per inbound, and the IPv4 forwarding sysctl written directly. - Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables and configs under the panel's data dir, so nothing collides with an operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg. - FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel. - A full panel stop records the final counters and tears the interfaces down, like the embedded datapath; a stopped panel enforces no quota or expiry. Crash survivors are still adopted without a rebuild. - The job reads counters before reconciling, and from every managed interface, so a client disabled for quota, or a removed inbound, is charged for its last interval instead of losing it with the dropped peer. - An unchanged reconcile tick forks no process (it probed the live key with `awg show` every 10s); readiness no longer loads the module from a GET. Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM teardown leaving no link, table, FORWARD rule or config behind. * fix(amneziawg): gate kernel host readiness only when a save starts the datapath Every save of a kernel-mode inbound re-ran the host preflight, so a host that lost its module (a kernel upgrade without a DKMS rebuild) refused even disabling the inbound or switching it back to embedded mode. Host blockers now apply only when the save starts the kernel datapath: a new inbound, a switch from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded ports, pinned listen) still apply to every save. * feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it A node built before kernel mode drops the unknown kernelMode field and serves the inbound on the embedded datapath while the master's form shows kernel mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses to push a kernel-mode inbound to a node that has not advertised it, learning the caps with an inbound list first when this Remote has not heard from it. * fix(amneziawg): pin the kernel module installer to verified commits The installer ran as root and built whatever upstream tag sorted newest at install time. It now clones fixed tags (module v3.1.20260906, tools v3.1.20260812) and refuses unless each resolves to its recorded commit. The udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat upstream, so the patch never matched. * fix(i18n): translate the kernel-mode AmneziaWG strings All thirteen non-English locales carried the English text verbatim. * fix(amneziawg): skip the kernel traffic flush when no kernel interface runs Shutdown listed the desired AmneziaWG inbounds from the database even on a panel with no kernel interface, an unneeded query on every stop. A server stopped before its database was opened panicked on it, which turned TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
640 lines
20 KiB
Go
640 lines
20 KiB
Go
package amneziawgkern
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
|
)
|
|
|
|
const (
|
|
testPrivateKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
otherPrivateKey = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
|
)
|
|
|
|
var errInjected = errors.New("injected failure")
|
|
|
|
type fakeLink struct {
|
|
publicKey string
|
|
addresses []string
|
|
mtu int
|
|
up bool
|
|
conf string
|
|
}
|
|
|
|
// fakeHost models the kernel state the manager drives, so tests assert what
|
|
// the host ends up with rather than which commands ran.
|
|
type fakeHost struct {
|
|
links map[string]*fakeLink
|
|
nat map[string]string
|
|
forward []string
|
|
iptables bool
|
|
module bool
|
|
forwarding bool
|
|
commands []string
|
|
failing map[string]error
|
|
}
|
|
|
|
func newFakeHost() *fakeHost {
|
|
return &fakeHost{links: map[string]*fakeLink{}, nat: map[string]string{}, iptables: true, failing: map[string]error{}}
|
|
}
|
|
|
|
func (h *fakeHost) linkExists(name string) bool {
|
|
_, ok := h.links[name]
|
|
return ok
|
|
}
|
|
|
|
func (h *fakeHost) linkMatches(name string, addresses []string, mtu int) bool {
|
|
l, ok := h.links[name]
|
|
return ok && l.mtu == mtu && addressesSatisfied(addresses, l.addresses)
|
|
}
|
|
|
|
func (h *fakeHost) hasCommand(name string) bool { return name != "iptables" || h.iptables }
|
|
func (h *fakeHost) moduleLoaded() bool { return h.module }
|
|
|
|
func (h *fakeHost) enableIPv4Forwarding() error {
|
|
h.forwarding = true
|
|
return nil
|
|
}
|
|
|
|
func (h *fakeHost) run(stdin, name string, args ...string) (string, error) {
|
|
cmd := strings.Join(append([]string{name}, args...), " ")
|
|
h.commands = append(h.commands, cmd)
|
|
for prefix, err := range h.failing {
|
|
if strings.HasPrefix(cmd, prefix) {
|
|
return "injected", err
|
|
}
|
|
}
|
|
switch name {
|
|
case "modprobe":
|
|
h.module = true
|
|
return "", nil
|
|
case "ip":
|
|
return "", h.ip(args)
|
|
case "awg":
|
|
return h.awg(args)
|
|
case "nft":
|
|
h.nftApply(stdin)
|
|
return "", nil
|
|
case "iptables":
|
|
return h.iptablesCmd(args)
|
|
}
|
|
return "", fmt.Errorf("fake host: unexpected command %q", cmd)
|
|
}
|
|
|
|
func (h *fakeHost) ip(args []string) error {
|
|
switch {
|
|
case len(args) == 6 && args[0] == "link" && args[1] == "add" && args[5] == "amneziawg":
|
|
if h.linkExists(args[3]) {
|
|
return errors.New("RTNETLINK answers: File exists")
|
|
}
|
|
h.links[args[3]] = &fakeLink{}
|
|
case len(args) == 4 && args[0] == "link" && args[1] == "del":
|
|
if !h.linkExists(args[3]) {
|
|
return errors.New("Cannot find device")
|
|
}
|
|
delete(h.links, args[3])
|
|
case len(args) == 6 && args[0] == "-4" && args[2] == "add":
|
|
l, ok := h.links[args[5]]
|
|
if !ok {
|
|
return errors.New("Cannot find device")
|
|
}
|
|
l.addresses = append(l.addresses, args[3])
|
|
case len(args) == 7 && args[0] == "link" && args[1] == "set" && args[4] == "mtu":
|
|
l, ok := h.links[args[3]]
|
|
if !ok {
|
|
return errors.New("Cannot find device")
|
|
}
|
|
l.mtu, _ = strconv.Atoi(args[5])
|
|
l.up = args[6] == "up"
|
|
default:
|
|
return fmt.Errorf("fake host: unexpected ip %v", args)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *fakeHost) awg(args []string) (string, error) {
|
|
if len(args) != 3 {
|
|
return "", fmt.Errorf("fake host: unexpected awg %v", args)
|
|
}
|
|
l, ok := h.links[args[1]]
|
|
if !ok {
|
|
return "", errors.New("Unable to access interface: No such device")
|
|
}
|
|
switch args[0] {
|
|
case "show":
|
|
if l.publicKey == "" {
|
|
return "(none)", nil
|
|
}
|
|
return l.publicKey, nil
|
|
case "setconf", "syncconf":
|
|
data, err := os.ReadFile(args[2])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
pub, err := configInterfacePublicKey(string(data))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
l.publicKey, l.conf = pub, string(data)
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("fake host: unexpected awg %v", args)
|
|
}
|
|
|
|
func (h *fakeHost) nftApply(tx string) {
|
|
for _, line := range strings.Split(tx, "\n") {
|
|
f := strings.Fields(line)
|
|
switch {
|
|
case len(f) == 4 && f[0] == "delete" && f[1] == "table":
|
|
delete(h.nat, f[3])
|
|
case len(f) == 4 && f[0] == "table" && f[3] == "{":
|
|
h.nat[f[2]] = tx
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *fakeHost) iptablesCmd(args []string) (string, error) {
|
|
if len(args) < 4 || args[0] != "-w" || args[3] != "FORWARD" {
|
|
return "", fmt.Errorf("fake host: unexpected iptables %v", args)
|
|
}
|
|
rest := args[4:]
|
|
switch args[2] {
|
|
case "-S":
|
|
// Like iptables-nft, print the comment quoted.
|
|
listing := []string{"-P FORWARD DROP"}
|
|
for _, r := range h.forward {
|
|
f := strings.Fields(r)
|
|
for i := 0; i+1 < len(f); i++ {
|
|
if f[i] == "--comment" {
|
|
f[i+1] = strconv.Quote(f[i+1])
|
|
}
|
|
}
|
|
listing = append(listing, strings.Join(f, " "))
|
|
}
|
|
return strings.Join(listing, "\n"), nil
|
|
case "-I":
|
|
h.forward = append([]string{"-A FORWARD " + strings.Join(rest[1:], " ")}, h.forward...)
|
|
return "", nil
|
|
case "-D":
|
|
rule := "-A FORWARD " + strings.Join(rest, " ")
|
|
for i, r := range h.forward {
|
|
if r == rule {
|
|
h.forward = append(h.forward[:i], h.forward[i+1:]...)
|
|
return "", nil
|
|
}
|
|
}
|
|
return "", errors.New("Bad rule (does a matching rule exist in that chain?)")
|
|
}
|
|
return "", fmt.Errorf("fake host: unexpected iptables %v", args)
|
|
}
|
|
|
|
func (h *fakeHost) ran(prefix string) bool {
|
|
for _, c := range h.commands {
|
|
if strings.HasPrefix(c, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func publicKeyOf(t *testing.T, privateKey string) string {
|
|
t.Helper()
|
|
pub, err := wgutil.PublicKeyFromPrivate(privateKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pub
|
|
}
|
|
|
|
func managerDesired(id int) Desired {
|
|
inst := testInstance()
|
|
inst.Id = id
|
|
inst.PrivateKey = testPrivateKey
|
|
return Desired{Instance: inst, EgressInterface: "eth0"}
|
|
}
|
|
|
|
func newTestManager(t *testing.T) (*Manager, *fakeHost, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
h := newFakeHost()
|
|
return newManager(h, dir), h, dir
|
|
}
|
|
|
|
func diskKey(t *testing.T, dir string, id int) string {
|
|
t.Helper()
|
|
conf, found, err := readOwnedConfig(dir, id)
|
|
if err != nil || !found {
|
|
t.Fatalf("owned config for %d: found=%v err=%v", id, found, err)
|
|
}
|
|
pub, err := configInterfacePublicKey(conf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pub
|
|
}
|
|
|
|
func forwardRulesFor(h *fakeHost, name string) []string {
|
|
var out []string
|
|
for _, r := range h.forward {
|
|
if strings.Contains(r, "--comment "+forwardComment(name)) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestEnsureBuildsInterfaceWithoutShell: every step is a direct exec, so no
|
|
// client-controlled value ever reaches a shell running as root.
|
|
func TestEnsureBuildsInterfaceWithoutShell(t *testing.T) {
|
|
m, h, dir := newTestManager(t)
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatalf("Ensure: %v", err)
|
|
}
|
|
link := h.links["xui-awg1"]
|
|
if link == nil || !link.up || link.mtu != 1380 || strings.Join(link.addresses, ",") != "10.8.1.1/24" {
|
|
t.Fatalf("link = %+v, want xui-awg1 up at MTU 1380 with 10.8.1.1/24", link)
|
|
}
|
|
if link.publicKey != publicKeyOf(t, testPrivateKey) || !strings.Contains(link.conf, "PublicKey = peerBPublicKeyBase64ValueForTestsPadding000=") {
|
|
t.Fatalf("link was not configured from the rendered config: %+v", link)
|
|
}
|
|
if !h.module || !h.forwarding {
|
|
t.Fatalf("module loaded=%v ip_forward=%v, want both", h.module, h.forwarding)
|
|
}
|
|
if !strings.Contains(h.nat["xui_awg1"], `ip saddr 10.8.1.0/24 oifname "eth0" masquerade`) {
|
|
t.Fatalf("NAT table = %q", h.nat["xui_awg1"])
|
|
}
|
|
if got := forwardRulesFor(h, "xui-awg1"); len(got) != 2 {
|
|
t.Fatalf("FORWARD accepts = %v, want the outbound and the established-return rule", got)
|
|
}
|
|
for _, c := range h.commands {
|
|
switch strings.Fields(c)[0] {
|
|
case "ip", "awg", "nft", "iptables", "modprobe":
|
|
default:
|
|
t.Errorf("unexpected command %q", c)
|
|
}
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
info, err := os.Stat(ConfPathIn(dir, "xui-awg1"))
|
|
if err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("config holding the private key: mode %v err %v, want 0600", info.Mode().Perm(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEnsureUnchangedTickRunsNothing: the job re-ensures every inbound every
|
|
// 10s; an unchanged one must not fork a single process.
|
|
func TestEnsureUnchangedTickRunsNothing(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.commands = nil
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(h.commands) != 0 {
|
|
t.Fatalf("unchanged ensure ran %v", h.commands)
|
|
}
|
|
}
|
|
|
|
// TestEnsurePeerEditSyncsInPlace: rebuilding the link on a peer edit would
|
|
// drop every other client's session.
|
|
func TestEnsurePeerEditSyncsInPlace(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.commands = nil
|
|
d := managerDesired(1)
|
|
d.Instance.Peers = d.Instance.Peers[:1]
|
|
if err := m.Ensure(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.ran("ip link del") || h.ran("ip link add") || !h.ran("awg syncconf xui-awg1") {
|
|
t.Fatalf("peer edit ran %v, want only a syncconf", h.commands)
|
|
}
|
|
if strings.Contains(h.links["xui-awg1"].conf, "peerB") {
|
|
t.Fatal("removed peer is still on the live interface")
|
|
}
|
|
}
|
|
|
|
// TestEnsureEgressChangeMovesNATAndForwardRules: a stale masquerade or
|
|
// FORWARD accept would keep pointing at the NIC the admin moved away from.
|
|
func TestEnsureEgressChangeMovesNATAndForwardRules(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d := managerDesired(1)
|
|
d.EgressInterface = "ens3"
|
|
if err := m.Ensure(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if h.ran("ip link del") {
|
|
t.Fatal("an egress change rebuilt the link")
|
|
}
|
|
if nat := h.nat["xui_awg1"]; strings.Contains(nat, `"eth0"`) || !strings.Contains(nat, `oifname "ens3"`) {
|
|
t.Fatalf("NAT table after egress change = %q", nat)
|
|
}
|
|
rules := forwardRulesFor(h, "xui-awg1")
|
|
if len(rules) != 2 || strings.Contains(strings.Join(rules, "\n"), "eth0") {
|
|
t.Fatalf("FORWARD accepts after egress change = %v, want two for ens3 only", rules)
|
|
}
|
|
}
|
|
|
|
// TestKeyRotationKeepsOldConfigUntilOldLinkIsGone: the old key on disk is
|
|
// what proves a leftover link is the panel's after a failed teardown.
|
|
func TestKeyRotationKeepsOldConfigUntilOldLinkIsGone(t *testing.T) {
|
|
m, h, dir := newTestManager(t)
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rotated := managerDesired(1)
|
|
rotated.Instance.PrivateKey = otherPrivateKey
|
|
|
|
h.failing["ip link del"] = errInjected
|
|
if err := m.Ensure(rotated); err == nil {
|
|
t.Fatal("rotation succeeded although the old link could not be removed")
|
|
}
|
|
if got := diskKey(t, dir, 1); got != publicKeyOf(t, testPrivateKey) {
|
|
t.Fatal("failed rotation replaced the config the live link still runs")
|
|
}
|
|
|
|
delete(h.failing, "ip link del")
|
|
if err := m.Ensure(rotated); err != nil {
|
|
t.Fatalf("retry: %v", err)
|
|
}
|
|
want := publicKeyOf(t, otherPrivateKey)
|
|
if h.links["xui-awg1"].publicKey != want || diskKey(t, dir, 1) != want {
|
|
t.Fatal("retried rotation did not converge link and disk on the new key")
|
|
}
|
|
}
|
|
|
|
// TestCreateFailureLeavesNoHalfBuiltLink: a keyless leftover would hold the
|
|
// name and port with nothing listening.
|
|
func TestCreateFailureLeavesNoHalfBuiltLink(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
h.failing["ip -4 address add"] = errInjected
|
|
if err := m.Ensure(managerDesired(1)); err == nil {
|
|
t.Fatal("Ensure succeeded although address setup failed")
|
|
}
|
|
if h.linkExists("xui-awg1") || m.HasRunning() {
|
|
t.Fatal("failed create left a link or a managed entry behind")
|
|
}
|
|
delete(h.failing, "ip -4 address add")
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatalf("retry: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestFreshManagerAdoptsSurvivingInterface: after a crash the next process
|
|
// must take the link over without dropping its sessions.
|
|
func TestFreshManagerAdoptsSurvivingInterface(t *testing.T) {
|
|
first, h, dir := newTestManager(t)
|
|
if err := first.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.commands = nil
|
|
second := newManager(h, dir)
|
|
if err := second.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatalf("adopt: %v", err)
|
|
}
|
|
if h.ran("ip link del") || h.ran("ip link add") {
|
|
t.Fatalf("adoption rebuilt the link: %v", h.commands)
|
|
}
|
|
state, ok := second.TrafficStateFor(1)
|
|
if !ok || !state.Adopted {
|
|
t.Fatalf("traffic state = %+v ok=%v, want an adopted baseline pending", state, ok)
|
|
}
|
|
if !h.ran("nft -f -") {
|
|
t.Fatal("adoption did not re-assert the NAT table it cannot see")
|
|
}
|
|
}
|
|
|
|
// TestFreshManagerRebuildsKeylessLeftover: a crash between link add and
|
|
// setconf leaves a keyless link that must not block the inbound forever.
|
|
func TestFreshManagerRebuildsKeylessLeftover(t *testing.T) {
|
|
m, h, dir := newTestManager(t)
|
|
s, err := buildSpec(managerDesired(1))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeConf(dir, s.name, s.conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.links["xui-awg1"] = &fakeLink{}
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatalf("Ensure: %v", err)
|
|
}
|
|
if h.links["xui-awg1"].publicKey != publicKeyOf(t, testPrivateKey) {
|
|
t.Fatal("keyless leftover was not rebuilt")
|
|
}
|
|
}
|
|
|
|
// TestEnsureRefusesInterfacesItCannotProveAreItsOwn: a same-named link
|
|
// without the panel's config, or with another key, belongs to someone else.
|
|
func TestEnsureRefusesInterfacesItCannotProveAreItsOwn(t *testing.T) {
|
|
foreignKey := publicKeyOf(t, otherPrivateKey)
|
|
cases := map[string]func(t *testing.T, dir string){
|
|
"no owned config": func(*testing.T, string) {},
|
|
"other key": func(t *testing.T, dir string) {
|
|
s, err := buildSpec(managerDesired(1))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeConf(dir, s.name, s.conf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
}
|
|
for name, seed := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
m, h, dir := newTestManager(t)
|
|
seed(t, dir)
|
|
before, _ := os.ReadFile(ConfPathIn(dir, "xui-awg1"))
|
|
h.links["xui-awg1"] = &fakeLink{publicKey: foreignKey, mtu: 1380, addresses: []string{"10.8.1.1/24"}}
|
|
|
|
if err := m.Ensure(managerDesired(1)); err == nil || !strings.Contains(err.Error(), "refusing") {
|
|
t.Fatalf("Ensure err = %v, want a refusal", err)
|
|
}
|
|
if err := m.Remove(1); err == nil {
|
|
t.Fatal("Remove tore down an interface the panel cannot prove is its own")
|
|
}
|
|
after, _ := os.ReadFile(ConfPathIn(dir, "xui-awg1"))
|
|
if h.links["xui-awg1"] == nil || h.links["xui-awg1"].publicKey != foreignKey || string(before) != string(after) {
|
|
t.Fatal("a refused interface or its config was changed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEnsureRefusesNonRegularOrForeignConfig: following a symlink or a
|
|
// directory at the config path would write the private key somewhere else.
|
|
func TestEnsureRefusesNonRegularOrForeignConfig(t *testing.T) {
|
|
cases := map[string]func(t *testing.T, path string){
|
|
"directory": func(t *testing.T, path string) {
|
|
if err := os.Mkdir(path, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
"unmarked file": func(t *testing.T, path string) {
|
|
if err := os.WriteFile(path, []byte("[Interface]\nPrivateKey = x\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
"symlink": func(t *testing.T, path string) {
|
|
target := filepath.Join(filepath.Dir(path), "elsewhere")
|
|
if err := os.WriteFile(target, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(target, path); err != nil {
|
|
t.Skipf("symlinks unavailable: %v", err)
|
|
}
|
|
},
|
|
}
|
|
for name, seed := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
m, h, dir := newTestManager(t)
|
|
path := ConfPathIn(dir, "xui-awg1")
|
|
seed(t, path)
|
|
before, _ := os.Lstat(path)
|
|
if err := m.Ensure(managerDesired(1)); err == nil {
|
|
t.Fatal("Ensure accepted a config path the panel did not write")
|
|
}
|
|
after, err := os.Lstat(path)
|
|
if err != nil || after.Mode() != before.Mode() || after.ModTime() != before.ModTime() || h.linkExists("xui-awg1") {
|
|
t.Fatal("a refused config path was replaced or a link was created")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReconcileTearsDownRecoveredUndesiredInterface: an inbound disabled
|
|
// while the panel was down must not keep forwarding with no quota enforced.
|
|
func TestReconcileTearsDownRecoveredUndesiredInterface(t *testing.T) {
|
|
first, h, dir := newTestManager(t)
|
|
if err := first.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newManager(h, dir).Reconcile(nil)
|
|
if h.linkExists("xui-awg1") || h.nat["xui_awg1"] != "" || len(forwardRulesFor(h, "xui-awg1")) != 0 {
|
|
t.Fatalf("recovered interface survived: links=%v nat=%v forward=%v", h.links, h.nat, h.forward)
|
|
}
|
|
if _, err := os.Stat(ConfPathIn(dir, "xui-awg1")); !os.IsNotExist(err) {
|
|
t.Fatalf("private config survived teardown: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReconcileLeavesForeignStateAlone: only state the panel can prove it
|
|
// wrote may be removed.
|
|
func TestReconcileLeavesForeignStateAlone(t *testing.T) {
|
|
m, h, dir := newTestManager(t)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
foreign := map[string]string{
|
|
"xui-awg2.conf": "[Interface]\nPrivateKey = x\n",
|
|
"awg0.conf": ownershipMarker("awg0") + "\n[Interface]\nPrivateKey = " + testPrivateKey + "\n",
|
|
}
|
|
for name, body := range foreign {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
h.links["xui-awg4"] = &fakeLink{publicKey: publicKeyOf(t, otherPrivateKey)}
|
|
h.forward = []string{"-A FORWARD -i eth0 -j DROP"}
|
|
|
|
m.Reconcile(nil)
|
|
for name := range foreign {
|
|
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
|
t.Errorf("foreign %s was removed: %v", name, err)
|
|
}
|
|
}
|
|
if !h.linkExists("xui-awg4") || len(h.forward) != 1 {
|
|
t.Fatal("a link or FORWARD rule the panel did not create was removed")
|
|
}
|
|
}
|
|
|
|
// TestStopAllTearsInterfacesDown: a stopped panel enforces no quota or
|
|
// expiry, so its peers must stop forwarding, as on the embedded datapath.
|
|
func TestStopAllTearsInterfacesDown(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
for _, id := range []int{1, 2} {
|
|
d := managerDesired(id)
|
|
d.Instance.ListenPort += id
|
|
if err := m.Ensure(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
m.StopAll()
|
|
if len(h.links) != 0 || len(h.nat) != 0 || len(h.forward) != 0 || m.HasRunning() {
|
|
t.Fatalf("after StopAll: links=%v nat=%v forward=%v running=%v", h.links, h.nat, h.forward, m.HasRunning())
|
|
}
|
|
}
|
|
|
|
// TestTeardownFailureKeepsInterfaceManagedForRetry: forgetting a link that
|
|
// is still up would leak its UDP port and block embedded mode from binding it.
|
|
func TestTeardownFailureKeepsInterfaceManagedForRetry(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.failing["ip link del"] = errInjected
|
|
if err := m.Remove(1); err == nil {
|
|
t.Fatal("Remove reported success although the link is still up")
|
|
}
|
|
m.Reconcile(nil)
|
|
if !m.Running(1) || !m.HasRunning() {
|
|
t.Fatal("a link that failed to come down is no longer reported running")
|
|
}
|
|
delete(h.failing, "ip link del")
|
|
m.Reconcile(nil)
|
|
if m.Running(1) || h.linkExists("xui-awg1") {
|
|
t.Fatal("retry did not complete the teardown")
|
|
}
|
|
}
|
|
|
|
// TestTeardownRemovesOnlyItsOwnForwardRules: another inbound's accepts and
|
|
// the operator's own rules must survive one inbound's removal.
|
|
func TestTeardownRemovesOnlyItsOwnForwardRules(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
h.forward = []string{"-A FORWARD -i eth0 -j DROP"}
|
|
for _, id := range []int{1, 2} {
|
|
d := managerDesired(id)
|
|
d.Instance.ListenPort += id
|
|
if err := m.Ensure(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := m.Remove(1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(forwardRulesFor(h, "xui-awg1")) != 0 || len(forwardRulesFor(h, "xui-awg2")) != 2 ||
|
|
!strings.Contains(strings.Join(h.forward, "\n"), "-A FORWARD -i eth0 -j DROP") {
|
|
t.Fatalf("FORWARD after removing inbound 1 = %v", h.forward)
|
|
}
|
|
}
|
|
|
|
// TestEnsureWithoutIptablesStillForwards: on a pure nftables host the
|
|
// FORWARD accepts are an optimisation, not a requirement.
|
|
func TestEnsureWithoutIptablesStillForwards(t *testing.T) {
|
|
m, h, _ := newTestManager(t)
|
|
h.iptables = false
|
|
if err := m.Ensure(managerDesired(1)); err != nil {
|
|
t.Fatalf("Ensure without iptables: %v", err)
|
|
}
|
|
if h.ran("iptables") || h.nat["xui_awg1"] == "" {
|
|
t.Fatalf("commands = %v nat = %v", h.commands, h.nat)
|
|
}
|
|
if err := m.Remove(1); err != nil {
|
|
t.Fatalf("Remove without iptables: %v", err)
|
|
}
|
|
}
|