Files
felhom.eu/hub/internal/web/hosts.go
T
admin e07d90f0f4 hub v0.85.0 — Network card: a host's addresses are visible at last
Pairs with agent v0.119.0 and is useless without it.

A managed box's LAN IP was not shown anywhere in the hub, because nothing
reported it — the host report carried no address of any kind. The only IP
reachable from the UI at all was the WireGuard one, on /offsite's peer table
keyed by pubkey, so an operator could go peer->host and never host->peer, which
is the direction anyone actually asks in.

The host page grows a Network card: every routable address the box holds, one row
per (interface, address), plus a WireGuard row. On demo-felhom that is vmbr0
192.168.0.162/24 and tailscale0 100.70.170.35/32 — with the PVE web console at
https://<the LAN address>:8006, the thing the operator wanted and could not get.

WireGuard is rendered as TWO facts, deliberately. WGAssignedIP is the hub's own
allocation (wg_peers, authoritative desired state); WGConfirmed is whether the box
reports actually holding it. Showing the allocation alone would make a peer that
was never applied look healthy — the same shape as reading a timestamp that
records an attempt as if it recorded a result.

The split is keyed on the ALLOCATION, not the interface name: wg-felhom is the
agent's current unit name, and a UI keyed on that string would silently
mis-render the day it changes.

An old agent renders UNKNOWN, never "no addresses". Below agent 0.119.0 the field
is absent from the wire, and an absent signal is not a negative result — the page
says so and names the version needed. Rendering an empty list there would have
stated something false about the host.

No new store table and no new ingest path: the report is already stored opaquely
and GetWGPeerForHost already existed with no UI consumer. This is parse + render.

The report fixture in the tests is the REAL wire — the addresses block copied out
of `felhom-agent --selftest=hub` on demo-felhom running 0.119.0.

Tests 559 -> 566; four red-proofs (inert view-model, unconditional confirmation,
the old-agent branch, and the drift case) each run, observed failing, reverted.
2026-07-31 08:49:44 +02:00

750 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"
"fmt"
"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 = 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)
}
// 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)
}
}