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.
This commit is contained in:
2026-07-31 08:49:44 +02:00
parent b4edc087fa
commit e07d90f0f4
6 changed files with 438 additions and 0 deletions
+98
View File
@@ -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 {