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) } }