Files
felhom.eu/hub/internal/web/hosts_network_test.go
T
admin e07d90f0f4 hub v0.85.0 — Network card: a host's addresses are visible at last
Pairs with agent v0.119.0 and is useless without it.

A managed box's LAN IP was not shown anywhere in the hub, because nothing
reported it — the host report carried no address of any kind. The only IP
reachable from the UI at all was the WireGuard one, on /offsite's peer table
keyed by pubkey, so an operator could go peer->host and never host->peer, which
is the direction anyone actually asks in.

The host page grows a Network card: every routable address the box holds, one row
per (interface, address), plus a WireGuard row. On demo-felhom that is vmbr0
192.168.0.162/24 and tailscale0 100.70.170.35/32 — with the PVE web console at
https://<the LAN address>:8006, the thing the operator wanted and could not get.

WireGuard is rendered as TWO facts, deliberately. WGAssignedIP is the hub's own
allocation (wg_peers, authoritative desired state); WGConfirmed is whether the box
reports actually holding it. Showing the allocation alone would make a peer that
was never applied look healthy — the same shape as reading a timestamp that
records an attempt as if it recorded a result.

The split is keyed on the ALLOCATION, not the interface name: wg-felhom is the
agent's current unit name, and a UI keyed on that string would silently
mis-render the day it changes.

An old agent renders UNKNOWN, never "no addresses". Below agent 0.119.0 the field
is absent from the wire, and an absent signal is not a negative result — the page
says so and names the version needed. Rendering an empty list there would have
stated something false about the host.

No new store table and no new ingest path: the report is already stored opaquely
and GetWGPeerForHost already existed with no UI consumer. This is parse + render.

The report fixture in the tests is the REAL wire — the addresses block copied out
of `felhom-agent --selftest=hub` on demo-felhom running 0.119.0.

Tests 559 -> 566; four red-proofs (inert view-model, unconditional confirmation,
the old-agent branch, and the drift case) each run, observed failing, reverted.
2026-07-31 08:49:44 +02:00

242 lines
9.1 KiB
Go

