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 (/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 // (/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 = MTU−40 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 latest-handshakes` output ("\t"). // 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 }