package api import ( "encoding/json" "fmt" ) // R-1204 (rehearsal 2026-10-20 B6 #8). A box that runs once with its clock AHEAD stamps a WireGuard handshake in the // future; ep0 then refuses every newer handshake (WireGuard's replay protection: the TAI64N timestamp must be newer // than the last one accepted from that peer), so the box's tunnel to ep0 — its PBS whole-box copies and the // operator's door — stays dead until the real date passes the wrong one, while the box itself reports normally. // Nothing said why. The hub now tells the OPERATOR once per host, edge-triggered from the host's newest tunnel event // (the host_swap shape), and once when it is healthy again. Operator-only (notify.operatorOnlyEvents): the household // cannot fix it. The repair (re-key the peer, or ep0 forgets the stored timestamp) is a separate decision. const ( eventWGTunnelStale = "wg_tunnel_stale" eventWGTunnelOK = "wg_tunnel_ok" // wgTunnelStaleAfterS: a healthy tunnel shakes hands at least every ~2 minutes (measured 2026-10-22 over 24 h of // reports on three boxes: 113–124 s at most); six hours is far past any reboot or network blip. wgTunnelStaleAfterS = 6 * 3600 ) // hostWireguard is the agent's `wireguard` stanza (felhom-agent hub.WireguardStatus). type hostWireguard struct { Registered bool `json:"registered"` Active bool `json:"active"` LastHandshakeAgeS *int64 `json:"last_handshake_age_s"` // HandshakeFutureS (agent ≥ the R-1204 commit): > 0 when the latest handshake lies in the future by the box's clock. HandshakeFutureS *int64 `json:"handshake_future_s"` } // wgTunnelStale judges one report: (stale, the cause in plain words). A box without a registered, active tunnel, or // one that sent no age, is not judged. func wgTunnelStale(w *hostWireguard) (bool, string) { if w == nil || !w.Registered || !w.Active { return false, "" } if w.HandshakeFutureS != nil && *w.HandshakeFutureS > 0 { return true, fmt.Sprintf("its last handshake is stamped %s in the FUTURE by the box's own clock — the clock ran ahead once, and ep0 refuses every newer handshake until that time passes", humanDuration(*w.HandshakeFutureS)) } if w.LastHandshakeAgeS != nil && *w.LastHandshakeAgeS > wgTunnelStaleAfterS { return true, fmt.Sprintf("it has not shaken hands for %s while the box reports — the box's clock may have run ahead (then ep0 refuses it), or ep0 dropped the peer", humanDuration(*w.LastHandshakeAgeS)) } return false, "" } func humanDuration(s int64) string { switch { case s >= 2*86400: return fmt.Sprintf("%d days", s/86400) case s >= 2*3600: return fmt.Sprintf("%d hours", s/3600) default: return fmt.Sprintf("%d minutes", s/60) } } func (h *Handler) checkWGTunnel(hostID, custID string, w *hostWireguard) { if w == nil || custID == "" || !w.Registered || !w.Active || (w.LastHandshakeAgeS == nil && w.HandshakeFutureS == nil) { return // not judged: no tunnel, or an agent that sent nothing to judge by } stale, cause := wgTunnelStale(w) prev, err := h.store.LatestHostTunnelEvent(custID, hostID) if err != nil { h.logger.Printf("[WARN] host %s tunnel check: reading the last tunnel event failed: %v", hostID, err) return } wasStale := prev != nil && prev.EventType == eventWGTunnelStale var typ, sev, msg string switch { case stale && !wasStale: typ, sev = eventWGTunnelStale, "warning" msg = fmt.Sprintf("Host %s: its WireGuard tunnel to the off-site server (ep0) is down — %s. Its whole-box copies to ep0 "+ "and the operator's door do not work until it is repaired (R-1204). The household cannot fix this.", hostID, cause) case !stale && wasStale: typ, sev = eventWGTunnelOK, "info" msg = fmt.Sprintf("Host %s: its WireGuard tunnel to the off-site server (ep0) shakes hands again.", hostID) default: return } h.logger.Printf("[WARN] host %s: %s", hostID, typ) det := map[string]any{"host_id": hostID} if w.LastHandshakeAgeS != nil { det["last_handshake_age_s"] = *w.LastHandshakeAgeS } if w.HandshakeFutureS != nil { det["handshake_future_s"] = *w.HandshakeFutureS } details, _ := json.Marshal(det) if _, eerr := h.store.SaveEvent(custID, typ, sev, msg, string(details), "hub"); eerr != nil { h.logger.Printf("[WARN] %s event save FAILED for %s: %v", typ, hostID, eerr) return // the audit row first: a mail without its event row lies } if h.dispatcher != nil { go h.dispatcher.ProcessEvent(custID, typ, sev, msg, string(details), "hub") } }