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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user