diff --git a/REUSE.md b/REUSE.md index ec19d0e..5f0fee4 100644 --- a/REUSE.md +++ b/REUSE.md @@ -71,6 +71,7 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| | `(*Server).hostDetailData` | hub/internal/web/hosts.go (~L282) | `(host *store.Host, r) map[string]interface{}` | The ONE view-model builder for the shared `host_detail_body` sub-template (standalone `/hosts/{id}` + customer Host tab) | Booleans/counts only for DR/escrow; carries `Deletable` (= status != "ok") which gates the danger-zone card. Never add a secret field. | +| `parseHostAddresses` + `(*Server).hostNetwork` / `hostNetworkView` (v0.85.0) | hub/internal/web/hosts.go | `(reportJSON) []hostAddressView` · `(host, reportJSON) hostNetworkView` | The host page's Network card: every routable address the box holds + its WireGuard allocation | Needs agent **>= 0.119.0** (`minAgentForAddresses`); below it the wire has no `addresses` key and the card renders **UNKNOWN, never "no addresses"** — an absent signal is not a negative result. WireGuard is TWO facts: the hub's allocation (`GetWGPeerForHost`, authoritative) AND whether the box confirms holding it — the allocation alone cannot distinguish a live tunnel from a peer that was never applied. The WG row is split out by comparing against the ALLOCATION, never by matching the interface name `wg-felhom`, which is a unit name that can change. | | `(*Store).GetHostRecoveryMeta` + `(*Server).handleHostRevealRecoveryCredential` | hub/internal/store/host_recovery.go · hub/internal/web/hosts.go | `(hostID) (*HostRecoveryMeta, error)` · `POST /hosts/{id}/reveal-recovery-credential` | The break-glass console credential, split into a RENDER half and a RETRIEVE half (v0.84.0) | **Use `GetHostRecoveryMeta` on any page-render path** — its struct and its `SELECT` both omit the `secret` column, so it cannot leak one; `GetHostRecoveryCredential` (which does select it) belongs only to the two retrieval handlers. The reveal is POST so the ServeHTTP-level CSRF check applies and no secret is reachable by URL; it writes ONE `recovery_credential_revealed` event via `SaveEvent` and calls NO dispatcher (the `handleRequestLogTail` shape). `api/handler.go handleAdminGetRecoveryCredential` (global key) is the independent fallback for when the UI is down — never route the UI through it. Secret at rest is plaintext → R-133. | | `host_detail_body` sub-template | hub/internal/web/templates/host_detail_body.html | `{{template "host_detail_body" .}}` | Rendering a host's detail sections on ANY surface | One namespace across ParseFS (icons.html pattern). Renders per-host — id-suffix any new element ids with `{{.HostID}}` (the customer page renders N instances). | | `(*Store).ListHostsByCustomer` | hub/internal/store/store.go (~L1620) | `(customerID) ([]Host, error)` | A customer's hosts, host_id order | A LIST by design (HA-cluster roadmap) — don't collapse to GetHostByCustomer. | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index a67564a..dc87bf1 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,45 @@ +## v0.85.0 — Network card: a host's addresses are visible at last (2026-07-31) + +**Pairs with agent v0.119.0 and is useless without it** — the agent is what reports the addresses. + +**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 reachable at +`https://:8006`, which is 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` — desired state, authoritative) and `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*. A +mismatch renders `not confirmed by the box`; there is a test for exactly that case, and a red-proof +that pins it (hard-wiring `WGConfirmed = true` turns it red). + +**The split is keyed on the ALLOCATION, not on the interface name.** `wg-felhom` is the agent's +current unit name; a UI keyed on that string would silently mis-render the day it changes. Comparing +the reported address against the hub's allocated one uses the identity that survives a rename. + +**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 *"this host's agent does +not report its addresses — they are unknown, not absent"* and names the version needed. Rendering an +empty list there would have stated something false about the host. Red-proofed: deleting the branch +makes the page claim the host has no routable address. + +**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. + +Files: `hub/internal/web/hosts.go` (`parseHostAddresses`, `hostNetworkView`, `hostNetwork`, +`hostDetailData`), `hub/internal/web/templates/host_detail_body.html`, +`hub/internal/api/testdata/host-report.golden.json` (the cross-repo contract, moved in lockstep with +the agent's copy). +Tests 559 → 566; four red-proofs (the inert view-model, unconditional confirmation, the old-agent +branch, and the report fixture being the REAL wire from `--selftest=hub`) each run, observed failing, +and reverted. + ## v0.84.0 — Break-glass console credential on the host page (2026-07-31) **The credential existed and was not reachable when it was wanted.** Every Felhom-installed box has diff --git a/hub/internal/api/testdata/host-report.golden.json b/hub/internal/api/testdata/host-report.golden.json index 3ba3c29..3d6a2d9 100644 --- a/hub/internal/api/testdata/host-report.golden.json +++ b/hub/internal/api/testdata/host-report.golden.json @@ -134,6 +134,9 @@ "audit_tail": [], "capabilities": [], "leaf_fingerprint": "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245", + "addresses": [ + { "iface": "vmbr0", "cidr": "192.168.0.162/24" } + ], "dr_recipe": { "recipe_version": 1, "guests": [ diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 5f9c95e..235b3cc 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -1,6 +1,7 @@ package web import ( + "database/sql" "encoding/json" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-hub/internal/semver" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) @@ -171,6 +173,97 @@ func capabilitiesNeedDRMigration(caps []capabilityView) bool { return false } +// --- Network (v0.85.0): the host's addresses + its WireGuard allocation --- + +// minAgentForAddresses is the agent release that first reported `addresses[]`. Below it the field is +// absent from the wire, which is UNKNOWN and must never render as "this host has no addresses" — +// the presence-is-not-result rule (CLAUDE.md): an absent signal and a negative result are different +// facts, and a page that conflates them tells the operator something false. +const minAgentForAddresses = "0.119.0" + +// hostAddressView is one (interface, address) row from the report. +type hostAddressView struct { + Iface string + CIDR string + IP string // the bare address, for comparison against the WG allocation +} + +// parseHostAddresses extracts addresses[] from a host-report body. Missing/malformed → empty +// (never a panic) — the "waiting for first report" / old-agent path. +func parseHostAddresses(reportJSON string) []hostAddressView { + out := []hostAddressView{} + if reportJSON == "" { + return out + } + var body struct { + Addresses []struct { + Iface string `json:"iface"` + CIDR string `json:"cidr"` + } `json:"addresses"` + } + if err := json.Unmarshal([]byte(reportJSON), &body); err != nil { + return out + } + for _, a := range body.Addresses { + v := hostAddressView{Iface: a.Iface, CIDR: a.CIDR} + if ip, _, ok := strings.Cut(a.CIDR, "/"); ok { + v.IP = ip + } else { + v.IP = a.CIDR + } + out = append(out, v) + } + return out +} + +// hostNetworkView is the Network card's whole view-model. +// +// The WireGuard half is deliberately TWO facts, not one: WGAssignedIP is the hub's own allocation +// (wg_peers — desired state, authoritative) and WGConfirmed says whether the box actually reports +// holding it. Rendering only the allocation would make a silently-unapplied peer look healthy; that +// is the same shape as a timestamp recording an attempt being read as a result. +type hostNetworkView struct { + Addresses []hostAddressView // non-WireGuard addresses (the LAN bridge, a tailnet) + WGAssignedIP string // hub allocation; "" when this host has no peer + WGConfirmed bool // the box reports an address equal to WGAssignedIP + Reported bool // the agent is new enough to report addresses at all + AgentTooOld bool // it is NOT — so the empty list means UNKNOWN, not none +} + +// hostNetwork builds the Network card's view-model from the report + the hub's peer allocation. +// +// The WireGuard address is split out by comparing against the hub's allocation rather than by +// matching an interface NAME: "wg-felhom" is the agent's current unit name, and keying a UI on it +// would silently mis-render the day that changes. The allocation is the identity that survives. +func (s *Server) hostNetwork(host *store.Host, reportJSON string) hostNetworkView { + v := hostNetworkView{Addresses: []hostAddressView{}} + + if peer, err := s.store.GetWGPeerForHost(host.HostID); err == nil && peer != nil { + v.WGAssignedIP = peer.AssignedIP + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + // A real store failure must not read as "this host has no tunnel". + s.logger.Printf("[ERROR] host network %s: wg peer: %v", host.HostID, err) + } + + // An agent older than minAgentForAddresses does not send the field at all. Say so, rather than + // rendering an empty list that looks like a finding. + if host.AgentVersion != "" && semver.Valid(host.AgentVersion) && + semver.Compare(host.AgentVersion, minAgentForAddresses) < 0 { + v.AgentTooOld = true + return v + } + + for _, a := range parseHostAddresses(reportJSON) { + if v.WGAssignedIP != "" && a.IP == v.WGAssignedIP { + v.WGConfirmed = true + continue // shown in the WireGuard row, not repeated in the address list + } + v.Addresses = append(v.Addresses, a) + } + v.Reported = len(v.Addresses) > 0 || v.WGConfirmed + return v +} + // storageTargetView is the rich per-drive row the host-detail Storage Targets table renders: // fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's // storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view @@ -438,6 +531,9 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in drBundle, _ := s.store.GetHostDRBundle(host.HostID) escrow, _ := s.store.GetHostEscrow(host.HostID) + // v0.85.0 Network — the host's addresses + its WireGuard allocation. + network := s.hostNetwork(host, reportJSON) + // v0.84.0 Console access — presence + username + set_at ONLY. GetHostRecoveryMeta cannot carry // the secret (its query does not select the column); the plaintext reaches the operator solely // through POST /hosts/{id}/reveal-recovery-credential. @@ -476,6 +572,8 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in // R-recoverable). Operator-only surface. "SupersededEscrowCount": func() int { n, _ := s.store.CountSupersededEscrow(host.HostID); return n }(), // v0.84.0 break-glass Console access card. NEVER add a key holding the secret. + // v0.85.0 Network card (addresses + WireGuard allocation/confirmation). + "Network": network, "RecoveryVaulted": recoveryMeta != nil, "RecoveryUsername": func() string { if recoveryMeta != nil { diff --git a/hub/internal/web/hosts_network_test.go b/hub/internal/web/hosts_network_test.go new file mode 100644 index 0000000..82e713a --- /dev/null +++ b/hub/internal/web/hosts_network_test.go @@ -0,0 +1,241 @@ +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") + } +} diff --git a/hub/internal/web/templates/host_detail_body.html b/hub/internal/web/templates/host_detail_body.html index 15bb3a6..0950181 100644 --- a/hub/internal/web/templates/host_detail_body.html +++ b/hub/internal/web/templates/host_detail_body.html @@ -264,6 +264,59 @@ {{end}} + +
+

Network

+
+
+ WireGuard + + {{if .Network.WGAssignedIP}}{{.Network.WGAssignedIP}} + {{if .Network.WGConfirmed}}confirmed + {{else if .Network.AgentTooOld}}allocated + {{else}}not confirmed by the box{{end}} + {{else}}no peer allocated{{end}} + +
+
+ {{if .Network.AgentTooOld}} +

+ This host's agent ({{.AgentVersion}}) does not report its addresses — they are + unknown, not absent. Needs agent 0.119.0 or newer. +

+ {{else if .Network.Addresses}} + + + + + + {{range .Network.Addresses}} + + + + + {{end}} + +
InterfaceAddress
{{.Iface}}{{.CIDR}}
+

+ Every routable address the box holds, as the kernel sees it. Loopback and link-local are + excluded — including the 169.254.253.1 local-API island, which is identical on + every box. The PVE web console is at https://<the LAN address>:8006. +

+ {{else if .HasReport}} +

+ The agent reports no routable address on this host. +

+ {{else}} +

+ Waiting for the first host report. +

+ {{end}} +
+

DR / Backup