Files
felhom.eu/hub/internal/api/host_tunnel.go
T

101 lines
4.8 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 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 records once when it is healthy again (wg_tunnel_ok is an info event: stored, not
// mailed). Operator-only (notify.operatorOnlyEvents): the household cannot fix it. The repair is D30 (operator
// 2026-10-22): an agent with it makes a new key after 15 minutes of a future-stamped handshake and registers it here
// (re-key in place, the full-list push to ep0); the new key's first handshake clears this alarm — pinned by
// TestD30_TheAlarmClearsWhenTheNewKeyShakesHands.
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 (an agent with the D30 repair makes a new key by itself within about 15 minutes)", 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("[%s] host %s: %s", map[bool]string{true: "WARN", false: "INFO"}[stale], 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")
}
}