Files
felhom-agent/internal/wgtunnel/manager.go
T
admin 734f45c422 wgtunnel: v4-pin + re-resolve watchdog; FELHOM_WG Critical flips (S4 agent half)
v4-pin (doc 06 §4.2): renderConf takes a pre-resolved IPv4 literal and writes
Endpoint=<ip>:<port> — never the DNS name, never AAAA. Resolver seam (A records
only, LookupNetIP "ip4"); multiple A → lowest (deterministic fleet-wide);
renderConf stays pure. Resolved IP cached: steady-state Apply = zero DNS + zero
execs. DNS failure keeps the last conf (never a teardown).

Watchdog (loop-only, so Apply's zero-exec steady state is untouched): handshake
age > wg_tunnel.stale_after_seconds (default 180) → re-resolve; IP changed →
re-render + restart (endpoint re-IP recovery); IP same → no churn (throttled
warn). Staleness read reuses wg show latest-handshakes (never dump).

Capability: wg-conf-install/enable/restart/handshake-read flipped Critical=true
(backups ride the tunnel from S4); apt-install + disable stay non-critical.
TestWGCapabilityCriticality pins the set.

Tests + red-proofs a/b/d all fire. No new sudoers grant; no wire/JSON change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-04 15:47:17 +02:00

596 lines
22 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"
"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)
}
// 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
}
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)
}
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/32\n", pbsIP)
b.WriteString("PersistentKeepalive = 25\n")
return b.String(), 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()
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
}