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)
+203
View File
@@ -0,0 +1,203 @@
package web
// Scenarios C/D (hub v0.47.0 stale host removal) — the web-layer gates. Every refusal test
// asserts the NON-effect (the host and its artifacts still exist), not just the status code.
// The store-level cascade completeness lives in store/host_delete_test.go.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func postHostDelete(t *testing.T, s *Server, hostID string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/hosts/"+hostID+"/delete", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleHostDelete(rr, req, hostID)
return rr
}
// D1 — an ONLINE host is never deletable, even with a correct confirmation + escrow ack.
// RED-PROOF 1: removing the online gate in handleHostDelete makes this FAIL (host deleted).
func TestHostDelete_OnlineRefused(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "live-host", CustomerID: "c1", APIKey: "k"}); err != nil {
t.Fatal(err)
}
// A just-saved report → status "ok" (online).
if err := st.SaveHostReport("live-host", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
before, err := st.CountHostArtifacts("live-host")
if err != nil {
t.Fatal(err)
}
rr := postHostDelete(t, s, "live-host", url.Values{
"confirm_host_id": {"live-host"}, "delete_escrow": {"1"},
})
if rr.Code != http.StatusConflict {
t.Fatalf("online delete = %d, want 409", rr.Code)
}
// Non-effect: the host row and every artifact are still there.
if h, _ := st.GetHost("live-host"); h == nil {
t.Fatal("online host was DELETED despite the 409")
}
after, _ := st.CountHostArtifacts("live-host")
if after != before {
t.Errorf("artifacts changed on a refused delete: %+v → %+v", before, after)
}
}
// D2 — escrow present + no acknowledgement → 409 naming the escrow, ZERO deletions.
// RED-PROOF 2: dropping the escrow-ack check (passing deleteEscrow=true unconditionally)
// makes this FAIL (host + escrow deleted).
func TestHostDelete_EscrowAckRequired(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "esc-host", CustomerID: "c2", APIKey: "k"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostEscrow("esc-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
t.Fatal(err)
}
rr := postHostDelete(t, s, "esc-host", url.Values{"confirm_host_id": {"esc-host"}})
if rr.Code != http.StatusConflict {
t.Fatalf("escrow-unacked delete = %d, want 409", rr.Code)
}
if !strings.Contains(rr.Body.String(), "escrow") {
t.Error("409 body must name the escrow so the operator knows what to acknowledge")
}
if h, _ := st.GetHost("esc-host"); h == nil {
t.Fatal("host deleted despite missing escrow ack")
}
if e, _ := st.GetHostEscrow("esc-host"); e == nil {
t.Fatal("escrow deleted despite missing ack")
}
}
// D3 — type-to-confirm mismatch → 400, zero deletions.
func TestHostDelete_ConfirmMismatch(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "typo-host", CustomerID: "c3", APIKey: "k"}); err != nil {
t.Fatal(err)
}
rr := postHostDelete(t, s, "typo-host", url.Values{"confirm_host_id": {"typo-hots"}})
if rr.Code != http.StatusBadRequest {
t.Fatalf("confirm mismatch = %d, want 400", rr.Code)
}
if h, _ := st.GetHost("typo-host"); h == nil {
t.Fatal("host deleted despite confirm mismatch")
}
// Unknown host → 404.
rr = postHostDelete(t, s, "ghost", url.Values{"confirm_host_id": {"ghost"}})
if rr.Code != http.StatusNotFound {
t.Errorf("unknown host delete = %d, want 404", rr.Code)
}
}
// D4 — the impact probe returns the documented JSON shape: counts + booleans ONLY.
func TestHostDelete_ImpactJSON(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "imp-host", CustomerID: "c4", APIKey: "SECRET-KEY"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostEscrow("imp-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
t.Fatal(err)
}
if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("imp-host", 100),
CustomerID: "c4", HostID: "imp-host", VMID: 100, Status: "stopped"}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/imp-host/delete-impact", nil), "imp-host")
if rr.Code != http.StatusOK {
t.Fatalf("impact = %d", rr.Code)
}
var d struct {
Status string `json:"status"`
Deletable bool `json:"deletable"`
Guests int `json:"guests"`
Reports int `json:"reports"`
LogBundles int `json:"log_bundles"`
EscrowPresent bool `json:"escrow_present"`
WGPeerBound bool `json:"wg_peer_bound"`
PBSSecretPresent bool `json:"pbs_secret_present"`
RecoveryPresent bool `json:"recovery_present"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &d); err != nil {
t.Fatalf("impact JSON: %v", err)
}
if d.Status != "pending" || !d.Deletable || d.Guests != 1 || !d.EscrowPresent {
t.Errorf("impact = %+v, want pending/deletable/guests=1/escrow", d)
}
// Booleans/counts only — never the api_key or blob bytes.
if strings.Contains(rr.Body.String(), "SECRET-KEY") || strings.Contains(rr.Body.String(), "blob") {
t.Error("SECRET LEAK: impact JSON carries a secret value")
}
// Unknown host → 404.
rr = httptest.NewRecorder()
s.handleHostDeleteImpact(rr, httptest.NewRequest(http.MethodGet, "/hosts/nope/delete-impact", nil), "nope")
if rr.Code != http.StatusNotFound {
t.Errorf("unknown impact = %d, want 404", rr.Code)
}
}
// Scenario C (handler level) — a deletable host with escrow + ack: 303 to /hosts, rows gone.
func TestHostDelete_HappyPath(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "dr-drill", CustomerID: "c5", APIKey: "k"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostEscrow("dr-drill", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
t.Fatal(err)
}
rr := postHostDelete(t, s, "dr-drill", url.Values{
"confirm_host_id": {"dr-drill"}, "delete_escrow": {"1"},
})
if rr.Code != http.StatusSeeOther {
t.Fatalf("delete = %d (%s), want 303", rr.Code, rr.Body.String())
}
if loc := rr.Header().Get("Location"); loc != "/hosts" {
t.Errorf("redirect = %q, want /hosts", loc)
}
if h, _ := st.GetHost("dr-drill"); h != nil {
t.Fatal("host row survived the delete")
}
if e, _ := st.GetHostEscrow("dr-drill"); e != nil {
t.Fatal("escrow row survived the acknowledged delete")
}
}
// The danger-zone card renders ONLY for a non-online host. The ONLINE case is pinned by
// TestHandleHostDetail's exactly-2-buttons assertion (which now doubles as the
// "delete hidden for online hosts" proof).
func TestHostDetail_DangerCardForStaleOnly(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "junk-host", CustomerID: "c6", APIKey: "k"}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/junk-host", nil), "junk-host")
body := rr.Body.String()
if !strings.Contains(body, "Danger zone") {
t.Error("non-online host missing the danger-zone card")
}
if !strings.Contains(body, `action="/hosts/junk-host/delete"`) {
t.Error("danger-zone card missing the delete form")
}
if !strings.Contains(body, "Re-enrollment requires the Day-0 passphrase flow") {
t.Error("danger copy must state the consequence")
}
}
+3
View File
@@ -168,6 +168,9 @@ func TestHandleHostDetail(t *testing.T) {
}
// v0.46.0: the ONLY host actions are the two log-bundle request forms (the page is
// otherwise still read-only — no destructive/host-mutating buttons).
// v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host
// danger-zone card must NOT render for it — this pin now doubles as the
// "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly).
if got := strings.Count(strings.ToLower(body), "<button"); got != 2 {
t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
}
+16
View File
@@ -254,6 +254,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
case path == "/hosts" || path == "/hosts/":
s.handleHostsList(w, r)
// v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
// (mirroring the request-logs placement).
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete-impact")
if r.Method == http.MethodGet {
s.handleHostDeleteImpact(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete")
if r.Method == http.MethodPost {
s.handleHostDelete(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
if r.Method == http.MethodPost {
@@ -227,4 +227,74 @@
</div>
</div>
</section>
{{if .Deletable}}
<!-- Danger zone (v0.47.0): rendered ONLY for non-online hosts — deleting a live
host would brick its heartbeat channel, so the affordance never exists for one.
Impact + type-to-confirm dialog per the global-floor confirm pattern. -->
<section class="card" style="border-color: var(--crit);">
<h2>Danger zone</h2>
<p class="text-muted" style="font-size: 0.85rem;">
Removing this host deletes its reports, guests, log bundles and WireGuard peer, and
permanently invalidates its API key — a still-running agent would receive 401s.
Re-enrollment requires the Day-0 passphrase flow.
</p>
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteConfirm('{{.HostID}}')">Remove host&hellip;</button>
<div id="host-delete-confirm-{{.HostID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); background: var(--crit-dim); border-radius: var(--radius); max-width: 44em;">
<p id="host-delete-impact-{{.HostID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">&hellip;</p>
<label id="host-delete-escrow-row-{{.HostID}}" style="display: none; margin: 0 0 0.5rem; font-size: 0.85em;">
<input type="checkbox" id="host-delete-escrow-{{.HostID}}">
Also delete the key escrow + DR bundle for this host
</label>
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: var(--text-2);">Type the host id to confirm:</p>
<form method="POST" action="/hosts/{{.HostID}}/delete" id="host-delete-form-{{.HostID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="confirm_host_id" id="host-delete-confirm-hidden-{{.HostID}}" value="">
<input type="hidden" name="delete_escrow" id="host-delete-escrow-hidden-{{.HostID}}" value="">
<input type="text" id="host-delete-confirm-input-{{.HostID}}" placeholder="retype the host id&hellip;" style="padding: 0.3em 0.5em; width: 16em;">
<button type="button" class="btn btn-danger btn-sm" onclick="hostDeleteSubmit('{{.HostID}}')">Confirm &amp; remove</button>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('host-delete-confirm-{{.HostID}}').style.display='none';">Cancel</button>
</form>
<p id="host-delete-err-{{.HostID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
</div>
</section>
<script>
function hostDeleteConfirm(hostID) {
var box = document.getElementById('host-delete-confirm-' + hostID);
var impact = document.getElementById('host-delete-impact-' + hostID);
document.getElementById('host-delete-confirm-input-' + hostID).value = '';
document.getElementById('host-delete-err-' + hostID).textContent = '';
box.style.display = 'block';
impact.textContent = 'Checking impact…';
fetch('/hosts/' + encodeURIComponent(hostID) + '/delete-impact')
.then(function(r){ return r.json(); })
.then(function(d){
var parts = ['Deleting ' + hostID + ' removes ' + d.guests + ' guest row(s), ' +
d.reports + ' host report(s), ' + d.log_bundles + ' agent log bundle(s)'];
if (d.wg_peer_bound) parts.push('the bound WireGuard peer');
if (d.pbs_secret_present) parts.push('the staged PBS secret');
if (d.recovery_present) parts.push('the break-glass recovery credential');
impact.textContent = parts.join(', ') + '. Host status: ' + d.status + '.' +
(d.deletable ? '' : ' Host is ONLINE — deletion will be refused.');
document.getElementById('host-delete-escrow-row-' + hostID).style.display =
d.escrow_present ? 'block' : 'none';
})
.catch(function(){ impact.textContent = 'Could not compute the impact — the server will still enforce every gate.'; });
}
function hostDeleteSubmit(hostID) {
var typed = document.getElementById('host-delete-confirm-input-' + hostID).value.trim();
var err = document.getElementById('host-delete-err-' + hostID);
if (typed !== hostID) { err.textContent = 'Confirmation does not match the host id.'; return; }
var escrowRow = document.getElementById('host-delete-escrow-row-' + hostID);
var escrowCb = document.getElementById('host-delete-escrow-' + hostID);
if (escrowRow.style.display !== 'none' && !escrowCb.checked) {
err.textContent = 'This host has a key escrow — tick the acknowledgement to delete it too.';
return;
}
document.getElementById('host-delete-confirm-hidden-' + hostID).value = typed;
document.getElementById('host-delete-escrow-hidden-' + hostID).value = escrowCb.checked ? '1' : '';
document.getElementById('host-delete-form-' + hostID).submit();
}
</script>
{{end}}
{{end}}