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
This commit is contained in:
@@ -1,3 +1,30 @@
|
|||||||
|
## v0.66.0 — S4 agent half: endpoint v4-pin + re-resolve watchdog + FELHOM_WG Critical flips (2026-07-04)
|
||||||
|
|
||||||
|
The two agent items S4 needs before offsite backups ride the tunnel (the tenancy + storage weight is
|
||||||
|
runbook-side). No new sudoers grants; no wire/JSON change.
|
||||||
|
|
||||||
|
- **`internal/wgtunnel` — v4-pin (doc 06 §4.2).** `renderConf` now takes the **pre-resolved IPv4
|
||||||
|
literal** and writes `Endpoint = <ip>:<port>` — never the DNS name, never an AAAA. A new `Resolver`
|
||||||
|
seam (`net.DefaultResolver.LookupNetIP(ctx, "ip4", …)` — A records only) resolves in the Manager;
|
||||||
|
multiple A records → the numerically **lowest** (deterministic fleet-wide). renderConf stays pure
|
||||||
|
(no DNS/IO inside). The resolved IP is cached: **steady-state Apply hits the cache — zero DNS, zero
|
||||||
|
execs** (the load-bearing negative). DNS failure on (re)resolve → keep the last-applied conf + throttled
|
||||||
|
ERROR — **never a teardown** (teardown stays revocation-only).
|
||||||
|
- **`internal/wgtunnel` — re-resolve watchdog (doc 06 §4.2, the slice-3 promise).** New
|
||||||
|
`Manager.Watchdog` (loop-driven only, so Apply's zero-exec steady state is untouched): when the
|
||||||
|
handshake age exceeds `wg_tunnel.stale_after_seconds` (default **180**) it re-resolves; **IP changed
|
||||||
|
→ re-render + restart** (endpoint re-IP recovery); IP unchanged → no churn, one throttled warn
|
||||||
|
(endpoint merely down). The staleness read is the existing `wg show … latest-handshakes` (never
|
||||||
|
`dump`).
|
||||||
|
- **`internal/config`** — `WGTunnelConfig.StaleAfterSeconds` (default 180 via `WithDefaults`).
|
||||||
|
- **`internal/capability` — FELHOM_WG Critical flips (S4).** Backups ride the tunnel now, so
|
||||||
|
`wg-conf-install`, `wg-enable`, `wg-restart`, `wg-handshake-read` are **Critical=true**
|
||||||
|
(operator-alert-worthy on degradation); `wg-tools-install` (one-time) + `wg-disable` (deliberate
|
||||||
|
revocation) stay non-critical. `TestWGCapabilityCriticality` pins the exact set (red-proofed).
|
||||||
|
- Tests: v4-pin golden (A literal, AAAA/dns_name refused), watchdog (healthy=no-DNS negative,
|
||||||
|
stale+re-IP restarts, stale+same-IP no-churn+throttle, resolver-failure keeps conf, initial-resolve-
|
||||||
|
failure no-teardown+recovery). Red-proofs a/b/d all fire.
|
||||||
|
|
||||||
## v0.65.0 — S3.1 offsite-tunnel client MTU 1420 → 1280 (resolve the CGNAT-smoke MTU open decision) (2026-07-04)
|
## v0.65.0 — S3.1 offsite-tunnel client MTU 1420 → 1280 (resolve the CGNAT-smoke MTU open decision) (2026-07-04)
|
||||||
|
|
||||||
One-constant fix closing `06 §4.3`'s OPEN DECISION. The 2026-07-04 CGNAT smoke test found the
|
One-constant fix closing `06 §4.3`'s OPEN DECISION. The 2026-07-04 CGNAT smoke test found the
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
|||||||
}
|
}
|
||||||
wtRunner := &proxmox.ExecRunner{Mode: wtMode, SudoPath: cfg.Privileged.SudoPath}
|
wtRunner := &proxmox.ExecRunner{Mode: wtMode, SudoPath: cfg.Privileged.SudoPath}
|
||||||
wgMgr := wgtunnel.NewManager(wtRunner, client, wt.StateDir, logger)
|
wgMgr := wgtunnel.NewManager(wtRunner, client, wt.StateDir, logger)
|
||||||
|
wgMgr.SetStaleAfter(time.Duration(wt.StaleAfterSeconds) * time.Second)
|
||||||
wgLoop = wgtunnel.NewLoop(wgMgr, time.Duration(wt.IntervalSeconds)*time.Second, logger)
|
wgLoop = wgtunnel.NewLoop(wgMgr, time.Duration(wt.IntervalSeconds)*time.Second, logger)
|
||||||
desiredSyncer.AddConsumer(wgLoop) // raw desired-state → the wireguard block
|
desiredSyncer.AddConsumer(wgLoop) // raw desired-state → the wireguard block
|
||||||
collector.SetWireguardReporter(wgLoop) // heartbeat status stanza
|
collector.SetWireguardReporter(wgLoop) // heartbeat status stanza
|
||||||
|
|||||||
@@ -102,13 +102,15 @@ var manifest = []Capability{
|
|||||||
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
|
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
|
||||||
{"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true},
|
{"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true},
|
||||||
|
|
||||||
// ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical: false — the tunnel is not yet
|
// ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical FLIPPED in S4/v0.66.0 — offsite
|
||||||
// load-bearing (offsite backup rides it only from S4, which flips the backup-path entries
|
// backups now RIDE the tunnel, so a degraded tunnel capability is operator-alert-worthy: the
|
||||||
// to Critical). The handshake read is the ONLY wg invocation (never `dump`). ----
|
// conf install, unit enable/restart and the handshake read gate the backup path. apt-install
|
||||||
|
// (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-critical. The
|
||||||
|
// handshake read is the ONLY wg invocation (never `dump`). ----
|
||||||
{"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false},
|
{"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false},
|
||||||
{"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, false},
|
{"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, true},
|
||||||
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, false},
|
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, true},
|
||||||
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, false},
|
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, true},
|
||||||
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false},
|
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false},
|
||||||
{"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, false},
|
{"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, true},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,3 +216,34 @@ func TestRedProof_DroppedControllerSwapTeeFailsCheck(t *testing.T) {
|
|||||||
t.Errorf("controllerswap-write should be covered by the real sudoers")
|
t.Errorf("controllerswap-write should be covered by the real sudoers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWGCapabilityCriticality pins the exact S4 (v0.66.0) Critical set for the FELHOM_WG entries:
|
||||||
|
// the backup path (conf install, unit enable/restart, handshake read) is operator-alert-worthy now
|
||||||
|
// that offsite backups ride the tunnel; the one-time apt install and the deliberate disable
|
||||||
|
// (revocation) are NOT. Red-proof: flip any one entry's Critical in manifest.go and this fails.
|
||||||
|
func TestWGCapabilityCriticality(t *testing.T) {
|
||||||
|
wantCritical := map[string]bool{
|
||||||
|
"wg-tools-install": false,
|
||||||
|
"wg-conf-install": true,
|
||||||
|
"wg-enable": true,
|
||||||
|
"wg-restart": true,
|
||||||
|
"wg-disable": false,
|
||||||
|
"wg-handshake-read": true,
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, c := range Manifest() {
|
||||||
|
want, ok := wantCritical[c.Name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[c.Name] = true
|
||||||
|
if c.Critical != want {
|
||||||
|
t.Errorf("%s: Critical = %v, want %v", c.Name, c.Critical, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range wantCritical {
|
||||||
|
if !seen[name] {
|
||||||
|
t.Errorf("manifest missing wg capability %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,9 +43,13 @@ type WGTunnelConfig struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
IntervalSeconds int `json:"interval_seconds"` // reconcile cadence; default 60
|
IntervalSeconds int `json:"interval_seconds"` // reconcile cadence; default 60
|
||||||
StateDir string `json:"state_dir"` // key/marker/staged-conf under <StateDir>/wg/; default /var/lib/felhom-agent (the FELHOM_WG sudoers install entry hard-codes this default)
|
StateDir string `json:"state_dir"` // key/marker/staged-conf under <StateDir>/wg/; default /var/lib/felhom-agent (the FELHOM_WG sudoers install entry hard-codes this default)
|
||||||
|
// StaleAfterSeconds is the handshake-age threshold (S4, doc 06 §4.2) beyond which the manager
|
||||||
|
// re-resolves the endpoint's A record and re-applies on an IP change (endpoint re-IP recovery).
|
||||||
|
// Default 180 (≈ 3× the 25s keepalive → a healthy tunnel never trips it).
|
||||||
|
StaleAfterSeconds int `json:"stale_after_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithDefaults fills interval + state dir.
|
// WithDefaults fills interval + state dir + staleness threshold.
|
||||||
func (w WGTunnelConfig) WithDefaults() WGTunnelConfig {
|
func (w WGTunnelConfig) WithDefaults() WGTunnelConfig {
|
||||||
if w.IntervalSeconds == 0 {
|
if w.IntervalSeconds == 0 {
|
||||||
w.IntervalSeconds = 60
|
w.IntervalSeconds = 60
|
||||||
@@ -53,6 +57,9 @@ func (w WGTunnelConfig) WithDefaults() WGTunnelConfig {
|
|||||||
if w.StateDir == "" {
|
if w.StateDir == "" {
|
||||||
w.StateDir = "/var/lib/felhom-agent"
|
w.StateDir = "/var/lib/felhom-agent"
|
||||||
}
|
}
|
||||||
|
if w.StaleAfterSeconds == 0 {
|
||||||
|
w.StaleAfterSeconds = 180
|
||||||
|
}
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func (l *Loop) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
fetched, block = l.snapshot()
|
fetched, block = l.snapshot()
|
||||||
l.mgr.Apply(ctx, fetched, block)
|
l.mgr.Apply(ctx, fetched, block)
|
||||||
|
l.mgr.Watchdog(ctx, fetched, block) // re-resolve + recover on endpoint re-IP (doc 06 §4.2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -44,6 +45,32 @@ type Registrar interface {
|
|||||||
RegisterWG(ctx context.Context, pubkey string) (*hub.WGRegisterResponse, error)
|
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
|
// 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.
|
// registration gate: present → never register again except the pubkey-mismatch re-key path.
|
||||||
// KEPT on revocation (revoked stays revoked — doc 06 §3.5 completion).
|
// KEPT on revocation (revoked stays revoked — doc 06 §3.5 completion).
|
||||||
@@ -66,12 +93,20 @@ type Manager struct {
|
|||||||
isActive func(ctx context.Context) bool
|
isActive func(ctx context.Context) bool
|
||||||
wgPresent func() bool
|
wgPresent func() bool
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
resolver Resolver
|
||||||
|
|
||||||
|
staleAfter time.Duration // handshake-age threshold for the re-resolve watchdog (doc 06 §4.2)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
nextRegAt time.Time
|
nextRegAt time.Time
|
||||||
regBackoff time.Duration
|
regBackoff time.Duration
|
||||||
keyBroken bool // corrupt key / partial state — loop idles until operator resolves
|
keyBroken bool // corrupt key / partial state — loop idles until operator resolves
|
||||||
brokenAnnounced bool
|
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 —
|
// NewManager builds a Manager. stateDir is the agent state dir (default /var/lib/felhom-agent —
|
||||||
@@ -93,7 +128,19 @@ func NewManager(runner proxmox.Runner, registrar Registrar, stateDir string, log
|
|||||||
_, err := os.Stat("/usr/bin/wg")
|
_, err := os.Stat("/usr/bin/wg")
|
||||||
return err == nil
|
return err == nil
|
||||||
},
|
},
|
||||||
now: time.Now,
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,17 +195,23 @@ func validKeyB64(s string) error {
|
|||||||
// (silently black-holed sub-~1480 paths — CGNAT smoke test 2026-07-04). See doc 06 §4.3.
|
// (silently black-holed sub-~1480 paths — CGNAT smoke test 2026-07-04). See doc 06 §4.3.
|
||||||
const clientMTU = 1280
|
const clientMTU = 1280
|
||||||
|
|
||||||
// renderConf builds the wg-felhom.conf content from the hub block + the local private key.
|
// renderConf builds the wg-felhom.conf content from the hub block + the local private key + the
|
||||||
// Client-side constants per doc 06 §4: MTU 1280, AllowedIPs = pbs_tunnel_ip/32 (the tunnel
|
// 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
|
// carries ONLY box→PBS traffic), PersistentKeepalive 25. All inputs validated — nothing
|
||||||
// user-controlled is interpolatable (strict charsets, netip parses).
|
// user-controlled is interpolatable (strict charsets, netip parses).
|
||||||
func renderConf(block *hub.WireWireguard, privB64 string) (string, error) {
|
func renderConf(block *hub.WireWireguard, privB64, endpointIPv4 string) (string, error) {
|
||||||
if err := validKeyB64(privB64); err != nil {
|
if err := validKeyB64(privB64); err != nil {
|
||||||
return "", fmt.Errorf("wgtunnel: private key: %w", err)
|
return "", fmt.Errorf("wgtunnel: private key: %w", err)
|
||||||
}
|
}
|
||||||
if err := validKeyB64(block.Endpoint.ServerPubkey); err != nil {
|
if err := validKeyB64(block.Endpoint.ServerPubkey); err != nil {
|
||||||
return "", fmt.Errorf("wgtunnel: server_pubkey: %w", err)
|
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)
|
addr, err := netip.ParsePrefix(block.AssignedIP)
|
||||||
if err != nil || addr.Bits() != 32 {
|
if err != nil || addr.Bits() != 32 {
|
||||||
return "", fmt.Errorf("wgtunnel: assigned_ip %q is not an ip/32", block.AssignedIP)
|
return "", fmt.Errorf("wgtunnel: assigned_ip %q is not an ip/32", block.AssignedIP)
|
||||||
@@ -182,7 +235,7 @@ func renderConf(block *hub.WireWireguard, privB64 string) (string, error) {
|
|||||||
fmt.Fprintf(&b, "MTU = %d\n\n", clientMTU)
|
fmt.Fprintf(&b, "MTU = %d\n\n", clientMTU)
|
||||||
b.WriteString("[Peer]\n")
|
b.WriteString("[Peer]\n")
|
||||||
fmt.Fprintf(&b, "PublicKey = %s\n", block.Endpoint.ServerPubkey)
|
fmt.Fprintf(&b, "PublicKey = %s\n", block.Endpoint.ServerPubkey)
|
||||||
fmt.Fprintf(&b, "Endpoint = %s:%d\n", block.Endpoint.DNSName, block.Endpoint.WGPort)
|
fmt.Fprintf(&b, "Endpoint = %s:%d\n", epIP, block.Endpoint.WGPort)
|
||||||
fmt.Fprintf(&b, "AllowedIPs = %s/32\n", pbsIP)
|
fmt.Fprintf(&b, "AllowedIPs = %s/32\n", pbsIP)
|
||||||
b.WriteString("PersistentKeepalive = 25\n")
|
b.WriteString("PersistentKeepalive = 25\n")
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
@@ -320,7 +373,11 @@ func (m *Manager) ensureTunnelLocked(ctx context.Context, block *hub.WireWiregua
|
|||||||
m.announceBroken("wgtunnel: " + err.Error())
|
m.announceBroken("wgtunnel: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conf, err := renderConf(block, priv)
|
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 {
|
if err != nil {
|
||||||
m.logger.Error("wgtunnel: refusing to apply invalid desired block", "err", err)
|
m.logger.Error("wgtunnel: refusing to apply invalid desired block", "err", err)
|
||||||
return
|
return
|
||||||
@@ -375,7 +432,98 @@ func (m *Manager) ensureTunnelLocked(ctx context.Context, block *hub.WireWiregua
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.logger.Info("wgtunnel: tunnel conf applied", "endpoint",
|
m.logger.Info("wgtunnel: tunnel conf applied", "endpoint",
|
||||||
fmt.Sprintf("%s:%d", block.Endpoint.DNSName, block.Endpoint.WGPort), "assigned_ip", block.AssignedIP, "action", verb[0])
|
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).
|
// ensureToolsLocked installs wireguard-tools once when absent (dnsmasq-install precedent).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -19,6 +20,33 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// fakeResolver drives endpoint resolution deterministically + counts lookups (the "no DNS while
|
||||||
|
// healthy" negative asserts count stays flat).
|
||||||
|
type fakeResolver struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
addrs []netip.Addr
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResolver) LookupIPv4(_ context.Context, _ string) ([]netip.Addr, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return append([]netip.Addr(nil), f.addrs...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResolver) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }
|
||||||
|
func (f *fakeResolver) set(a ...netip.Addr) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.addrs, f.err = a, nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
func (f *fakeResolver) fail(e error) { f.mu.Lock(); f.err = e; f.mu.Unlock() }
|
||||||
|
|
||||||
// recordingRunner records every privileged exec; scripted errors per command name.
|
// recordingRunner records every privileged exec; scripted errors per command name.
|
||||||
type recordingRunner struct {
|
type recordingRunner struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -107,6 +135,8 @@ func testManager(t *testing.T, fh *fakeHub) (*Manager, *recordingRunner, *bool,
|
|||||||
m := NewManager(rr, fh, t.TempDir(), logger)
|
m := NewManager(rr, fh, t.TempDir(), logger)
|
||||||
m.isActive = func(ctx context.Context) bool { return active }
|
m.isActive = func(ctx context.Context) bool { return active }
|
||||||
m.wgPresent = func() bool { return true }
|
m.wgPresent = func() bool { return true }
|
||||||
|
// Default resolver: one A record, no real DNS. Tests exercising the watchdog swap in their own.
|
||||||
|
m.resolver = &fakeResolver{addrs: []netip.Addr{netip.MustParseAddr("167.233.158.164")}}
|
||||||
return m, rr, &active, logBuf
|
return m, rr, &active, logBuf
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +161,12 @@ func localPub(t *testing.T, m *Manager) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestRenderConf_Golden pins the EXACT conf bytes, including "MTU = 1280" (the IPv6-minimum
|
// TestRenderConf_Golden pins the EXACT conf bytes, including "MTU = 1280" (the IPv6-minimum
|
||||||
// floor, doc 06 §4.3). Red-proofed 2026-07-04: flipping clientMTU back to 1420 fails this test
|
// floor, doc 06 §4.3) and the v4-pinned "Endpoint = 167.233.158.164:443" (doc 06 §4.2 — the A
|
||||||
// on the MTU line mismatch — the golden is non-vacuous, not a "contains MTU" check.
|
// LITERAL, never the DNS name, never an AAAA). Red-proofed: flipping clientMTU back to 1420, or
|
||||||
|
// rendering the dns_name instead of the resolved literal, fails this on the mismatch — non-vacuous.
|
||||||
func TestRenderConf_Golden(t *testing.T) {
|
func TestRenderConf_Golden(t *testing.T) {
|
||||||
block := testBlock("ignored")
|
block := testBlock("ignored")
|
||||||
conf, err := renderConf(block, vectorPrivB64)
|
conf, err := renderConf(block, vectorPrivB64, "167.233.158.164")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -147,29 +178,36 @@ MTU = 1280
|
|||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=
|
PublicKey = CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=
|
||||||
Endpoint = ep0.felhom.eu:443
|
Endpoint = 167.233.158.164:443
|
||||||
AllowedIPs = 10.77.0.1/32
|
AllowedIPs = 10.77.0.1/32
|
||||||
PersistentKeepalive = 25
|
PersistentKeepalive = 25
|
||||||
`
|
`
|
||||||
if conf != want {
|
if conf != want {
|
||||||
t.Errorf("conf mismatch:\n--- got ---\n%s\n--- want ---\n%s", conf, want)
|
t.Errorf("conf mismatch:\n--- got ---\n%s\n--- want ---\n%s", conf, want)
|
||||||
}
|
}
|
||||||
// Refusals: bad server key, bad ip, hostile dns_name.
|
// Refusals: bad server key, bad ip, hostile dns_name, non-v4 endpoint literal.
|
||||||
bad := *block
|
bad := *block
|
||||||
bad.Endpoint.ServerPubkey = "short"
|
bad.Endpoint.ServerPubkey = "short"
|
||||||
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
|
if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
|
||||||
t.Error("bad server pubkey accepted")
|
t.Error("bad server pubkey accepted")
|
||||||
}
|
}
|
||||||
bad = *block
|
bad = *block
|
||||||
bad.AssignedIP = "10.77.0.2" // no /32
|
bad.AssignedIP = "10.77.0.2" // no /32
|
||||||
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
|
if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
|
||||||
t.Error("assigned_ip without /32 accepted")
|
t.Error("assigned_ip without /32 accepted")
|
||||||
}
|
}
|
||||||
bad = *block
|
bad = *block
|
||||||
bad.Endpoint.DNSName = "evil host\ninjected"
|
bad.Endpoint.DNSName = "evil host\ninjected"
|
||||||
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
|
if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
|
||||||
t.Error("hostile dns_name accepted")
|
t.Error("hostile dns_name accepted")
|
||||||
}
|
}
|
||||||
|
// The endpoint literal must be IPv4 — a v6 (or the DNS name) is refused.
|
||||||
|
if _, err := renderConf(block, vectorPrivB64, "2a01:4f8:1c16:7aa1::1"); err == nil {
|
||||||
|
t.Error("IPv6 endpoint literal accepted (v4-pin violated)")
|
||||||
|
}
|
||||||
|
if _, err := renderConf(block, vectorPrivB64, "ep0.felhom.eu"); err == nil {
|
||||||
|
t.Error("DNS name accepted as endpoint literal")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
|
func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
|
||||||
@@ -450,3 +488,161 @@ func TestNoKeyMaterialInLogs(t *testing.T) {
|
|||||||
t.Error("pubkey absent from logs — expected (pubkeys are fine to log)")
|
t.Error("pubkey absent from logs — expected (pubkeys are fine to log)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- S4: v4-pin + re-resolve watchdog (doc 06 §4.2) ---
|
||||||
|
|
||||||
|
// TestV4Pin_LowestARecordInConf: Apply renders the DETERMINISTIC-lowest A record as the Endpoint
|
||||||
|
// literal (never the DNS name), resolves exactly once, and a steady-state re-Apply hits the cache
|
||||||
|
// (no second DNS call — the "no DNS while healthy" negative at the Apply level).
|
||||||
|
func TestV4Pin_LowestARecordInConf(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, _, active, _ := testManager(t, fh)
|
||||||
|
fr := &fakeResolver{}
|
||||||
|
fr.set(netip.MustParseAddr("167.233.158.164"), netip.MustParseAddr("10.0.0.5")) // lowest = 10.0.0.5
|
||||||
|
m.resolver = fr
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m.Apply(ctx, false, nil)
|
||||||
|
pub := localPub(t, m)
|
||||||
|
m.Apply(ctx, true, testBlock(pub))
|
||||||
|
*active = true
|
||||||
|
|
||||||
|
staged, err := os.ReadFile(m.stagedConfPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(staged), "Endpoint = 10.0.0.5:443") {
|
||||||
|
t.Errorf("conf did not pin the lowest A record:\n%s", staged)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(staged), "ep0.felhom.eu") {
|
||||||
|
t.Error("DNS name leaked into the conf (v4-pin violated)")
|
||||||
|
}
|
||||||
|
m.Apply(ctx, true, testBlock(pub)) // steady state
|
||||||
|
if fr.count() != 1 {
|
||||||
|
t.Errorf("resolver calls = %d, want exactly 1 (cached after first apply — no DNS while healthy)", fr.count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// establishWG brings a manager to applied+active over the given resolver; returns the local pubkey.
|
||||||
|
func establishWG(t *testing.T, m *Manager, active *bool) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
m.Apply(ctx, false, nil)
|
||||||
|
pub := localPub(t, m)
|
||||||
|
m.Apply(ctx, true, testBlock(pub))
|
||||||
|
*active = true
|
||||||
|
return pub
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchdog_HealthyNoReResolve(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, rr, active, _ := testManager(t, fh)
|
||||||
|
fr := &fakeResolver{}
|
||||||
|
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||||
|
m.resolver = fr
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115+30, 0) } // handshake age 30s < 180 → fresh
|
||||||
|
dnsBefore, scBefore := fr.count(), rr.count("systemctl")
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
if fr.count() != dnsBefore {
|
||||||
|
t.Errorf("watchdog re-resolved a HEALTHY tunnel: %d extra DNS calls", fr.count()-dnsBefore)
|
||||||
|
}
|
||||||
|
if rr.count("systemctl") != scBefore {
|
||||||
|
t.Errorf("watchdog took mutating action on a healthy tunnel: %v", rr.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchdog_StaleReIPRestarts(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, rr, active, _ := testManager(t, fh)
|
||||||
|
fr := &fakeResolver{}
|
||||||
|
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||||
|
m.resolver = fr
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // age 300s > 180 → stale
|
||||||
|
fr.set(netip.MustParseAddr("192.0.2.9")) // endpoint re-IP
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
|
||||||
|
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl restart wg-quick@wg-felhom" {
|
||||||
|
t.Errorf("re-IP watchdog systemctl = %v, want restart", got)
|
||||||
|
}
|
||||||
|
staged, err := os.ReadFile(m.stagedConfPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(staged), "Endpoint = 192.0.2.9:443") {
|
||||||
|
t.Errorf("conf not re-pinned to the new endpoint IP:\n%s", staged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchdog_StaleSameIPNoChurn(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, rr, active, logBuf := testManager(t, fh)
|
||||||
|
fr := &fakeResolver{}
|
||||||
|
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||||
|
m.resolver = fr
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // stale, but resolver returns SAME IP
|
||||||
|
scBefore := rr.count("systemctl")
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub)) // second stale tick
|
||||||
|
if rr.count("systemctl") != scBefore {
|
||||||
|
t.Errorf("watchdog restarted on stale-but-unchanged IP (churn): %v", rr.calls)
|
||||||
|
}
|
||||||
|
if n := strings.Count(logBuf.String(), "endpoint IP unchanged"); n != 1 {
|
||||||
|
t.Errorf("stale-same-IP warning logged %d times, want throttled to 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchdog_ResolverFailureKeepsConf(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, rr, active, _ := testManager(t, fh)
|
||||||
|
fr := &fakeResolver{}
|
||||||
|
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||||
|
m.resolver = fr
|
||||||
|
pub := establishWG(t, m, active)
|
||||||
|
ctx := context.Background()
|
||||||
|
orig, _ := os.ReadFile(m.stagedConfPath())
|
||||||
|
|
||||||
|
m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // stale
|
||||||
|
fr.fail(errors.New("resolver down"))
|
||||||
|
scBefore := rr.count("systemctl")
|
||||||
|
m.Watchdog(ctx, true, testBlock(pub))
|
||||||
|
if rr.count("systemctl") != scBefore {
|
||||||
|
t.Errorf("watchdog acted despite a resolver failure: %v", rr.calls)
|
||||||
|
}
|
||||||
|
after, _ := os.ReadFile(m.stagedConfPath())
|
||||||
|
if !bytes.Equal(orig, after) {
|
||||||
|
t.Error("conf changed on resolver failure — must keep the last-applied conf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitialResolveFailureNoTeardown: first apply with a failing resolver cannot render — so it
|
||||||
|
// applies NOTHING and NEVER tears down (DNS failure ≠ revocation); it recovers when DNS returns.
|
||||||
|
func TestInitialResolveFailureNoTeardown(t *testing.T) {
|
||||||
|
fh := &fakeHub{}
|
||||||
|
m, rr, _, _ := testManager(t, fh)
|
||||||
|
fr := &fakeResolver{}
|
||||||
|
fr.fail(errors.New("resolver down"))
|
||||||
|
m.resolver = fr
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m.Apply(ctx, false, nil) // register (no DNS)
|
||||||
|
pub := localPub(t, m)
|
||||||
|
m.Apply(ctx, true, testBlock(pub)) // resolve fails → cannot apply
|
||||||
|
if rr.count("install") != 0 || rr.count("systemctl") != 0 {
|
||||||
|
t.Errorf("applied/tore-down despite a resolve failure: %v", rr.calls)
|
||||||
|
}
|
||||||
|
// DNS returns → the tunnel comes up on the next tick.
|
||||||
|
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||||
|
m.Apply(ctx, true, testBlock(pub))
|
||||||
|
if rr.count("install") != 1 {
|
||||||
|
t.Errorf("did not recover after DNS returned: %v", rr.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user