4d6ec7c7bb
gates / gates (push) Successful in 14s
Four paper debts and one fact given a reader. Hub-only — nothing to bake. A4 — the entry about "the tester's machine" named a risk correctly and labelled it in a way that invited deleting it. Established from the hub's own store: `peti-felhom` is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47 this morning. The prompt's premise conflated the two; the register now says which is which. A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out of STATUS's "Waiting on you", which is now empty. A3 — day0-install §C.1 said pushing the installer publishes it. It has not since R-110. Corrected, with the two manifest pins named and an outside-verification command; the one copy that repeated it (a dated audit, true when written) carries a superseded note. A5 — standing rule 5: evidence comes off the machine at the end of the phase that produced it, before any revert. Earned twice in three days on the same box at the same point (R-320). Four homes, plus what to do when it is already gone. R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll` mail kind so the mail names the page a REBUILT box actually shows („A szerver beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach. Naming only; the acceptance pin proves the secret is untouched. R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never drawn as healthy — three absences, three sentences. No alarm, deliberately. Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190. B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now. Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled` reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed. Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
922 lines
35 KiB
Go
922 lines
35 KiB
Go
package web
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||
)
|
||
|
||
// agentOrUnknown renders an agent version for operator text, mapping the empty (never-reported)
|
||
// value to a readable token.
|
||
func agentOrUnknown(v string) string {
|
||
if v == "" {
|
||
return "unknown"
|
||
}
|
||
return v
|
||
}
|
||
|
||
// hostStatus computes the host's liveness state from its last-report recency, using the
|
||
// SAME thresholds as the HostStalenessChecker (s.staleThreshold; "down" at 2×). This keeps
|
||
// the GUI badge in agreement with the alerting — there is no second definition of "stale".
|
||
// Returns one of: "ok" (online), "stale", "down", "pending" (never reported).
|
||
func (s *Server) hostStatus(lastReport *time.Time) string {
|
||
if lastReport == nil {
|
||
return "pending"
|
||
}
|
||
age := time.Since(*lastReport)
|
||
switch {
|
||
case age > 2*s.staleThreshold:
|
||
return "down"
|
||
case age > s.staleThreshold:
|
||
return "stale"
|
||
default:
|
||
return "ok"
|
||
}
|
||
}
|
||
|
||
// hostStatusClass maps the internal status to the existing status-badge-* CSS class.
|
||
// "stale" reuses the amber -warn class (there is no dedicated -stale class), keeping the
|
||
// styling consistent with the rest of the console.
|
||
func hostStatusClass(status string) string {
|
||
switch status {
|
||
case "ok":
|
||
return "status-badge-ok"
|
||
case "stale":
|
||
return "status-badge-warn"
|
||
case "down":
|
||
return "status-badge-down"
|
||
default:
|
||
return "status-badge-pending"
|
||
}
|
||
}
|
||
|
||
// hostStatusLabel maps the internal status to the operator-facing badge label.
|
||
func hostStatusLabel(status string) string {
|
||
switch status {
|
||
case "ok":
|
||
return "ONLINE"
|
||
case "stale":
|
||
return "STALE"
|
||
case "down":
|
||
return "DOWN"
|
||
default:
|
||
return "NO REPORT"
|
||
}
|
||
}
|
||
|
||
// hostVitals are the report-body fields the Hosts views surface (CPU/mem/disk +
|
||
// cloudflared). Parsed from the latest host-report's report_json — the same body the
|
||
// checkers read; no new ingestion path. Zero values when there is no report.
|
||
type hostVitals struct {
|
||
CPUPercent float64
|
||
MemoryPercent float64
|
||
DiskPercent float64
|
||
CloudflaredStatus string
|
||
}
|
||
|
||
// parseHostVitals extracts the vitals block from a host-report body. A missing/malformed
|
||
// body yields zero vitals (never a panic) — the "waiting for first report" path.
|
||
func parseHostVitals(reportJSON string) hostVitals {
|
||
var v hostVitals
|
||
if reportJSON == "" {
|
||
return v
|
||
}
|
||
var body struct {
|
||
Host struct {
|
||
CPUPercent float64 `json:"cpu_percent"`
|
||
MemoryPercent float64 `json:"memory_percent"`
|
||
DiskPercent float64 `json:"disk_percent"`
|
||
} `json:"host"`
|
||
Cloudflared struct {
|
||
Status string `json:"status"`
|
||
} `json:"cloudflared"`
|
||
}
|
||
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
|
||
return v
|
||
}
|
||
v.CPUPercent = body.Host.CPUPercent
|
||
v.MemoryPercent = body.Host.MemoryPercent
|
||
v.DiskPercent = body.Host.DiskPercent
|
||
v.CloudflaredStatus = body.Cloudflared.Status
|
||
return v
|
||
}
|
||
|
||
// capabilityView is one privileged-capability chip on the host detail page (v0.51.0 — the agent
|
||
// has reported these since v0.44.0; the hub now renders them). Class maps the agent's status to
|
||
// a badge: ok → badge-ok, degraded → badge-fail (critical) / badge-warn, inactive → badge-neutral
|
||
// (disabled ≠ degraded — the DR-tier-by-default rule; agent v0.86.0 emits "inactive").
|
||
type capabilityView struct {
|
||
Name string
|
||
Feature string
|
||
Status string
|
||
Reason string
|
||
Critical bool
|
||
Class string
|
||
}
|
||
|
||
// parseHostCapabilities extracts the capabilities array from a host-report body. Missing or
|
||
// malformed → nil (the "waiting for first report" path — the section hides).
|
||
func parseHostCapabilities(reportJSON string) []capabilityView {
|
||
if reportJSON == "" {
|
||
return nil
|
||
}
|
||
var body struct {
|
||
Capabilities []struct {
|
||
Name string `json:"name"`
|
||
Feature string `json:"feature"`
|
||
Critical bool `json:"critical"`
|
||
Status string `json:"status"`
|
||
Reason string `json:"reason"`
|
||
} `json:"capabilities"`
|
||
}
|
||
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
|
||
return nil
|
||
}
|
||
out := make([]capabilityView, 0, len(body.Capabilities))
|
||
for _, c := range body.Capabilities {
|
||
v := capabilityView{Name: c.Name, Feature: c.Feature, Status: c.Status, Reason: c.Reason, Critical: c.Critical}
|
||
switch c.Status {
|
||
case "ok":
|
||
v.Class = "badge-ok"
|
||
case "inactive":
|
||
v.Class = "badge-neutral"
|
||
default: // degraded (or an unknown future status — surface it, never hide it)
|
||
if c.Critical {
|
||
v.Class = "badge-error"
|
||
} else {
|
||
v.Class = "badge-warn"
|
||
}
|
||
}
|
||
out = append(out, v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// capabilitiesNeedDRMigration reports whether any pbsdr-* capability is degraded with the
|
||
// pre-v1.15.0 signature ("binary not found") — the box predates the uniform DR plumbing. The
|
||
// host page then surfaces the migration one-liner instead of silently pretending (§8 of the
|
||
// DR-by-default spec).
|
||
func capabilitiesNeedDRMigration(caps []capabilityView) bool {
|
||
for _, c := range caps {
|
||
if strings.HasPrefix(c.Name, "pbsdr-") && c.Status == "degraded" && c.Reason == "binary not found" {
|
||
return true
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// ── R-319 — the guest-network watchdog gets a reader ────────────────────────────────────────────
|
||
//
|
||
// FIRST OF THE R-264 READERS. The agent has reported `guest_net` on every heartbeat since v0.92.0
|
||
// (R-54, 2026-07-21) and the hub — the component that emails the operator — modelled NONE of it: the
|
||
// string `guest_net` occurred nowhere in this repository. It was stored as raw text inside
|
||
// `report_json` and read by nothing.
|
||
//
|
||
// WHY THIS ONE FIRST. It has a live incident behind it: a killed `dhclient` in a guest took a tunnel
|
||
// down for 1 h 15 m with nobody told (`audits/INCIDENT-guest-dhclient-killed-2026-07-20.md`), and the
|
||
// watchdog built afterwards has been reporting exactly that condition ever since — to a hub that
|
||
// modelled none of it. R-264 named it "the strongest candidate of the twenty-one".
|
||
//
|
||
// WHAT IS ACTUALLY BEING READ, and it is NOT just the current state. The signal is
|
||
// `heals_last_hour`: a guest whose network the watchdog keeps REPAIRING is healthy at every instant
|
||
// anyone looks and is nevertheless failing. Rendering only `state` would give that machine a green
|
||
// tick — which is the exact shape of the defect that drew a failed disk as a healthy empty disk.
|
||
// RepairCount is therefore surfaced beside the state, not behind it.
|
||
//
|
||
// AN UNKNOWN IS NEVER DRAWN AS HEALTHY. Three distinct absences are kept apart, following the
|
||
// companion-flag convention this project settled in August (`hostNetworkView` above is the sibling):
|
||
// - AgentTooOld — the agent predates v0.92.0, so absence is EXPECTED but still tells us nothing;
|
||
// - Reported=false — a new-enough agent sent no stanza (watchdog disabled, or a report that
|
||
// predates the feature on this box);
|
||
// - a guest whose `state` is empty or unrecognised → rendered "unknown", never healthy.
|
||
// A malformed stanza degrades to Reported=false and must never 500 the page.
|
||
|
||
// minAgentForGuestNet is the agent release that first reported `guest_net` (v0.92.0, R-54). Below it
|
||
// the stanza is absent BY CONSTRUCTION, so its absence is unknown-and-expected rather than a finding.
|
||
const minAgentForGuestNet = "0.92.0"
|
||
|
||
// guestNetGuestView is one owned guest's network health as the watchdog last saw it.
|
||
type guestNetGuestView struct {
|
||
VMID int
|
||
State string // healthy | unhealthy | static_fault | unknown ("" → unknown)
|
||
Mode string // dhcp | static | unknown
|
||
IP string
|
||
HasRoute bool
|
||
DHClientAlive bool
|
||
// RepairCount is `heals_last_hour` — THE SIGNAL. A machine repairing itself over and over is
|
||
// telling you something that its current state cannot.
|
||
RepairCount int
|
||
LastHealAt string
|
||
Damped bool
|
||
Message string
|
||
// Healed / HealSucceeded are carried DELIBERATELY, and together. R-260 is this project's
|
||
// warning: the OOB decoder mirrored five of the agent's eight fields, and the three it dropped
|
||
// included the one that decided the question the checker existed to answer. A repair that FAILED
|
||
// is a different fact from a repair that worked, and the count alone cannot express it — six
|
||
// successful repairs is a nuisance, six FAILED ones is a guest that is down right now.
|
||
Healed bool
|
||
HealSucceeded bool
|
||
}
|
||
|
||
// HealFailed reports that the watchdog TRIED to repair this guest and did not succeed. Kept separate
|
||
// from State because the two can disagree: the sweep that failed to heal is not necessarily the sweep
|
||
// that set the state.
|
||
func (g guestNetGuestView) HealFailed() bool { return g.Healed && !g.HealSucceeded }
|
||
|
||
// Unknown reports whether this guest's state is one the watchdog did not positively assert. An empty
|
||
// or unrecognised state is drawn as unknown; it is never allowed to fall through to the healthy
|
||
// branch. Value receiver — a pointer receiver compiles, vets, passes the suite and 500s at render.
|
||
func (g guestNetGuestView) Unknown() bool {
|
||
switch g.State {
|
||
case "healthy", "unhealthy", "static_fault":
|
||
return false
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
// Repairing reports whether the watchdog has had to repair this guest inside the last hour. This is
|
||
// deliberately independent of State: the whole point is that a REPAIRED guest reads healthy.
|
||
func (g guestNetGuestView) Repairing() bool { return g.RepairCount > 0 }
|
||
|
||
// guestNetView is the Guest network card's whole view-model.
|
||
type guestNetView struct {
|
||
// Reported is the ONLY thing that licenses drawing any health at all. False → unknown.
|
||
Reported bool
|
||
// AgentTooOld distinguishes "this agent cannot report it" from "a capable agent said nothing".
|
||
// Both render as unknown; they need different words, and conflating them is how an operator
|
||
// starts ignoring the card.
|
||
AgentTooOld bool
|
||
CheckedAt string
|
||
Guests []guestNetGuestView
|
||
// RepairingCount / UnhealthyCount drive the card's summary badge. Counted rather than derived in
|
||
// the template: template logic that computes a verdict is logic nobody tests.
|
||
RepairingCount int
|
||
UnhealthyCount int
|
||
UnknownCount int
|
||
// HealFailedCount — repairs ATTEMPTED and not succeeded. Counted separately from
|
||
// RepairingCount: a failing repair is a harder fact than a frequent one.
|
||
HealFailedCount int
|
||
}
|
||
|
||
// Degraded is true when any owned guest is unhealthy OR is being repeatedly repaired. The repair leg
|
||
// is the one that matters: without it a guest the watchdog fixes every ten minutes reports "healthy"
|
||
// for ever.
|
||
func (v guestNetView) Degraded() bool {
|
||
return v.UnhealthyCount > 0 || v.RepairingCount > 0 || v.HealFailedCount > 0
|
||
}
|
||
|
||
// parseGuestNet extracts the `guest_net` stanza. A missing or malformed body yields Reported=false
|
||
// (unknown), never an error and never a partial claim of health.
|
||
func parseGuestNet(reportJSON string) guestNetView {
|
||
v := guestNetView{Guests: []guestNetGuestView{}}
|
||
if reportJSON == "" {
|
||
return v
|
||
}
|
||
var body struct {
|
||
GuestNet *struct {
|
||
CheckedAt string `json:"checked_at"`
|
||
Guests []struct {
|
||
VMID int `json:"vmid"`
|
||
State string `json:"state"`
|
||
Mode string `json:"mode"`
|
||
IP string `json:"ip"`
|
||
HasRoute bool `json:"has_route"`
|
||
DHClientAlive bool `json:"dhclient_alive"`
|
||
HealsLastHour int `json:"heals_last_hour"`
|
||
Healed bool `json:"healed"`
|
||
HealSucceeded bool `json:"heal_succeeded"`
|
||
LastHealAt string `json:"last_heal_at"`
|
||
Damped bool `json:"damped"`
|
||
Message string `json:"message"`
|
||
} `json:"guests"`
|
||
} `json:"guest_net"`
|
||
}
|
||
// A decode error is NOT propagated: one box's bad field must not break the page for the rest of
|
||
// the fleet. It degrades to Reported=false, which renders as unknown — the safe direction.
|
||
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil || body.GuestNet == nil {
|
||
return v
|
||
}
|
||
v.Reported = true
|
||
v.CheckedAt = body.GuestNet.CheckedAt
|
||
for _, g := range body.GuestNet.Guests {
|
||
gv := guestNetGuestView{
|
||
VMID: g.VMID, State: g.State, Mode: g.Mode, IP: g.IP,
|
||
HasRoute: g.HasRoute, DHClientAlive: g.DHClientAlive,
|
||
RepairCount: g.HealsLastHour, LastHealAt: g.LastHealAt,
|
||
Damped: g.Damped, Message: g.Message,
|
||
Healed: g.Healed, HealSucceeded: g.HealSucceeded,
|
||
}
|
||
switch {
|
||
case gv.Unknown():
|
||
v.UnknownCount++
|
||
case gv.State == "unhealthy" || gv.State == "static_fault":
|
||
v.UnhealthyCount++
|
||
}
|
||
if gv.Repairing() {
|
||
v.RepairingCount++
|
||
}
|
||
if gv.HealFailed() {
|
||
v.HealFailedCount++
|
||
}
|
||
v.Guests = append(v.Guests, gv)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// guestNet builds the card's view-model, applying the version gate before the report is consulted so
|
||
// that an old agent's silence is named as such rather than rendered as a finding.
|
||
func (s *Server) guestNet(host *store.Host, reportJSON string) guestNetView {
|
||
if host.AgentVersion != "" && semver.Valid(host.AgentVersion) &&
|
||
semver.Compare(host.AgentVersion, minAgentForGuestNet) < 0 {
|
||
return guestNetView{Guests: []guestNetGuestView{}, AgentTooOld: true}
|
||
}
|
||
return parseGuestNet(reportJSON)
|
||
}
|
||
|
||
// 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
|
||
// mirrors the fields the read-only page shows). Never carries a secret.
|
||
type storageTargetView struct {
|
||
Name string
|
||
Type string
|
||
Role string
|
||
State string
|
||
MountPath string
|
||
Reachable bool
|
||
FillPct float64 // used_fraction × 100
|
||
HasThin bool
|
||
ThinDataPct float64
|
||
// SMART (pointers → "n/a" when the drive/agent doesn't report the metric)
|
||
SmartHealth string
|
||
TempC *int
|
||
WearPct *int // NVMe percentage_used
|
||
}
|
||
|
||
// parseHostStorageTargets extracts the rich storage-target rows from a report body. A
|
||
// missing/malformed body yields an empty slice.
|
||
func parseHostStorageTargets(reportJSON string) []storageTargetView {
|
||
out := []storageTargetView{}
|
||
if reportJSON == "" {
|
||
return out
|
||
}
|
||
var body struct {
|
||
StorageTargets []struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Role string `json:"role"`
|
||
State string `json:"state"`
|
||
MountPath string `json:"mount_path"`
|
||
Reachable bool `json:"reachable"`
|
||
UsedFraction float64 `json:"used_fraction"`
|
||
ThinPool *struct {
|
||
DataUsedFraction float64 `json:"data_used_fraction"`
|
||
} `json:"thin_pool"`
|
||
Smart struct {
|
||
Health string `json:"health"`
|
||
TemperatureC *int `json:"temperature_c"`
|
||
PercentageUsed *int `json:"percentage_used"`
|
||
} `json:"smart"`
|
||
} `json:"storage_targets"`
|
||
}
|
||
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
|
||
return out
|
||
}
|
||
for _, t := range body.StorageTargets {
|
||
v := storageTargetView{
|
||
Name: t.Name, Type: t.Type, Role: t.Role, State: t.State,
|
||
MountPath: t.MountPath, Reachable: t.Reachable,
|
||
FillPct: t.UsedFraction * 100,
|
||
SmartHealth: t.Smart.Health, TempC: t.Smart.TemperatureC, WearPct: t.Smart.PercentageUsed,
|
||
}
|
||
if t.ThinPool != nil {
|
||
v.HasThin = true
|
||
v.ThinDataPct = t.ThinPool.DataUsedFraction * 100
|
||
}
|
||
out = append(out, v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// hostListRow is the per-host view model for the fleet list.
|
||
type hostListRow struct {
|
||
HostID string
|
||
CustomerID string
|
||
CustomerName string
|
||
AgentVersion string
|
||
Status string // ok | stale | down | pending
|
||
StatusLabel string
|
||
StatusClass string
|
||
LastReportAt *time.Time
|
||
HasReport bool
|
||
GuestRunning int
|
||
GuestTotal int
|
||
Vitals hostVitals
|
||
WorstFillPct float64
|
||
WorstFillName string
|
||
HasStorage bool
|
||
// FloorHeld (Part D): the managed controller-version floor is being WITHHELD because this box's
|
||
// agent is below the current golden's MinAgent. HeldReason carries the operator-facing text.
|
||
FloorHeld bool
|
||
HeldReason string
|
||
}
|
||
|
||
// customerName resolves a display name for a customer id (config first, then the last
|
||
// report's embedded name), falling back to the id. Read-only convenience for the Hosts views.
|
||
func (s *Server) customerName(customerID string) string {
|
||
if cfg, _ := s.store.GetCustomerConfig(customerID); cfg != nil && cfg.CustomerName != "" {
|
||
return cfg.CustomerName
|
||
}
|
||
if c, _ := s.store.GetCustomer(customerID); c != nil && c.CustomerName != "" {
|
||
return c.CustomerName
|
||
}
|
||
return customerID
|
||
}
|
||
|
||
// handleHostsList renders the read-only fleet list of enrolled hosts (audit F-M1). GET only.
|
||
func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
|
||
hosts, err := s.store.ListHosts()
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Hosts list: %v", err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Worst storage fill per host, from every host's latest report.
|
||
targets, _ := s.store.GetHostStorageTargets()
|
||
worstFill := make(map[string]store.HostStorageTargetRow)
|
||
for _, t := range targets {
|
||
if cur, ok := worstFill[t.HostID]; !ok || t.Percent > cur.Percent {
|
||
worstFill[t.HostID] = t
|
||
}
|
||
}
|
||
|
||
rows := make([]hostListRow, 0, len(hosts))
|
||
for _, h := range hosts {
|
||
status := s.hostStatus(h.LastReportAt)
|
||
row := hostListRow{
|
||
HostID: h.HostID,
|
||
CustomerID: h.CustomerID,
|
||
CustomerName: s.customerName(h.CustomerID),
|
||
AgentVersion: h.AgentVersion,
|
||
Status: status,
|
||
StatusLabel: hostStatusLabel(status),
|
||
StatusClass: hostStatusClass(status),
|
||
LastReportAt: h.LastReportAt,
|
||
HasReport: h.LastReportAt != nil,
|
||
}
|
||
|
||
// Part D: surface a held managed floor (agent below the golden's MinAgent) so a held box is
|
||
// never silently stale.
|
||
if fd := s.store.ResolveManagedFloor(h.CustomerID); fd.Held {
|
||
row.FloorHeld = true
|
||
row.HeldReason = fd.HoldReason()
|
||
}
|
||
|
||
// Guest counts from the reality table (per-host accurate).
|
||
guests, _ := s.store.ListGuestsForHost(h.HostID)
|
||
row.GuestTotal = len(guests)
|
||
for _, g := range guests {
|
||
if g.Status == "running" {
|
||
row.GuestRunning++
|
||
}
|
||
}
|
||
|
||
// Vitals from the latest report body.
|
||
if reportJSON, _ := s.store.GetLatestHostReportJSON(h.CustomerID); reportJSON != "" {
|
||
row.Vitals = parseHostVitals(reportJSON)
|
||
}
|
||
|
||
if wf, ok := worstFill[h.HostID]; ok {
|
||
row.HasStorage = true
|
||
row.WorstFillPct = wf.Percent
|
||
row.WorstFillName = wf.Name
|
||
}
|
||
|
||
rows = append(rows, row)
|
||
}
|
||
|
||
// R-21 slice C: the unclaimed-appliance section + bind picker.
|
||
unclaimed, picker, err := s.gatherUnclaimed(time.Now())
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Hosts list: unclaimed appliances: %v", err)
|
||
// non-fatal: still render the host list
|
||
}
|
||
|
||
data := map[string]interface{}{
|
||
"Hosts": rows,
|
||
"Unclaimed": unclaimed,
|
||
"CustomerPicker": picker,
|
||
"Flash": r.URL.Query().Get("flash"),
|
||
"CSRFToken": s.getCSRFToken(r),
|
||
}
|
||
if err := s.templates.ExecuteTemplate(w, "hosts.html", data); err != nil {
|
||
s.logger.Printf("[ERROR] hosts.html template: %v", err)
|
||
}
|
||
}
|
||
|
||
// hostDetailData assembles the view-model map the shared host_detail_body sub-template
|
||
// renders — used by BOTH the standalone /hosts/{id} page and the customer page's Host tab
|
||
// (v0.47.0). Booleans/counts only for DR/escrow; never api_key or blob contents.
|
||
// wrapperDrift compares the PBS-DR wrapper hash a host REPORTS against the one the operator vouched
|
||
// in the artifact manifest (R-50b(a), v0.68.0).
|
||
//
|
||
// The wrapper is a root-owned 0755 file installed from `raw/branch/main` — unversioned, unpinned and
|
||
// absent from every manifest until now, so two hosts installed a week apart could carry different
|
||
// privileged code while reporting the same agent version. This does not fix the delivery channel
|
||
// (R-50b(b)/(c)); it makes drift VISIBLE, which is what was missing.
|
||
//
|
||
// Returns ("", "") when either side is unknown: a hub that has not vouched a hash, or an agent below
|
||
// 0.91.0 that does not report one, is NOT drift — treating "unknown" as "mismatch" would light every
|
||
// host amber on the day this ships and teach the operator to ignore it.
|
||
func (s *Server) wrapperDrift(reportJSON string) (drift string, reported string) {
|
||
reported = parseReportedWrapperSHA(reportJSON)
|
||
return compareWrapperSHA(reported, s.store.GetArtifactManifest().WrapperSHA256), reported
|
||
}
|
||
|
||
// compareWrapperSHA is the pure comparison: "" (quiet) when either side is unknown, else ok/mismatch.
|
||
func compareWrapperSHA(reported, vouched string) string {
|
||
if reported == "" || vouched == "" {
|
||
return ""
|
||
}
|
||
if !strings.EqualFold(reported, vouched) {
|
||
return "mismatch"
|
||
}
|
||
return "ok"
|
||
}
|
||
|
||
// parseReportedWrapperSHA pulls host.wrapper_sha256 out of a host report ("" when absent).
|
||
func parseReportedWrapperSHA(reportJSON string) string {
|
||
if strings.TrimSpace(reportJSON) == "" {
|
||
return ""
|
||
}
|
||
var doc struct {
|
||
Host struct {
|
||
WrapperSHA256 string `json:"wrapper_sha256"`
|
||
} `json:"host"`
|
||
}
|
||
if json.Unmarshal([]byte(reportJSON), &doc) != nil {
|
||
return ""
|
||
}
|
||
return strings.ToLower(strings.TrimSpace(doc.Host.WrapperSHA256))
|
||
}
|
||
|
||
func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]interface{} {
|
||
status := s.hostStatus(host.LastReportAt)
|
||
|
||
guests, _ := s.store.ListGuestsForHost(host.HostID)
|
||
guestRunning := 0
|
||
for _, g := range guests {
|
||
if g.Status == "running" {
|
||
guestRunning++
|
||
}
|
||
}
|
||
|
||
reportJSON, _ := s.store.GetLatestHostReportJSON(host.CustomerID)
|
||
vitals := parseHostVitals(reportJSON)
|
||
wrapperDrift, reportedWrapperSHA := s.wrapperDrift(reportJSON)
|
||
storageTargets := parseHostStorageTargets(reportJSON)
|
||
sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
|
||
// v0.51.0: capability chips — non-ok first (what the operator needs to see), then by name.
|
||
capabilities := parseHostCapabilities(reportJSON)
|
||
sort.SliceStable(capabilities, func(i, j int) bool {
|
||
rank := func(s string) int {
|
||
switch s {
|
||
case "degraded":
|
||
return 0
|
||
case "inactive":
|
||
return 1
|
||
default:
|
||
return 2
|
||
}
|
||
}
|
||
if a, b := rank(capabilities[i].Status), rank(capabilities[j].Status); a != b {
|
||
return a < b
|
||
}
|
||
return capabilities[i].Name < capabilities[j].Name
|
||
})
|
||
|
||
// DR / backup presence — booleans only, never the opaque blobs.
|
||
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)
|
||
|
||
// R-319 Guest network — the R-54 watchdog's per-guest verdict AND its repair count.
|
||
guestNet := s.guestNet(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.
|
||
recoveryMeta, err := s.store.GetHostRecoveryMeta(host.HostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] host recovery meta %s: %v", host.HostID, err)
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"WrapperDrift": wrapperDrift,
|
||
"ReportedWrapperSHA": reportedWrapperSHA,
|
||
"VouchedWrapperSHA": s.store.GetArtifactManifest().WrapperSHA256,
|
||
"HostID": host.HostID,
|
||
"CustomerID": host.CustomerID,
|
||
"CustomerName": s.customerName(host.CustomerID),
|
||
"AgentVersion": host.AgentVersion,
|
||
"CreatedAt": host.CreatedAt,
|
||
"Status": status,
|
||
"StatusLabel": hostStatusLabel(status),
|
||
"StatusClass": hostStatusClass(status),
|
||
"LastReportAt": host.LastReportAt,
|
||
"HasReport": host.LastReportAt != nil,
|
||
"RecoveryMode": host.InRecoveryMode(time.Now()),
|
||
"RecoveryUntil": host.RecoveryModeUntil,
|
||
"DesiredGeneration": host.DesiredGeneration,
|
||
"Vitals": vitals,
|
||
"Guests": guests,
|
||
"GuestRunning": guestRunning,
|
||
"GuestTotal": len(guests),
|
||
"StorageTargets": storageTargets,
|
||
"Capabilities": capabilities,
|
||
"NeedsDRMigration": capabilitiesNeedDRMigration(capabilities),
|
||
"DRPresent": drBundle != nil,
|
||
"EscrowPresent": escrow != nil,
|
||
// v0.60.0 Part B: retained superseded escrow blobs (data-first — old passphrases stay
|
||
// 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,
|
||
"GuestNet": guestNet,
|
||
"RecoveryVaulted": recoveryMeta != nil,
|
||
"RecoveryUsername": func() string {
|
||
if recoveryMeta != nil {
|
||
return recoveryMeta.Username
|
||
}
|
||
return ""
|
||
}(),
|
||
"RecoverySetAt": func() time.Time {
|
||
if recoveryMeta != nil {
|
||
return recoveryMeta.SetAt
|
||
}
|
||
return time.Time{}
|
||
}(),
|
||
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
||
"LogBundles": s.hostLogBundleRows(host),
|
||
"CSRFToken": s.getCSRFToken(r),
|
||
// v0.47.0 stale host removal: the danger-zone card renders ONLY for non-online
|
||
// hosts — an ONLINE host is never deletable (no override exists).
|
||
"Deletable": status != "ok",
|
||
}
|
||
}
|
||
|
||
// handleHostDeleteImpact — GET /hosts/{id}/delete-impact (v0.47.0 stale host removal).
|
||
// The confirm dialog's impact probe: counts/booleans ONLY (never a secret, blob, or key),
|
||
// mirroring the global-floor impact endpoint's read-only-JSON pattern.
|
||
func (s *Server) handleHostDeleteImpact(w http.ResponseWriter, r *http.Request, hostID string) {
|
||
host, err := s.store.GetHost(hostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] host delete-impact %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
a, err := s.store.CountHostArtifacts(hostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] host delete-impact %s: artifacts: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
status := s.hostStatus(host.LastReportAt)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"status": status,
|
||
"deletable": status != "ok",
|
||
"guests": a.Guests,
|
||
"reports": a.Reports,
|
||
"log_bundles": a.LogBundles,
|
||
"escrow_present": a.EscrowPresent,
|
||
"wg_peer_bound": a.WGPeerBound,
|
||
"pbs_secret_present": a.PBSSecretPresent,
|
||
"recovery_present": a.RecoveryPresent,
|
||
})
|
||
}
|
||
|
||
// handleHostRevealRecoveryCredential — POST /hosts/{id}/reveal-recovery-credential (v0.84.0).
|
||
// The operator-SESSION counterpart to the global-key API path (api/handler.go
|
||
// handleAdminGetRecoveryCredential), which stays untouched and remains the break-glass route for
|
||
// when this UI is itself unavailable — coupling it to the session layer would remove exactly the
|
||
// independence that makes it a fallback.
|
||
//
|
||
// POST, not GET, deliberately: it is the only way the ServeHTTP-level CSRF check applies, and a
|
||
// secret must not be retrievable by URL alone (prefetch, history, referrer).
|
||
//
|
||
// SECRET DISCIPLINE: the plaintext goes into the JSON response body and nowhere else — never the
|
||
// hub log, never the event message or details_json.
|
||
func (s *Server) handleHostRevealRecoveryCredential(w http.ResponseWriter, r *http.Request, hostID string) {
|
||
host, err := s.store.GetHost(hostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] reveal recovery credential %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
cred, err := s.store.GetHostRecoveryCredential(hostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] reveal recovery credential %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if cred == nil {
|
||
// A 404 is not an access — nothing was delivered, so nothing is recorded on the timeline.
|
||
s.logger.Printf("[INFO] reveal recovery credential %s: no credential vaulted", hostID)
|
||
http.Error(w, "No recovery credential vaulted for this host", http.StatusNotFound)
|
||
return
|
||
}
|
||
// Transparency by default, exactly as handleRequestLogTail does it: SaveEvent alone writes the
|
||
// customer-visible timeline row WITHOUT emailing anyone (no dispatcher call here, by design).
|
||
// An unbound host has no customer to tell — the [INFO] line below is then the only record.
|
||
if host.CustomerID != "" {
|
||
if _, err := s.store.SaveEvent(host.CustomerID, "recovery_credential_revealed", "info",
|
||
"Az üzemeltető lekérte a géped konzolos hozzáférési jelszavát (távoli hibaelhárítás).", "", "hub"); err != nil {
|
||
s.logger.Printf("[WARN] SaveEvent recovery_credential_revealed %s/%s: %v", host.CustomerID, hostID, err)
|
||
}
|
||
}
|
||
s.logger.Printf("[INFO] operator revealed break-glass console credential for host %s (user=%s, secret %d chars)",
|
||
hostID, cred.Username, len(cred.Secret))
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"host_id": cred.HostID,
|
||
"username": cred.Username,
|
||
"password": cred.Secret,
|
||
"set_at": cred.SetAt.UTC().Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
// handleHostDelete — POST /hosts/{id}/delete (v0.47.0 stale host removal). Gates, in order:
|
||
// - unknown host → 404
|
||
// - ONLINE host → 409 unconditionally (host reports authenticate via GetHostByAPIKey;
|
||
// deleting a live host permanently bricks its heartbeat channel — enroll is
|
||
// passphrase-gated mint-once, so there is deliberately NO override)
|
||
// - confirm_host_id mismatch → 400 (type-to-confirm)
|
||
// - escrow present without delete_escrow=1 → 409 (store-enforced, fail-safe-to-refuse)
|
||
func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID string) {
|
||
host, err := s.store.GetHost(hostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if status := s.hostStatus(host.LastReportAt); status == "ok" {
|
||
s.logger.Printf("[WARN] host delete refused: %s is online", hostID)
|
||
http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently).", http.StatusConflict)
|
||
return
|
||
}
|
||
if confirm := strings.TrimSpace(r.FormValue("confirm_host_id")); confirm != hostID {
|
||
s.logger.Printf("[WARN] host delete refused: %s confirm mismatch", hostID)
|
||
http.Error(w, "Confirmation does not match the host id — nothing deleted.", http.StatusBadRequest)
|
||
return
|
||
}
|
||
deleteEscrow := r.FormValue("delete_escrow") == "1"
|
||
if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil {
|
||
if errors.Is(err, store.ErrHostEscrowPresent) {
|
||
s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID)
|
||
http.Error(w, "This host has a key escrow (+ DR bundle). Tick the escrow acknowledgement to move it to retained custody — nothing deleted.", http.StatusConflict)
|
||
return
|
||
}
|
||
s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
s.logger.Printf("[INFO] host deleted: %s (escrow deleted: %v)", hostID, deleteEscrow)
|
||
http.Redirect(w, r, "/hosts", http.StatusSeeOther)
|
||
}
|
||
|
||
// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
|
||
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
|
||
host, err := s.store.GetHost(hostID)
|
||
if err != nil {
|
||
s.logger.Printf("[ERROR] Host detail %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
data := s.hostDetailData(host, r)
|
||
if err := s.templates.ExecuteTemplate(w, "host_detail.html", data); err != nil {
|
||
s.logger.Printf("[ERROR] host_detail.html template: %v", err)
|
||
}
|
||
}
|