Files
felhom-agent/internal/wgtunnel/manager.go
T
admin ac112c956e v0.90.0 — guest RAM resize (R-24) + fast-tick-until-convergence (R-28)
MinAgent coupling: felhom-controller v0.143.0 gates its guest-memory-resize UI on
this agent (FeatureGuestMemoryResize, MinAgent 0.90.0).

R-24 guest RAM resize (internal/localapi/guestmemory.go): self-scoped GET/POST
/guest/memory. Agent enforces every bound FRESH per request (min 2048, max
host_total-2048, shrink floor max(2048, usage+512)); applies via PVE SetConfig —
live cgroup apply, no reboot (Phase-0 proven on the nested demo box). Verify-after-apply
re-reads maxmem before claiming success. New narrow MemoryOps seam (GuestAPI untouched);
Options.Memory nil -> 503. Memory only.

R-28 fast-tick (internal/fasttick): while any desired-state item is unapplied -
including the pre-tunnel window a hub poke can't reach - pulse the shared out-of-band
trigger every 30s, self-disarm on convergence. Four cached sources (desired-gen==0,
reconcile Planned-Pending>0, pbsdr waiting_secret only, wgtunnel desired-not-operational);
LOUD pbsdr states + pending_signature excluded. Seams: reconcile.Engine.LastResult() +
wgtunnel.Manager.TunnelConvergence() (cached, no per-tick exec).

Guests-0/0: hypothesis REFUTED live (9201 IS a pool member; 0/0 was the pre-provision
window; PoolAddVMID re-assert already covers restore-over-existing). No code change; the
fast-tick mitigates the window.

Tests + red-proofs (i floor guard, ii max guard, iii always-pulse) all restored green.
2026-07-17 19:09:40 +02:00

