docs: Q1c GREEN — reboot survival automatic since agent 0.84.0 (feature doc + audit §7 + CONTEXT + REPORT)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 21:25:14 +02:00
parent ae950e5933
commit 146d165c26
11 changed files with 743 additions and 40 deletions
+83
View File
@@ -2,9 +2,11 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -323,9 +325,90 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
// 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 := true // RED-PROOF 2: escrow-ack check dropped
if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil {
if errors.Is(err, store.ErrHostEscrowPresent) {
s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID)
http.Error(w, "This host has a key escrow (+ DR bundle). Tick the escrow acknowledgement to delete it too — nothing deleted.", http.StatusConflict)
return
}
s.logger.Printf("[ERROR] host delete %s: %v", hostID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] host deleted: %s (escrow deleted: %v)", hostID, deleteEscrow)
http.Redirect(w, r, "/hosts", http.StatusSeeOther)
}
// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
host, err := s.store.GetHost(hostID)