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

108 lines
5.1 KiB
Go

package api
import (
"net/http"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1204: the box's tunnel to ep0 — a future-stamped handshake (the clock ran ahead) or no handshake for 6 h raises
// wg_tunnel_stale ONCE (edge-triggered from the host's newest tunnel event), and wg_tunnel_ok once when healthy; a
// healthy tunnel, an inactive one and an older agent are not judged.
// RED-PROOF: with checkWGTunnel not called from the host-report handler the first count fails (0 events); with
// wgTunnelStale ignoring handshake_future_s the clock case fails — seen failing 2026-10-22.
func tunnelReportBody(hostID, wg string) string {
b := validReportBody(hostID)
i := strings.LastIndex(b, "}")
return b[:i] + `,"wireguard":` + wg + b[i:]
}
func TestR1204_TheClockAheadTunnelAlarmsOnceAndClears(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
post := func(wg string) {
t.Helper()
if rr := do(h, http.MethodPost, "/host-report", "HKEY", tunnelReportBody("h1", wg)); rr.Code != http.StatusOK {
t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String())
}
}
count := func(typ string) int {
evs, _ := st.GetEventsByType("c1", typ, time.Time{})
return len(evs)
}
post(`{"registered":true,"active":true,"last_handshake_age_s":60}`) // healthy
post(`{"registered":true,"active":false}`) // service down: not judged here
if count(eventWGTunnelStale) != 0 {
t.Fatal("a healthy or inactive tunnel must not alarm")
}
post(`{"registered":true,"active":true,"last_handshake_age_s":0,"handshake_future_s":63072000}`) // ran 2 years ahead
post(`{"registered":true,"active":true,"last_handshake_age_s":0,"handshake_future_s":63071100}`)
if n := count(eventWGTunnelStale); n != 1 {
t.Fatalf("wg_tunnel_stale = %d, want exactly 1 (edge-triggered)", n)
}
evs, _ := st.GetEventsByType("c1", eventWGTunnelStale, time.Time{})
if !strings.Contains(evs[0].Message, "Host h1:") || !strings.Contains(evs[0].Message, "FUTURE") {
t.Fatalf("the alarm must name the host and the clock cause: %q", evs[0].Message)
}
post(`{"registered":true,"active":true,"last_handshake_age_s":45}`)
post(`{"registered":true,"active":true,"last_handshake_age_s":50}`)
if count(eventWGTunnelOK) != 1 {
t.Fatalf("wg_tunnel_ok = %d, want 1", count(eventWGTunnelOK))
}
post(`{"registered":true,"active":true,"last_handshake_age_s":30000}`) // 8 h 20 min with no handshake
if count(eventWGTunnelStale) != 2 {
t.Fatal("six hours without a handshake while reporting must alarm again")
}
}
func TestR1204_WhatIsJudged(t *testing.T) {
i := func(v int64) *int64 { return &v }
for _, c := range []struct {
w *hostWireguard
want bool
}{
{nil, false},
{&hostWireguard{Registered: true, Active: true, LastHandshakeAgeS: i(120)}, false},
{&hostWireguard{Registered: true, Active: true, LastHandshakeAgeS: i(6*3600 + 1)}, true},
{&hostWireguard{Registered: true, Active: true, LastHandshakeAgeS: i(0), HandshakeFutureS: i(3600)}, true},
{&hostWireguard{Registered: false, Active: true, LastHandshakeAgeS: i(99999)}, false},
} {
if got, _ := wgTunnelStale(c.w); got != c.want {
t.Errorf("%+v: stale=%t, want %t", c.w, got, c.want)
}
}
}
// D30 (R-1204): after the agent re-keys, the box reports a restarted tunnel with no handshake yet (nothing to judge),
// then the new key's first handshake — which clears the alarm once, and raises nothing new.
// RED-PROOF (2026-10-22 night): with the `!stale && wasStale` case removed from checkWGTunnel, this fails (no ok event).
func TestD30_TheAlarmClearsWhenTheNewKeyShakesHands(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
post := func(wg string) {
t.Helper()
if rr := do(h, http.MethodPost, "/host-report", "HKEY", tunnelReportBody("h1", wg)); rr.Code != http.StatusOK {
t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String())
}
}
count := func(typ string) int {
evs, _ := st.GetEventsByType("c1", typ, time.Time{})
return len(evs)
}
post(`{"pubkey":"OLD","registered":true,"active":true,"last_handshake_age_s":0,"handshake_future_s":10800}`) // 3 h ahead
post(`{"pubkey":"NEW","registered":true,"active":true}`) // re-keyed, no handshake yet
if count(eventWGTunnelStale) != 1 || count(eventWGTunnelOK) != 0 {
t.Fatalf("stale %d ok %d, want 1 / 0 before the new key shakes hands", count(eventWGTunnelStale), count(eventWGTunnelOK))
}
post(`{"pubkey":"NEW","registered":true,"active":true,"last_handshake_age_s":20}`)
post(`{"pubkey":"NEW","registered":true,"active":true,"last_handshake_age_s":110}`)
if count(eventWGTunnelOK) != 1 || count(eventWGTunnelStale) != 1 {
t.Fatalf("stale %d ok %d, want 1 / 1 — the new key's handshake clears the alarm once", count(eventWGTunnelStale), count(eventWGTunnelOK))
}
}