101 lines
4.8 KiB
Go
101 lines
4.8 KiB
Go
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")
|
||
}
|
||
}
|