f181efd6a7
gates / gates (push) Successful in 18s
Operator ruling 2026-09-13. Above the vouched golden, a floor saved with a declared MinAgent is served under the same agent comparison; an undeclared one is still held beyond the golden. The declaration is stored beside each floor as FLOOR=MINAGENT so it never carries to a later floor. Both floor forms require min_agent above the golden (flash floor_needs_min_agent, nothing stored). The Hosts page and the API log name the source. Vouch path and R-120 gate untouched. Scenarios A-E tested; red-proofs A and C in documentation/audits/rulings-r472-r475-2026-09-13/.
927 lines
36 KiB
Go
927 lines
36 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
|
||
// FloorSource (R-472) — where a SERVED floor's agent requirement came from: "manifest" or
|
||
// "declared" ("" = no floor, or an uncoupled release).
|
||
FloorSource 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()
|
||
} else if fd.Floor != "" {
|
||
row.FloorSource = fd.MinAgentSource
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|