689 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package wgtunnel
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
)
const (
// Iface is the managed interface/unit name: wg-quick@wg-felhom.
Iface = "wg-felhom"
// confDest is the installed conf path — must match the FELHOM_WG sudoers entry EXACTLY.
confDest = "/etc/wireguard/wg-felhom.conf"
// unit is the systemd unit the sudoers allowlists.
unit = "wg-quick@wg-felhom"
markerName = "registered.json"
lastAppliedName = "last-applied.sha"
stagedConfName = "wg-felhom.conf"
// registration backoff bounds (Scenario D: no hot loop).
regBackoffMin = time.Minute
regBackoffMax = 15 * time.Minute
)
// Registrar is the hub seam (satisfied by *hub.Client; tests inject a fake).
type Registrar interface {
RegisterWG(ctx context.Context, pubkey string) (*hub.WGRegisterResponse, error)
}
// Resolver resolves the endpoint's DNS name to IPv4 addresses (doc 06 §4.2 v4-pin). The seam lets
// tests drive resolution deterministically; the production impl asks ONLY for A records ("ip4"),
// so the AAAA is never returned — the tunnel can never silently ride un-NATed IPv6.
type Resolver interface {
LookupIPv4(ctx context.Context, host string) ([]netip.Addr, error)
}
// netResolver is the production Resolver — the system resolver, A records only.
type netResolver struct{}
func (netResolver) LookupIPv4(ctx context.Context, host string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip4", host)
}
// lowestAddr picks the numerically lowest address — a deterministic, fleet-wide-stable choice when
// the endpoint publishes multiple A records (every box picks the same one; no per-box drift).
func lowestAddr(addrs []netip.Addr) netip.Addr {
best := addrs[0]
for _, a := range addrs[1:] {
if a.Compare(best) < 0 {
best = a
}
}
return best
}
// marker is the local registration record (<StateDir>/wg/registered.json). Its EXISTENCE is the
// registration gate: present → never register again except the pubkey-mismatch re-key path.
// KEPT on revocation (revoked stays revoked — doc 06 §3.5 completion).
type marker struct {
Pubkey string `json:"pubkey"`
AssignedIP string `json:"assigned_ip"`
Generation int64 `json:"generation"`
}
// Manager renders + applies the wg-felhom conf and drives wg-quick@wg-felhom through the
// narrow-sudoers runner (the lanresolver shape). All mutations go through `runner`; the only
// unprivileged execs are `systemctl is-active` (world-readable state) via isActive.
type Manager struct {
runner proxmox.Runner
hub Registrar
stateDir string
logger *slog.Logger
// injectable for tests
isActive func(ctx context.Context) bool
wgPresent func() bool
now func() time.Time
resolver Resolver
staleAfter time.Duration // handshake-age threshold for the re-resolve watchdog (doc 06 §4.2)
mu sync.Mutex
nextRegAt time.Time
regBackoff time.Duration
keyBroken bool // corrupt key / partial state — loop idles until operator resolves
brokenAnnounced bool
// v4-pin cache + watchdog throttles (all guarded by mu).
lastResolvedIP netip.Addr // last A-record we rendered into the conf; invalid → resolve on next apply
resolveFailLogged bool // DNS-failure log throttle (reset on the next success)
staleSameIPLogged bool // "stale but IP unchanged" log throttle (endpoint-down, not re-IP)
// conv is the CACHED tunnel-convergence snapshot (v0.90.0, R-28 fast-tick source), refreshed at
// the end of every Apply (the tunnel's own cadence) so the fast-tick reads it WITHOUT execing
// `wg`/`systemctl` per tick. wgBlockDesired = a wireguard block for THIS key is desired;
// operational = registered (marker) AND the unit is active. desired && !operational is exactly
// the poke-undeliverable window R-28 exists to close.
conv convSnapshot
}
// convSnapshot is the cheap, cached read the fast-tick consumes.
type convSnapshot struct {
wgBlockDesired bool
operational bool
}
// TunnelConvergence returns the cached (desired, operational) snapshot without any exec (fast-tick
// source). Zero value (false, false) before the first Apply = "nothing desired yet" = converged for
// this source (the never-fetched case is covered by the desired-generation source instead).
func (m *Manager) TunnelConvergence() (desired, operational bool) {
m.mu.Lock()
defer m.mu.Unlock()
return m.conv.wgBlockDesired, m.conv.operational
}
// NewManager builds a Manager. stateDir is the agent state dir (default /var/lib/felhom-agent —
// note the FELHOM_WG sudoers install entry hard-codes the staged path under it).
func NewManager(runner proxmox.Runner, registrar Registrar, stateDir string, logger *slog.Logger) *Manager {
if logger == nil {
logger = slog.Default()
}
return &Manager{
runner: runner,
hub: registrar,
stateDir: stateDir,
logger: logger,
isActive: func(ctx context.Context) bool {
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", unit).Output()
return strings.TrimSpace(string(out)) == "active"
},
wgPresent: func() bool {
_, err := os.Stat("/usr/bin/wg")
return err == nil
},
now: time.Now,
resolver: netResolver{},
staleAfter: 180 * time.Second,
}
}
// SetStaleAfter overrides the re-resolve staleness threshold (from wg_tunnel.stale_after_seconds);
// non-positive values keep the 180s default.
func (m *Manager) SetStaleAfter(d time.Duration) {
if d > 0 {
m.mu.Lock()
m.staleAfter = d
m.mu.Unlock()
}
}
func (m *Manager) wgDir() string { return filepath.Join(m.stateDir, "wg") }
func (m *Manager) markerPath() string { return filepath.Join(m.wgDir(), markerName) }
func (m *Manager) lastAppliedPath() string { return filepath.Join(m.wgDir(), lastAppliedName) }
func (m *Manager) stagedConfPath() string { return filepath.Join(m.wgDir(), stagedConfName) }
func (m *Manager) loadMarker() *marker {
raw, err := os.ReadFile(m.markerPath())
if err != nil {
return nil
}
var mk marker
if json.Unmarshal(raw, &mk) != nil || mk.Pubkey == "" {
return nil
}
return &mk
}
// LoadAssignedAddr reads the box's assigned WG address from the registration marker
// (<stateDir>/wg/registered.json) WITHOUT constructing a Manager — the poke listener (v0.89.0)
// binds EXCLUSIVELY to this /32, so it needs the bare address, not the /32 prefix. Returns
// ok=false until the box has registered (marker absent / unparsable / empty). The address is
// stable across the box's life (preserved on re-key / reinstall — the WG IP is host-scoped).
func LoadAssignedAddr(stateDir string) (netip.Addr, bool) {
raw, err := os.ReadFile(filepath.Join(stateDir, "wg", markerName))
if err != nil {
return netip.Addr{}, false
}
var mk marker
if json.Unmarshal(raw, &mk) != nil || mk.AssignedIP == "" {
return netip.Addr{}, false
}
if pfx, err := netip.ParsePrefix(mk.AssignedIP); err == nil {
return pfx.Addr(), true
}
if a, err := netip.ParseAddr(mk.AssignedIP); err == nil { // tolerate a bare addr
return a, true
}
return netip.Addr{}, false
}
func (m *Manager) writeMarker(mk marker) error {
if err := os.MkdirAll(m.wgDir(), 0o700); err != nil {
return err
}
raw, _ := json.Marshal(mk)
return os.WriteFile(m.markerPath(), raw, 0o600)
}
// --- conf rendering (pure; every value strictly validated before it reaches the file) ---
var dnsNameRe = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
func validKeyB64(s string) error {
if len(s) != 44 {
return fmt.Errorf("key must be 44 base64 chars, got %d", len(s))
}
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return fmt.Errorf("key is not valid base64")
}
if len(raw) != 32 {
return fmt.Errorf("key decodes to %d bytes, want 32", len(raw))
}
return nil
}
// clientMTU is the offsite tunnel's interface MTU — the IPv6-minimum floor (RFC 8200 guarantees
// every path carries ≥1280). Chosen fleet-wide + family-agnostic so bulk TCP never black-holes on
// a constrained path: outer = 1280+60 (v4) / 1280+80 (v6), both fit the mobile ~1400, DS-Lite
// ~1452, PPPoE 1492 and clean 1500 cases. Client-only bounds both directions (interface MTU caps
// box→PBS; advertised MSS = MTU40 caps PBS→box), so the endpoint's wg0 stays untouched. Was 1420
// (silently black-holed sub-~1480 paths — CGNAT smoke test 2026-07-04). See doc 06 §4.3.
const clientMTU = 1280
// renderConf builds the wg-felhom.conf content from the hub block + the local private key + the
// PRE-RESOLVED endpoint IPv4 (the caller resolves the DNS name; renderConf stays pure — no DNS).
// The Endpoint line carries the v4 LITERAL (doc 06 §4.2 v4-pin: deterministic family, never the
// AAAA). Client-side constants per doc 06 §4: MTU 1280, AllowedIPs = pbs_tunnel_ip/32 (the tunnel
// carries ONLY box→PBS traffic), PersistentKeepalive 25. All inputs validated — nothing
// user-controlled is interpolatable (strict charsets, netip parses).
func renderConf(block *hub.WireWireguard, privB64, endpointIPv4 string) (string, error) {
if err := validKeyB64(privB64); err != nil {
return "", fmt.Errorf("wgtunnel: private key: %w", err)
}
if err := validKeyB64(block.Endpoint.ServerPubkey); err != nil {
return "", fmt.Errorf("wgtunnel: server_pubkey: %w", err)
}
epIP, err := netip.ParseAddr(endpointIPv4)
if err != nil || !epIP.Is4() {
return "", fmt.Errorf("wgtunnel: endpoint ip %q is not an IPv4 literal", endpointIPv4)
}
addr, err := netip.ParsePrefix(block.AssignedIP)
if err != nil || addr.Bits() != 32 {
return "", fmt.Errorf("wgtunnel: assigned_ip %q is not an ip/32", block.AssignedIP)
}
pbsIP, err := netip.ParseAddr(block.Endpoint.PBSTunnelIP)
if err != nil {
return "", fmt.Errorf("wgtunnel: pbs_tunnel_ip %q is not an address", block.Endpoint.PBSTunnelIP)
}
if !dnsNameRe.MatchString(block.Endpoint.DNSName) {
return "", fmt.Errorf("wgtunnel: endpoint dns_name has invalid characters")
}
if block.Endpoint.WGPort < 1 || block.Endpoint.WGPort > 65535 {
return "", fmt.Errorf("wgtunnel: wg_port %d out of range", block.Endpoint.WGPort)
}
// AllowedIPs: the PBS tunnel IP always; the operator OOB peer /32 too when present (H1 [OF-1]).
// The tunnel carries box→PBS AND (when OOB is on) operator→box SSH — both terminate on the box, so
// the low interface MTU still bounds both directions. RENDERED here (not a runtime `wg set`) so it
// survives the agent's own self-heal. allowedIPs returns a DETERMINISTICALLY SORTED list so the
// conf-hash is stable across ticks (trap 1: nondeterministic order → a restart every 60s).
allowed, err := allowedIPsLine(pbsIP, block.OOBPeerIP)
if err != nil {
return "", err
}
var b strings.Builder
b.WriteString("# felhom offsite tunnel — agent-managed (S3); DO NOT EDIT\n")
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", privB64)
fmt.Fprintf(&b, "Address = %s\n", block.AssignedIP)
fmt.Fprintf(&b, "MTU = %d\n\n", clientMTU)
b.WriteString("[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", block.Endpoint.ServerPubkey)
fmt.Fprintf(&b, "Endpoint = %s:%d\n", epIP, block.Endpoint.WGPort)
fmt.Fprintf(&b, "AllowedIPs = %s\n", allowed)
b.WriteString("PersistentKeepalive = 25\n")
return b.String(), nil
}
// allowedIPsLine builds the peer AllowedIPs value: pbsIP/32 always, plus the operator OOB /32 when
// oobPeerIP is a non-empty valid IPv4. The result is SORTED by address (netip.Addr.Compare) so two
// renders of the same inputs are byte-identical — the conf-hash stability the reconcile loop relies
// on. oobPeerIP is validated (bare IPv4, like pbs_tunnel_ip); a bad value is a hard error, never a
// silently-dropped widening.
func allowedIPsLine(pbsIP netip.Addr, oobPeerIP string) (string, error) {
addrs := []netip.Addr{pbsIP}
if oobPeerIP != "" {
op, err := netip.ParseAddr(oobPeerIP)
if err != nil || !op.Is4() {
return "", fmt.Errorf("wgtunnel: oob_peer_ip %q is not an IPv4 address", oobPeerIP)
}
if op != pbsIP { // never duplicate if (misconfigured) equal to the PBS IP
addrs = append(addrs, op)
}
}
sort.Slice(addrs, func(i, j int) bool { return addrs[i].Compare(addrs[j]) < 0 })
parts := make([]string, len(addrs))
for i, a := range addrs {
parts[i] = a.String() + "/32"
}
return strings.Join(parts, ", "), nil
}
// --- the state machine (doc 06 §3.3/§3.5 + spec §7/§8) ---
// Apply reconciles local reality toward (fetched, block). fetched=false means "no desired-state
// data seen yet this process" — NEVER a teardown signal (teardown only on a PRESENT desired-state
// without the block).
func (m *Manager) Apply(ctx context.Context, fetched bool, block *hub.WireWireguard) {
m.mu.Lock()
defer m.mu.Unlock()
// Refresh the cached fast-tick convergence snapshot at the end of every Apply (this cadence),
// so the fast-tick never execs. Runs under the lock, before the Unlock defer (LIFO), covering
// every return path. desired = a wg block for this key is wanted; operational = registered + active.
defer func() {
desired := fetched && block != nil
operational := false
if desired {
if mk := m.loadMarker(); mk != nil {
operational = m.isActive(ctx)
}
}
m.conv = convSnapshot{wgBlockDesired: desired, operational: operational}
}()
keyExists := false
if _, err := os.Stat(KeyFilePath(m.stateDir)); err == nil {
keyExists = true
}
mk := m.loadMarker()
// Partial state: marker without key. Never guess — a fresh keygen here would silently orphan
// the hub-registered identity. Operator repairs (delete marker → fresh keygen+register).
if mk != nil && !keyExists {
m.announceBroken("wgtunnel: registration marker exists but the key file is missing — idling until the operator resolves (delete the marker for a fresh keygen+register)")
return
}
pub, created, err := EnsureKey(m.stateDir)
if err != nil {
m.announceBroken("wgtunnel: " + err.Error())
return
}
m.keyBroken, m.brokenAnnounced = false, false
if created {
m.logger.Info("wgtunnel: generated new WG keypair", "pubkey", pub)
}
if mk == nil {
// Not registered (by local knowledge). Adopt if the hub already knows this exact key —
// the lost-marker-kept-key case; no re-register.
if fetched && block != nil && block.Pubkey == pub {
if err := m.writeMarker(marker{Pubkey: pub, AssignedIP: block.AssignedIP}); err != nil {
m.logger.Error("wgtunnel: writing adoption marker", "err", err)
return
}
m.logger.Info("wgtunnel: adopted existing hub registration (marker restored)", "pubkey", pub)
mk = m.loadMarker()
} else {
// One-shot registration (backoff-gated). Registration is gated ONLY on marker
// absence: a revoked box (marker present, block gone) never lands here.
m.registerLocked(ctx, pub)
return
}
}
if !fetched {
return // no desired data yet — keep last-applied state untouched
}
if block == nil {
// PRESENT desired-state without the block = the hub revoked us. Stop + disable, KEEP the
// marker (revoked stays revoked; the operator re-adds the peer using the reported pubkey).
m.teardownLocked(ctx)
return
}
if block.Pubkey != pub {
// The hub's block is for another key (DR from escrowed key on a re-provisioned box, or
// stale hub state) — the S2 re-key-in-place path. Bounded by the same backoff.
m.logger.Warn("wgtunnel: desired block pubkey differs from local key — re-registering (re-key-in-place)",
"local", pub, "desired", block.Pubkey)
m.registerLocked(ctx, pub)
return // the bumped generation brings a corrected block on the next fetch
}
m.ensureTunnelLocked(ctx, block)
}
// announceBroken logs a broken-state error ONCE (then idles quietly until state changes).
func (m *Manager) announceBroken(msg string) {
if !m.brokenAnnounced {
m.logger.Error(msg)
m.brokenAnnounced = true
}
m.keyBroken = true
}
// registerLocked runs one backoff-gated RegisterWG and writes the marker on success.
func (m *Manager) registerLocked(ctx context.Context, pub string) {
if m.now().Before(m.nextRegAt) {
return
}
resp, err := m.hub.RegisterWG(ctx, pub)
if err != nil {
if m.regBackoff == 0 {
m.regBackoff = regBackoffMin
} else if m.regBackoff *= 2; m.regBackoff > regBackoffMax {
m.regBackoff = regBackoffMax
}
m.nextRegAt = m.now().Add(m.regBackoff)
m.logger.Warn("wgtunnel: registration failed — will retry", "err", err, "backoff", m.regBackoff)
return
}
m.regBackoff, m.nextRegAt = 0, time.Time{}
if err := m.writeMarker(marker{Pubkey: pub, AssignedIP: resp.AssignedIP, Generation: resp.Generation}); err != nil {
m.logger.Error("wgtunnel: registration succeeded but marker write failed", "err", err)
return
}
m.logger.Info("wgtunnel: registered with hub", "pubkey", pub,
"assigned_ip", resp.AssignedIP, "existed", resp.Existed, "generation", resp.Generation, "sync", resp.Sync)
}
// teardownLocked disables the tunnel once (idempotent via the last-applied hash file + a live
// service check). NO re-registration follows — the marker stays.
func (m *Manager) teardownLocked(ctx context.Context) {
applied := false
if _, err := os.Stat(m.lastAppliedPath()); err == nil {
applied = true
}
if !applied && !m.isActive(ctx) {
return // already torn down — nothing to exec (Scenario C's quiet ticks)
}
m.logger.Warn("wgtunnel: desired-state no longer carries the wireguard block — hub revoked this peer; disabling the tunnel (marker kept; NO re-registration)")
if _, errOut, err := m.runner.Run(ctx, "systemctl", "disable", "--now", unit); err != nil {
m.logger.Error("wgtunnel: disable failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return
}
os.Remove(m.lastAppliedPath())
}
// ensureTunnelLocked renders + applies the conf and keeps the service alive (Scenario B).
func (m *Manager) ensureTunnelLocked(ctx context.Context, block *hub.WireWireguard) {
priv, err := readPrivateKeyB64(m.stateDir)
if err != nil {
m.announceBroken("wgtunnel: " + err.Error())
return
}
epIP, ok := m.resolveEndpointLocked(ctx, block)
if !ok {
return // resolveEndpointLocked logged; conf UNTOUCHED — DNS failure is never a teardown
}
conf, err := renderConf(block, priv, epIP)
if err != nil {
m.logger.Error("wgtunnel: refusing to apply invalid desired block", "err", err)
return
}
sum := sha256.Sum256([]byte(conf))
hash := hex.EncodeToString(sum[:])
last, _ := os.ReadFile(m.lastAppliedPath())
active := m.isActive(ctx)
if string(last) == hash {
if active {
return // steady state: zero execs
}
// self-heal: conf is current but the service is down (crash/manual stop)
m.logger.Info("wgtunnel: service inactive with current conf — re-enabling (self-heal)")
if err := m.ensureToolsLocked(ctx); err != nil {
return
}
if _, errOut, err := m.runner.Run(ctx, "systemctl", "enable", "--now", unit); err != nil {
m.logger.Error("wgtunnel: enable failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
}
return
}
// Conf changed (first apply, endpoint re-key, ip change): stage → install → enable/restart.
if err := m.ensureToolsLocked(ctx); err != nil {
return
}
if err := os.MkdirAll(m.wgDir(), 0o700); err != nil {
m.logger.Error("wgtunnel: state dir", "err", err)
return
}
if err := os.WriteFile(m.stagedConfPath(), []byte(conf), 0o600); err != nil {
m.logger.Error("wgtunnel: staging conf", "err", err)
return
}
// argv matches the FELHOM_WG sudoers entry exactly (fixed source + dest).
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0600", "--", m.stagedConfPath(), confDest); err != nil {
m.logger.Error("wgtunnel: conf install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return
}
verb := []string{"enable", "--now", unit}
if active {
verb = []string{"restart", unit} // conf change on a running tunnel → restart (never reload)
}
if _, errOut, err := m.runner.Run(ctx, "systemctl", verb...); err != nil {
m.logger.Error("wgtunnel: systemctl "+verb[0]+" failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
return
}
if err := os.WriteFile(m.lastAppliedPath(), []byte(hash), 0o600); err != nil {
m.logger.Error("wgtunnel: recording applied hash", "err", err)
return
}
m.logger.Info("wgtunnel: tunnel conf applied", "endpoint",
fmt.Sprintf("%s:%d", epIP, block.Endpoint.WGPort), "dns_name", block.Endpoint.DNSName,
"assigned_ip", block.AssignedIP, "action", verb[0])
}
// resolveEndpointLocked returns the endpoint IPv4 to render. Steady state uses the CACHED IP — no
// DNS call while the tunnel is healthy (the watchdog owns re-resolution, doc 06 §4.2). The cache is
// empty on first apply and after a process restart → one resolve then. On resolver failure with a
// cache present the caller keeps the last conf (never a teardown); with no cache yet it simply
// can't apply and retries next tick.
func (m *Manager) resolveEndpointLocked(ctx context.Context, block *hub.WireWireguard) (string, bool) {
if m.lastResolvedIP.IsValid() {
return m.lastResolvedIP.String(), true
}
pick, ok := m.resolveNowLocked(ctx, block)
if !ok {
return "", false
}
m.lastResolvedIP = pick
return pick.String(), true
}
// resolveNowLocked forces a fresh A-record lookup, picks the lowest, and throttles failure logging.
// Never returns an AAAA (the resolver asks for "ip4"). Returns ok=false on a bad name or lookup
// failure — the caller decides whether that means "keep last conf" or "can't apply yet".
func (m *Manager) resolveNowLocked(ctx context.Context, block *hub.WireWireguard) (netip.Addr, bool) {
name := block.Endpoint.DNSName
if !dnsNameRe.MatchString(name) {
m.logger.Error("wgtunnel: endpoint dns_name has invalid characters — not resolving", "dns_name", name)
return netip.Addr{}, false
}
addrs, err := m.resolver.LookupIPv4(ctx, name)
if err != nil || len(addrs) == 0 {
if !m.resolveFailLogged {
m.logger.Error("wgtunnel: endpoint A-record resolution failed — keeping last-applied conf (DNS failure is NEVER a teardown)",
"err", err, "dns_name", name)
m.resolveFailLogged = true
}
return netip.Addr{}, false
}
m.resolveFailLogged = false
return lowestAddr(addrs), true
}
// handshakeStaleLocked reports whether the live tunnel's last handshake is older than staleAfter —
// the watchdog's re-resolve trigger. Inactive tunnel or no-handshake-yet → NOT stale (the ensure
// path handles those). This is the ONLY place a per-tick `wg show` runs outside Status; it lives in
// Watchdog (loop-only) so Apply's steady state stays zero-exec.
func (m *Manager) handshakeStaleLocked(ctx context.Context) bool {
if !m.isActive(ctx) {
return false
}
out, _, err := m.runner.Run(ctx, "wg", "show", Iface, "latest-handshakes")
if err != nil {
return false
}
age, ok := parseHandshakeAge(string(out), m.now())
if !ok {
return false
}
return age > int64(m.staleAfter.Seconds())
}
// Watchdog re-resolves the endpoint when the tunnel's handshake has gone stale and re-applies on an
// IP change — recovery from an endpoint re-IP or a family flap (doc 06 §4.2). Loop-driven only:
// Apply never calls it, so Apply's steady state stays DNS-free and exec-free. A stale handshake with
// an UNCHANGED IP (endpoint merely down) causes no churn — one throttled log, no restart.
func (m *Manager) Watchdog(ctx context.Context, fetched bool, block *hub.WireWireguard) {
m.mu.Lock()
defer m.mu.Unlock()
if !fetched || block == nil || !m.lastResolvedIP.IsValid() {
return // nothing applied to watch, or no desired block
}
if !m.handshakeStaleLocked(ctx) {
return // healthy → no DNS, no action (the load-bearing negative)
}
pick, ok := m.resolveNowLocked(ctx, block)
if !ok {
return // resolver failed while stale → keep last conf, retry next tick
}
if pick == m.lastResolvedIP {
if !m.staleSameIPLogged {
m.logger.Warn("wgtunnel: tunnel handshake stale but endpoint IP unchanged — endpoint may be down; not restarting",
"endpoint_ip", pick.String())
m.staleSameIPLogged = true
}
return
}
m.logger.Info("wgtunnel: endpoint re-IP detected — re-resolving and re-applying",
"old", m.lastResolvedIP.String(), "new", pick.String(), "dns_name", block.Endpoint.DNSName)
m.lastResolvedIP = pick
m.staleSameIPLogged = false
m.ensureTunnelLocked(ctx, block) // re-renders with the new cached IP → restart
}
// ensureToolsLocked installs wireguard-tools once when absent (dnsmasq-install precedent).
func (m *Manager) ensureToolsLocked(ctx context.Context) error {
if m.wgPresent() {
return nil
}
m.logger.Info("wgtunnel: wireguard-tools absent — installing")
if out, errOut, err := m.runner.Run(ctx, "apt-get", "install", "-y", "-q", "wireguard-tools"); err != nil {
m.logger.Error("wgtunnel: apt-get install wireguard-tools failed",
"err", err, "out", strings.TrimSpace(string(errOut))+strings.TrimSpace(string(out)))
return err
}
return nil
}
// Status builds the heartbeat report stanza (doc 06 §4.6). Read-only: it NEVER creates keys or
// registers. The handshake read is the package's single wg invocation — `latest-handshakes`
// only (NEVER `dump`, whose interface line carries the private key).
func (m *Manager) Status(ctx context.Context) *hub.WireguardStatus {
m.mu.Lock()
defer m.mu.Unlock()
st := &hub.WireguardStatus{}
if raw, err := os.ReadFile(KeyFilePath(m.stateDir)); err == nil {
if priv, derr := decodeKey(raw); derr == nil {
if pub, perr := derivePublic(priv); perr == nil {
st.Pubkey = pub
}
}
}
if mk := m.loadMarker(); mk != nil {
st.Registered = true
st.AssignedIP = mk.AssignedIP
}
st.Active = m.isActive(ctx)
if st.Active {
if out, _, err := m.runner.Run(ctx, "wg", "show", Iface, "latest-handshakes"); err == nil {
if age, ok := parseHandshakeAge(string(out), m.now()); ok {
st.LastHandshakeAgeS = &age
}
}
}
if st.Pubkey == "" && !st.Registered && !st.Active {
return st // still a valid (empty-ish) stanza; caller decides whether to attach
}
return st
}
// parseHandshakeAge parses `wg show <if> latest-handshakes` output ("<peer-pub>\t<epoch>").
// epoch 0 = no handshake yet → not ok (nil in the report; nil ≠ 0).
func parseHandshakeAge(out string, now time.Time) (int64, bool) {
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
f := strings.Fields(line)
if len(f) != 2 {
continue
}
epoch, err := strconv.ParseInt(f[1], 10, 64)
if err != nil || epoch <= 0 {
continue
}
age := now.Unix() - epoch
if age < 0 {
age = 0
}
return age, true
}
return 0, false
}