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
+1
View File
@@ -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. |
+42
View File
@@ -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://<the LAN address>: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
+3
View File
@@ -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": [
+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 {
+241
View File
@@ -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")
}
}
@@ -264,6 +264,59 @@
{{end}}
</section>
<!-- Network (v0.85.0): where this box actually is. Addresses come from the agent's
addresses[] (agent >= 0.119.0); the WireGuard row pairs the HUB's allocation with
whether the box confirms holding it, because an allocation alone cannot tell a live
tunnel from a peer that was never applied. -->
<section class="card">
<h2>Network</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">WireGuard</span>
<span class="value">
{{if .Network.WGAssignedIP}}<code>{{.Network.WGAssignedIP}}</code>
{{if .Network.WGConfirmed}}<span class="badge badge-ok" title="The box reports holding this address">confirmed</span>
{{else if .Network.AgentTooOld}}<span class="badge badge-neutral" title="This agent does not report its addresses">allocated</span>
{{else}}<span class="badge badge-warn" title="The hub allocated this peer but the box does not report holding the address">not confirmed by the box</span>{{end}}
{{else}}<span class="text-muted">no peer allocated</span>{{end}}
</span>
</div>
</div>
{{if .Network.AgentTooOld}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
This host's agent (<code>{{.AgentVersion}}</code>) does not report its addresses &mdash; they are
<strong>unknown</strong>, not absent. Needs agent <code>0.119.0</code> or newer.
</p>
{{else if .Network.Addresses}}
<table class="data-table" style="margin-top: 0.75rem;">
<thead>
<tr><th>Interface</th><th>Address</th></tr>
</thead>
<tbody>
{{range .Network.Addresses}}
<tr>
<td><code>{{.Iface}}</code></td>
<td><code>{{.CIDR}}</code></td>
</tr>
{{end}}
</tbody>
</table>
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.5rem;">
Every routable address the box holds, as the kernel sees it. Loopback and link-local are
excluded &mdash; including the <code>169.254.253.1</code> local-API island, which is identical on
every box. The PVE web console is at <code>https://&lt;the LAN address&gt;:8006</code>.
</p>
{{else if .HasReport}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
The agent reports no routable address on this host.
</p>
{{else}}
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.75rem;">
Waiting for the first host report.
</p>
{{end}}
</section>
<!-- DR / Backup -->
<section class="card">
<h2>DR / Backup</h2>