Files
felhom.eu/hub/internal/web/hosts.go
T

514 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
"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
}
// 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 = fmt.Sprintf("held: agent %s < MinAgent %s", agentOrUnknown(fd.AgentVersion), fd.MinAgent)
}
// 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)
}
data := map[string]interface{}{
"Hosts": rows,
}
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.
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)
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)
return map[string]interface{}{
"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.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,
})
}
// 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 delete it too — 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)
}
}