Files
YoungReckless4 56ac8d0fc7 feat(amneziawg): add opt-in Linux kernel datapath (#6877)
* 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>
2026-10-25 13:24:50 +01:00

624 lines
18 KiB
Go

package amneziawgkern
import (
"context"
"fmt"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// commandTimeout bounds every awg/ip/nft/iptables invocation.
const commandTimeout = 20 * time.Second
// Desired is one inbound's wanted kernel-interface state.
type Desired struct {
Instance amneziawg.Instance
// EgressInterface is the host NIC masquerade attaches to; blank means
// detect it (DetectDefaultInterface).
EgressInterface string
// RouteThroughXray reflects a persisted setting rejected by kernel preflight.
RouteThroughXray bool
}
// managed is one live interface and the spec it was last brought to.
type managed struct {
spec spec
trafficGeneration uint64
trafficBaselinePending bool
}
// TrafficInterfaceState identifies one managed interface generation.
type TrafficInterfaceState struct {
Name string
Generation uint64
Adopted bool
}
// host is the part of the OS the manager drives; tests substitute a fake.
type host interface {
linkExists(name string) bool
linkMatches(name string, addresses []string, mtu int) bool
run(stdin, name string, args ...string) (string, error)
hasCommand(name string) bool
moduleLoaded() bool
enableIPv4Forwarding() error
}
// Manager owns the running kernel interfaces keyed by inbound id, mirroring
// internal/amneziawgnet.Manager's shape so callers can hold either behind the same call sites.
type Manager struct {
mu sync.Mutex
ifaces map[int]*managed
generation uint64
host host
confDir string
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide kernel-AmneziaWG manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = newManager(osHost{}, defaultConfDir())
})
return manager
}
func newManager(h host, confDir string) *Manager {
return &Manager{ifaces: map[int]*managed{}, host: h, confDir: confDir}
}
// Ensure brings the inbound's kernel interface to the state d describes.
func (m *Manager) Ensure(d Desired) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.ensureLocked(d)
}
func (m *Manager) ensureLocked(d Desired) error {
s, err := buildSpec(d)
if err != nil {
return err
}
id := d.Instance.Id
cur, isManaged := m.ifaces[id]
linkUp := m.host.linkExists(s.name)
owned, found, err := readOwnedConfig(m.confDir, id)
if err != nil {
return err
}
if linkUp && !isManaged {
if !found {
return fmt.Errorf("amneziawgkern: refusing occupied interface %s without an owned config", s.name)
}
if err := m.verifyLiveInterface(s.name, owned); err != nil {
return err
}
}
if linkUp && isManaged && cur.spec.structFP == s.structFP {
changed := cur.spec.conf != s.conf || cur.spec.nat != s.nat || cur.spec.forward != s.forward
if err := m.reconfigure(cur.spec, s); err != nil {
return err
}
cur.spec = s
if changed {
logger.Debug("amneziawgkern: synced ", s.name)
}
return nil
}
if linkUp && !isManaged && interfaceSection(owned) == interfaceSection(s.conf) &&
m.host.linkMatches(s.name, s.addresses, s.mtu) {
err := m.adopt(s)
if err == nil {
m.ifaces[id] = m.newManaged(s, true)
logger.Info("amneziawgkern: adopted running interface ", s.name, " for inbound ", id)
return nil
}
logger.Warning("amneziawgkern: could not adopt ", s.name, ", rebuilding: ", err)
}
// The old config stays on disk until its link is gone, so an interrupted
// rebuild still has the key that proves the leftover link is ours.
if linkUp {
if err := m.deleteLink(s.name); err != nil {
return err
}
}
if err := writeConf(m.confDir, s.name, s.conf); err != nil {
return err
}
if err := m.createLink(s); err != nil {
if m.host.linkExists(s.name) {
if out, delErr := m.host.run("", "ip", "link", "del", "dev", s.name); delErr != nil {
logger.Warning("amneziawgkern: cleaning up half-built ", s.name, ": ", delErr, " ", out)
}
}
return err
}
// Without a managed entry the rules a previous process left are unknown,
// so the sentinel forces a resync.
prevForward := forwardRules{tunnel: s.name}
if isManaged {
prevForward = cur.spec.forward
}
if err := m.applyEgress(s, prevForward, true); err != nil {
return err
}
m.ifaces[id] = m.newManaged(s, false)
logger.Info("amneziawgkern: started kernel interface ", s.name, " for inbound ", id)
return nil
}
func (m *Manager) newManaged(s spec, adopted bool) *managed {
m.generation++
return &managed{spec: s, trafficGeneration: m.generation, trafficBaselinePending: adopted}
}
// reconfigure applies peer and egress changes to a live, structurally
// unchanged interface without dropping its sessions.
func (m *Manager) reconfigure(prev, s spec) error {
if prev.conf != s.conf {
if err := writeConf(m.confDir, s.name, s.conf); err != nil {
return err
}
if out, err := m.host.run("", "awg", "syncconf", s.name, ConfPathIn(m.confDir, s.name)); err != nil {
return fmt.Errorf("amneziawgkern: awg syncconf %s: %w (%s)", s.name, err, out)
}
}
return m.applyEgress(s, prev.forward, prev.nat != s.nat)
}
// adopt takes over an interface that outlived the panel; its egress state is
// unknown, so NAT and forwarding are re-applied unconditionally.
func (m *Manager) adopt(s spec) error {
if err := writeConf(m.confDir, s.name, s.conf); err != nil {
return err
}
if out, err := m.host.run("", "awg", "syncconf", s.name, ConfPathIn(m.confDir, s.name)); err != nil {
return fmt.Errorf("amneziawgkern: awg syncconf %s: %w (%s)", s.name, err, out)
}
return m.applyEgress(s, forwardRules{tunnel: s.name}, true)
}
func (m *Manager) createLink(s spec) error {
if !m.host.moduleLoaded() {
if out, err := m.host.run("", "modprobe", "amneziawg"); err != nil {
return fmt.Errorf("amneziawgkern: modprobe amneziawg: %w (%s)", err, out)
}
}
steps := [][]string{
{"ip", "link", "add", "dev", s.name, "type", "amneziawg"},
{"awg", "setconf", s.name, ConfPathIn(m.confDir, s.name)},
}
for _, a := range s.addresses {
steps = append(steps, []string{"ip", "-4", "address", "add", a, "dev", s.name})
}
steps = append(steps, []string{"ip", "link", "set", "dev", s.name, "mtu", fmt.Sprint(s.mtu), "up"})
for _, step := range steps {
if out, err := m.host.run("", step[0], step[1:]...); err != nil {
return fmt.Errorf("amneziawgkern: %s: %w (%s)", strings.Join(step, " "), err, out)
}
}
return nil
}
func (m *Manager) deleteLink(name string) error {
out, err := m.host.run("", "ip", "link", "del", "dev", name)
if m.host.linkExists(name) {
if err != nil {
return fmt.Errorf("amneziawgkern: ip link del %s: %w (%s)", name, err, out)
}
return fmt.Errorf("amneziawgkern: ip link del %s left the interface running", name)
}
return nil
}
// applyEgress converges NAT (when natChanged) and the FORWARD accepts.
func (m *Manager) applyEgress(s spec, prevForward forwardRules, natChanged bool) error {
if natChanged {
if out, err := m.host.run(s.nat, "nft", "-f", "-"); err != nil {
return fmt.Errorf("amneziawgkern: applying NAT for %s: %w (%s)", s.name, err, out)
}
}
if s.forward.egress != "" {
if err := m.host.enableIPv4Forwarding(); err != nil {
return fmt.Errorf("amneziawgkern: enabling IPv4 forwarding: %w", err)
}
}
if prevForward != s.forward {
m.syncForwardRules(s.name, s.forward)
}
return nil
}
// syncForwardRules is best effort: with a FORWARD policy of ACCEPT the rules
// are redundant, so a host without iptables still forwards.
func (m *Manager) syncForwardRules(name string, f forwardRules) {
if !m.host.hasCommand("iptables") {
return
}
m.removeForwardRules(name)
for _, rule := range f.rules() {
if out, err := m.host.run("", "iptables", append([]string{"-w", "5", "-I", "FORWARD", "1"}, rule...)...); err != nil {
logger.Warning("amneziawgkern: adding FORWARD accept for ", name, ": ", err, " ", out)
}
}
}
// removeForwardRules deletes every FORWARD rule tagged with name's comment,
// whatever egress it was written for.
func (m *Manager) removeForwardRules(name string) {
if !m.host.hasCommand("iptables") {
return
}
listing, err := m.host.run("", "iptables", "-w", "5", "-S", "FORWARD")
if err != nil {
logger.Warning("amneziawgkern: listing FORWARD rules: ", err, " ", listing)
return
}
tag := forwardComment(name)
for _, line := range strings.Split(listing, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 || fields[0] != "-A" {
continue
}
// -S quotes the comment; passed back verbatim the quotes would be part of it.
for i := range fields {
fields[i] = strings.Trim(fields[i], `"`)
}
if !hasCommentTag(fields, tag) {
continue
}
fields[0] = "-D"
if out, err := m.host.run("", "iptables", append([]string{"-w", "5"}, fields...)...); err != nil {
logger.Warning("amneziawgkern: removing FORWARD accept for ", name, ": ", err, " ", out)
}
}
}
func forwardComment(name string) string { return "3x-ui:" + name }
func hasCommentTag(fields []string, tag string) bool {
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "--comment" && fields[i+1] == tag {
return true
}
}
return false
}
func (f forwardRules) rules() [][]string {
if f.egress == "" {
return nil
}
comment := []string{"-m", "comment", "--comment", forwardComment(f.tunnel), "-j", "ACCEPT"}
return [][]string{
append([]string{"-i", f.tunnel, "-o", f.egress}, comment...),
append([]string{"-i", f.egress, "-o", f.tunnel, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED"}, comment...),
}
}
// verifyLiveInterface refuses a live link whose key is not the owned config's.
// A keyless link is one a crashed create left behind.
func (m *Manager) verifyLiveInterface(name, config string) error {
want, err := configInterfacePublicKey(config)
if err != nil {
return fmt.Errorf("amneziawgkern: refusing to manage %s: %w", name, err)
}
got, err := m.host.run("", "awg", "show", name, "public-key")
if err != nil {
return fmt.Errorf("amneziawgkern: refusing to manage %s: cannot verify live AmneziaWG interface: %w", name, err)
}
if got = strings.TrimSpace(got); got != want && got != "(none)" && got != "" {
return fmt.Errorf("amneziawgkern: refusing to manage %s: live interface key does not match its owned config", name)
}
return nil
}
// Reconcile brings the whole set of kernel interfaces to what desired describes, tearing down any that
// are no longer wanted.
func (m *Manager) Reconcile(desired []Desired) {
m.mu.Lock()
defer m.mu.Unlock()
want := make(map[int]struct{}, len(desired))
for _, d := range desired {
want[d.Instance.Id] = struct{}{}
}
attempted := make(map[int]struct{})
for id := range m.ifaces {
if _, ok := want[id]; ok {
continue
}
attempted[id] = struct{}{}
if err := m.teardown(id); err != nil {
logger.Warning("amneziawgkern: stopping removed inbound ", id, ": ", err)
continue
}
logger.Info("amneziawgkern: stopped kernel interface for removed inbound ", id)
}
ownedIDs, err := ownedConfigIDs(m.confDir)
if err != nil {
logger.Warning("amneziawgkern: listing owned configs: ", err)
}
for _, id := range ownedIDs {
if _, ok := want[id]; ok {
continue
}
if _, ok := attempted[id]; ok {
continue
}
if err := m.teardown(id); err != nil {
logger.Warning("amneziawgkern: stopping recovered inbound ", id, ": ", err)
continue
}
logger.Info("amneziawgkern: stopped recovered kernel interface for inbound ", id)
}
for _, d := range desired {
if err := m.ensureLocked(d); err != nil {
logger.Warning("amneziawgkern: ensure inbound ", d.Instance.Id, ": ", err)
}
}
}
// Remove tears down one inbound's interface and forgets it.
func (m *Manager) Remove(id int) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.teardown(id)
}
// StopAll tears every managed interface down, like the embedded datapath does
// on shutdown: a stopped panel enforces no quota, so nothing may keep forwarding.
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id := range m.ifaces {
if err := m.teardown(id); err != nil {
logger.Warning("amneziawgkern: stopping inbound ", id, " on shutdown: ", err)
}
}
}
// ManagedIDs lists the inbounds with a managed interface, in id order.
func (m *Manager) ManagedIDs() []int {
m.mu.Lock()
defer m.mu.Unlock()
ids := make([]int, 0, len(m.ifaces))
for id := range m.ifaces {
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
// HasRunning reports whether any kernel interface is currently managed.
func (m *Manager) HasRunning() bool {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.ifaces) > 0
}
// Running reports whether this inbound has a live kernel interface.
func (m *Manager) Running(id int) bool {
m.mu.Lock()
defer m.mu.Unlock()
name := ownedInterfaceName(id)
if !m.host.linkExists(name) {
return false
}
if _, ok := m.ifaces[id]; ok {
return true
}
_, found, err := readOwnedConfig(m.confDir, id)
return err == nil && found
}
// TrafficStateFor returns the current generation and whether it was adopted.
func (m *Manager) TrafficStateFor(id int) (TrafficInterfaceState, bool) {
m.mu.Lock()
defer m.mu.Unlock()
cur, ok := m.ifaces[id]
if !ok || !m.host.linkExists(cur.spec.name) {
return TrafficInterfaceState{}, false
}
return TrafficInterfaceState{
Name: cur.spec.name, Generation: cur.trafficGeneration,
Adopted: cur.trafficBaselinePending,
}, true
}
// PrimeAdoptedTrafficBaseline consumes a matching adoption after its first dump.
func (m *Manager) PrimeAdoptedTrafficBaseline(id int, generation uint64, stats []PeerStat, collector *Collector) bool {
m.mu.Lock()
defer m.mu.Unlock()
cur, ok := m.ifaces[id]
if !ok || cur.trafficGeneration != generation || !cur.trafficBaselinePending || !m.host.linkExists(cur.spec.name) {
return false
}
collector.Prime(cur.spec.name, stats)
cur.trafficBaselinePending = false
return true
}
// teardown removes the link, its NAT table, its FORWARD accepts and its
// private config, refusing a live link the panel cannot prove is its own.
func (m *Manager) teardown(id int) error {
name := ownedInterfaceName(id)
owned, found, err := readOwnedConfig(m.confDir, id)
if err != nil {
return err
}
_, isManaged := m.ifaces[id]
if !found && !isManaged {
if m.host.linkExists(name) {
return fmt.Errorf("amneziawgkern: refusing to stop %s without an owned config", name)
}
return nil
}
if m.host.linkExists(name) {
if found {
if err := m.verifyLiveInterface(name, owned); err != nil {
return err
}
}
if err := m.deleteLink(name); err != nil {
return err
}
}
if out, err := m.host.run(natTransaction(id, ""), "nft", "-f", "-"); err != nil {
return fmt.Errorf("amneziawgkern: removing NAT for %s: %w (%s)", name, err, out)
}
m.removeForwardRules(name)
if found {
if err := os.Remove(ConfPathIn(m.confDir, name)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("amneziawgkern: removing private config %s: %w", name, err)
}
}
delete(m.ifaces, id)
GetCollector().Forget(name)
return nil
}
// interfaceSection is the config above its first [Peer] -- what adoption
// requires to match before a live link's peers may be replaced.
func interfaceSection(conf string) string {
if i := strings.Index(conf, "\n[Peer]"); i >= 0 {
return conf[:i]
}
return conf
}
// writeConf replaces the interface's config atomically at 0600: it holds the
// inbound's private key.
func writeConf(dir, name, conf string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("amneziawgkern: creating %s: %w", dir, err)
}
path := ConfPathIn(dir, name)
tmp, err := os.CreateTemp(filepath.Dir(path), ".awgconf-*")
if err != nil {
return fmt.Errorf("amneziawgkern: temp config: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(conf); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("amneziawgkern: writing config: %w", err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("amneziawgkern: chmod config: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("amneziawgkern: closing config: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("amneziawgkern: installing config at %s: %w", path, err)
}
return nil
}
// osHost is the real Linux host.
type osHost struct{}
func (osHost) linkExists(name string) bool {
_, err := os.Stat("/sys/class/net/" + name)
return err == nil
}
// linkMatches checks whether a live link already has the requested addresses and MTU.
func (osHost) linkMatches(name string, addresses []string, mtu int) bool {
ifi, err := net.InterfaceByName(name)
if err != nil || ifi.MTU != mtu {
return false
}
addrs, err := ifi.Addrs()
if err != nil {
return false
}
live := make([]string, 0, len(addrs))
for _, a := range addrs {
live = append(live, a.String())
}
return addressesSatisfied(addresses, live)
}
func (osHost) run(stdin, name string, args ...string) (string, error) {
return run(stdin, name, args...)
}
func (osHost) hasCommand(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func (osHost) moduleLoaded() bool {
_, err := os.Stat("/sys/module/amneziawg")
return err == nil
}
func (osHost) enableIPv4Forwarding() error {
return os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0o644)
}
// addressesSatisfied reports whether every wanted address is present on the interface.
func addressesSatisfied(wanted, live []string) bool {
if len(wanted) == 0 {
return false
}
have := make(map[string]struct{}, len(live))
for _, a := range live {
have[normalizeCIDR(a)] = struct{}{}
}
for _, w := range wanted {
if _, ok := have[normalizeCIDR(w)]; !ok {
return false
}
}
return true
}
// normalizeCIDR makes "10.8.1.1/24" comparable however it was spelled.
func normalizeCIDR(s string) string {
s = strings.TrimSpace(s)
p, err := netip.ParsePrefix(s)
if err != nil {
return s
}
return p.String()
}
// run executes one command, no shell involved, with a bounded timeout and
// returns its combined output for the error message.
func run(stdin, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}