package web
// Network card (hub v0.85.0) — the host's addresses + its WireGuard allocation.
//
// The report fixture below is the REAL wire: it is the `addresses` block copied out of
// `felhom-agent --selftest=hub` on demo-felhom running agent 0.119.0 on 2026-07-31. Testing against
// a hand-written shape would have proved only that the parser matches my own idea of the format.
//
// Every test drives ServeHTTP, so a route/template gate that never renders is visible here.
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// liveReportJSON is demo-felhom's real report body, trimmed to the parsed fields.
const liveReportJSON = `{
"host": {"cpu_percent": 4.0, "memory_percent": 30.0, "disk_percent": 20.0},
"addresses": [
{"iface": "tailscale0", "cidr": "100.70.170.35/32"},
{"iface": "tailscale0", "cidr": "fd7a:115c:a1e0::5236:aa24/128"},
{"iface": "vmbr0", "cidr": "192.168.0.162/24"},
{"iface": "wg-felhom", "cidr": "10.77.0.2/32"}
]
}`
// seedNetHost creates a host with a report and (optionally) a WG peer allocation.
func seedNetHost(t *testing.T, st *store.Store, hostID, agentVersion, reportJSON, wgIP string) {
t.Helper()
if err := st.UpsertHost(&store.Host{
HostID: hostID, CustomerID: "c-" + hostID, APIKey: "k-" + hostID, AgentVersion: agentVersion,
}); err != nil {
t.Fatal(err)
}
if reportJSON != "" {
if err := st.SaveHostReport(hostID, "c-"+hostID, []byte(reportJSON), store.HostReportDenorm{
AgentVersion: agentVersion,
}); err != nil {
t.Fatal(err)
}
}
if wgIP != "" {
if err := st.SetWGEndpoint(&store.WGEndpoint{
EndpointID: "ep0", DNSName: "ep0.example", WGPort: 51820,
ServerPubkey: "srv", TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
}); err != nil {
t.Fatal(err)
}
if _, _, err := t3AddPeer(st, hostID, wgIP); err != nil {
t.Fatal(err)
}
}
}
// t3AddPeer binds a peer at a SPECIFIC address (the allocator picks the lowest free one, so the
// fixture drives it to the address the live box actually holds).
func t3AddPeer(st *store.Store, hostID, wantIP string) (string, bool, error) {
// Allocate sequentially until the wanted address is the one handed out; the subnet is /24 and
// the fixtures ask for .2/.3, so this costs a couple of rows.
for i := 0; i < 8; i++ {
ip, existed, err := st.AddWGPeer("pk-"+hostID+"-"+string(rune('a'+i)), hostID, "test")
if err != nil {
return "", false, err
}
if ip == wantIP {
return ip, existed, nil
}
}
return "", false, nil
}
func getHostPage(t *testing.T, s *Server, cookie *http.Cookie, hostID string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("host page = %d, want 200", rr.Code)
}
return rr.Body.String()
}
// --- A: the LAN address reaches the page. This is the whole point of the feature. ---
// RED-PROOF A: delete the `"Network": network` key from hostDetailData → red.
func TestNetwork_A_LANAddressRendered(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.119.0", liveReportJSON, "10.77.0.2")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "192.168.0.162/24") {
t.Fatal("the LAN address is not on the host page — the feature shows nothing")
}
if !strings.Contains(body, "vmbr0") {
t.Error("the interface carrying the LAN address is not named")
}
if !strings.Contains(body, "Network") {
t.Error("no Network card")
}
// The tailnet address is a real address and is shown too, labelled by its interface.
if !strings.Contains(body, "100.70.170.35/32") || !strings.Contains(body, "tailscale0") {
t.Error("the tailnet address was dropped")
}
}
// --- B: WireGuard is TWO facts — the hub's allocation, and whether the box confirms it ---
// RED-PROOF B: make hostNetwork set WGConfirmed = true unconditionally → the C case below goes red.
func TestNetwork_B_WireGuardAllocatedAndConfirmed(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "demo-felhom-8363b5", "0.119.0", liveReportJSON, "10.77.0.2")
body := getHostPage(t, s, cookie, "demo-felhom-8363b5")
if !strings.Contains(body, "10.77.0.2") {
t.Fatal("the WireGuard address is not on the page")
}
if !strings.Contains(body, "confirmed") {
t.Error("a box that reports holding its allocated address is not marked confirmed")
}
// The WG address must NOT also appear as an ordinary address row — one fact, one place.
if strings.Contains(body, "wg-felhom") {
t.Error("the WireGuard address was repeated in the address table instead of its own row")
}
}
// The drift case that motivates splitting the two facts: the hub allocated a peer the box does not
// hold. Rendering the allocation alone would show this as healthy.
func TestNetwork_C_AllocatedButBoxDoesNotHoldIt(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
// The report carries NO 10.77.0.x address — the peer exists only in the hub.
report := `{"addresses":[{"iface":"vmbr0","cidr":"192.168.0.50/24"}]}`
seedNetHost(t, st, "drifted-host", "0.119.0", report, "10.77.0.2")
body := getHostPage(t, s, cookie, "drifted-host")
if !strings.Contains(body, "not confirmed by the box") {
t.Fatal("an allocated-but-unheld WireGuard peer renders as healthy — the drift is invisible")
}
if strings.Contains(body, ">confirmed<") {
t.Error("the page claims confirmation the box never gave")
}
}
// --- D: an OLD agent means UNKNOWN, not "no addresses" (presence is not result) ---
// RED-PROOF D: drop the AgentTooOld branch from hostNetwork → the page renders the generic
// "no routable address" line and this goes red.
func TestNetwork_D_OldAgentSaysUnknownNotNone(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
// 0.118.1 predates addresses[] — it sends no such key at all.
seedNetHost(t, st, "old-agent-host", "0.118.1", `{"host":{"cpu_percent":1}}`, "10.77.0.2")
body := getHostPage(t, s, cookie, "old-agent-host")
if !strings.Contains(body, "does not report its addresses") {
t.Fatal("an old agent's silence is not explained")
}
if !strings.Contains(body, "unknown") {
t.Error("the page must say UNKNOWN — an absent field and an empty result are different facts")
}
if strings.Contains(body, "reports no routable address") {
t.Fatal("an old agent renders as 'this host has no addresses', which is a false statement")
}
// The allocation is still shown — the hub knows it regardless of the agent's version.
if !strings.Contains(body, "10.77.0.2") {
t.Error("the WireGuard allocation vanished just because the agent is old")
}
}
// --- E: a host with no WG peer says so, rather than rendering a blank ---
func TestNetwork_E_NoPeerAllocated(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "no-peer-host", "0.119.0", liveReportJSON, "")
body := getHostPage(t, s, cookie, "no-peer-host")
if !strings.Contains(body, "no peer allocated") {
t.Error("a host without a WireGuard peer does not say so")
}
// With no allocation the WG address cannot be split out, so it stays an ordinary row — still
// visible, never silently dropped.
if !strings.Contains(body, "10.77.0.2/32") {
t.Error("the box's wg address disappeared when the hub had no allocation to match it against")
}
}
// --- F: never-reported host — the card must not claim anything ---
func TestNetwork_F_NoReportYet(t *testing.T) {
s, st, _ := newRevealServer(t)
cookie, _ := newRevealSession(t, s)
seedNetHost(t, st, "fresh-host", "0.119.0", "", "")
body := getHostPage(t, s, cookie, "fresh-host")
if !strings.Contains(body, "Waiting for the first host report") {
t.Error("a never-reported host does not say it is waiting")
}
if strings.Contains(body, "reports no routable address") {
t.Error("a host that never reported is described as having reported no addresses")
}
}
// --- G: the parser, against the real wire ---
func TestParseHostAddresses_RealWire(t *testing.T) {
got := parseHostAddresses(liveReportJSON)
if len(got) != 4 {
t.Fatalf("want 4 addresses from the live report, got %d: %+v", len(got), got)
}
// The bare IP must be split off the CIDR — it is what the WG comparison keys on.
var found bool
for _, a := range got {
if a.Iface == "vmbr0" {
found = true
if a.CIDR != "192.168.0.162/24" || a.IP != "192.168.0.162" {
t.Errorf("vmbr0 parsed wrong: %+v", a)
}
}
}
if !found {
t.Error("vmbr0 missing from the parse")
}
// Degradations: neither an empty body nor a malformed one may panic or invent rows.
if got := parseHostAddresses(""); len(got) != 0 {
t.Errorf("empty body produced %d rows", len(got))
}
if got := parseHostAddresses("{not json"); len(got) != 0 {
t.Errorf("malformed body produced %d rows", len(got))
}
if got := parseHostAddresses(`{"host":{}}`); got == nil {
t.Error("a report without addresses returned nil rather than an empty slice")
}
}