Files
felhom.eu/hub/internal/web/hosts.go
T
admin a7f1d277b1
gates / gates (push) Successful in 7s
hub: the held-floor REASON must match the CAUSE (CAMPAIGN-11 follow-on)
v0.97.0 introduced a second hold reason and left both surfaces printing the first. The
freshly deployed hub logged, for the campaign box:

  managed floor HELD for c11: agent "0.125.0" < MinAgent 0.113.0

which is FALSE — 0.125.0 is ABOVE 0.113.0. That box is held because its floor sits above
the vouched golden, not because of its agent. CLAUDE.md's corollary exactly: when a verdict
changes which field it counts from, the alarm text has to change with it, or a true alarm
reads as one to dismiss.

Both the ACK log line and the Hosts-dashboard HeldReason now come from one
ManagedFloorDecision.HoldReason(), and TestResolveManagedFloor_HoldReasonMatchesTheCause
pins each reason to its cause.
2026-08-05 17:53:24 +02:00

749 lines
27 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 (
"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
}
// 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)
// 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,
"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)
}
}