2752e12acc
- DeleteHost(deleteEscrow) demotes current host_escrow into host_escrow_superseded (copy-before-delete, same tx), spares existing; one shared demoteCurrentEscrowTx (reused by SaveHostEscrow). F-14 provenance/gate unchanged. - DeleteCustomerConfig now purges both escrow tables for all the customer's hosts incl. already-deleted (F-14 provenance UNION) — the one true purge point. - Wording: checkbox/refusal/Danger-zone → demotion. S6b OBSOLETE. Red-proofs TestDeleteHost_Demotes + TestDeleteCustomer_Purges + wording guard.
517 lines
18 KiB
Go
517 lines
18 KiB
Go
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.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.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 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)
|
||
}
|
||
}
|