hub v0.46.0: observability pass — per-box log pulls, bundle custody, 72h TTL + secret gate
log_bundle_requests + log_bundles store (gzip, newest-3, 72h TTL purged on the 60s sweep); SaveLogBundle secret gate fail-closed (blocked flag row, no payload; REDACTED/checksums pass). Report ACK gains controller_log_requested + ingests controller_log_tail; heartbeat envelope gains log_tail_requested + ingests log_tail (consume-once on arrival; pre-0.83 agents stay visibly pending). Host detail Diagnostics section: request buttons (controller/agent), state rows with honest latency hints, View/Download endpoint. Red-proofs: gate disabled and clear-on-arrival removed both FAIL their tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -329,6 +329,9 @@ func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID
|
||||
"StorageTargets": storageTargets,
|
||||
"DRPresent": drBundle != nil,
|
||||
"EscrowPresent": escrow != nil,
|
||||
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).
|
||||
"LogBundles": s.hostLogBundleRows(host),
|
||||
"CSRFToken": s.getCSRFToken(r),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "host_detail.html", data); err != nil {
|
||||
s.logger.Printf("[ERROR] host_detail.html template: %v", err)
|
||||
|
||||
@@ -166,9 +166,17 @@ func TestHandleHostDetail(t *testing.T) {
|
||||
if strings.Contains(body, secretKey) {
|
||||
t.Errorf("SECRET LEAK: detail page rendered the host api_key")
|
||||
}
|
||||
// Read-only: no host action buttons.
|
||||
if strings.Contains(strings.ToLower(body), "<button") {
|
||||
t.Error("host detail must not contain action buttons")
|
||||
// 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).
|
||||
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)
|
||||
}
|
||||
if strings.Count(body, `action="/hosts/demo-felhom-01/request-logs"`) != 2 {
|
||||
t.Error("the request-logs forms are missing — every button must be a log-bundle request")
|
||||
}
|
||||
// The Diagnostics section renders with its honest latency hint.
|
||||
if !strings.Contains(body, "Diagnostics") || !strings.Contains(body, "72 h") {
|
||||
t.Error("Diagnostics log-bundle section missing")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// Component log-bundle UI (v0.46.0) — the host-detail "Diagnostics" section. The
|
||||
// operator requests the CONTROLLER ring (rides the report ACK, ≤ ~15 min) or the
|
||||
// AGENT ring (rides the heartbeat envelope, ≈ the heartbeat cadence); the box
|
||||
// pushes on its own cycle (pull-only sovereignty posture; the box's own log
|
||||
// records the pull — customer-visible transparency). Bundles expire after 72 h.
|
||||
|
||||
// logBundleRow is the template's per-bundle/pending row.
|
||||
type logBundleRow struct {
|
||||
ID int
|
||||
Component string
|
||||
State string // pending | available | blocked
|
||||
RequestedAt time.Time
|
||||
CollectedAt time.Time
|
||||
ReceivedAt time.Time
|
||||
SizeBytes int64
|
||||
Blocked bool
|
||||
BlockedNote string
|
||||
}
|
||||
|
||||
// logBundleScope resolves the store scope for a component on this host: the agent
|
||||
// channel is per-host; the controller channel is per-customer (the report ACK).
|
||||
func logBundleScope(host *store.Host, component string) string {
|
||||
if component == store.LogBundleComponentController {
|
||||
return host.CustomerID
|
||||
}
|
||||
return host.HostID
|
||||
}
|
||||
|
||||
// handleRequestLogBundle — POST /hosts/{id}/request-logs (form: component).
|
||||
// Stores the pending pull the respective channel advertises on the box's next cycle.
|
||||
func (s *Server) handleRequestLogBundle(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
if !s.validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil || host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
component := strings.TrimSpace(r.FormValue("component"))
|
||||
if component != store.LogBundleComponentController && component != store.LogBundleComponentAgent {
|
||||
http.Error(w, "Invalid component", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.store.RequestLogBundle(logBundleScope(host, component), component); err != nil {
|
||||
s.logger.Printf("[ERROR] RequestLogBundle %s/%s: %v", hostID, component, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] %s log bundle requested for host %s — the box delivers on its next cycle", component, hostID)
|
||||
http.Redirect(w, r, "/hosts/"+hostID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogBundleView — GET /hosts/{id}/log-bundles/{bundleID} renders the bundle;
|
||||
// ?download=1 serves it as a plain-text .log file. Scoped to the host's own scopes.
|
||||
func (s *Server) handleLogBundleView(w http.ResponseWriter, r *http.Request, hostID, bundleIDStr string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
if err != nil || host == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id, err := strconv.Atoi(bundleIDStr)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Try both scopes this host legitimately owns (agent = host_id, controller = customer_id).
|
||||
meta, lines, err := s.store.GetLogBundleContent(id, host.HostID)
|
||||
if err == nil && meta == nil {
|
||||
meta, lines, err = s.store.GetLogBundleContent(id, host.CustomerID)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] GetLogBundleContent %d/%s: %v", id, hostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if meta == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if meta.Blocked {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprintf(w, "bundle %d (%s) is BLOCKED: %s\nNothing was stored — fix the offending log line box-side and re-request.\n",
|
||||
meta.ID, meta.Component, meta.BlockedReason)
|
||||
return
|
||||
}
|
||||
filename := fmt.Sprintf("%s-%s-%s.log", hostID, meta.Component, meta.CollectedAt.UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if r.URL.Query().Get("download") == "1" {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
}
|
||||
for _, line := range lines {
|
||||
w.Write([]byte(line))
|
||||
w.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// hostLogBundleRows builds the host-detail Diagnostics rows: pending requests first
|
||||
// (with the honest per-channel latency hint rendered template-side), then stored
|
||||
// bundles newest-first. Both of this host's scopes are merged.
|
||||
func (s *Server) hostLogBundleRows(host *store.Host) []logBundleRow {
|
||||
var rows []logBundleRow
|
||||
seenPending := map[string]bool{}
|
||||
for _, scope := range []string{host.HostID, host.CustomerID} {
|
||||
reqs, err := s.store.GetLogBundleRequests(scope)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, req := range reqs {
|
||||
// A host row only owns its own channel scopes.
|
||||
if logBundleScope(host, req.Component) != scope || seenPending[req.Component] {
|
||||
continue
|
||||
}
|
||||
seenPending[req.Component] = true
|
||||
rows = append(rows, logBundleRow{
|
||||
Component: req.Component, State: "pending", RequestedAt: req.RequestedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, scope := range []string{host.HostID, host.CustomerID} {
|
||||
bundles, err := s.store.GetLogBundles(scope)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, b := range bundles {
|
||||
if logBundleScope(host, b.Component) != scope {
|
||||
continue
|
||||
}
|
||||
state := "available"
|
||||
if b.Blocked {
|
||||
state = "blocked"
|
||||
}
|
||||
rows = append(rows, logBundleRow{
|
||||
ID: b.ID, Component: b.Component, State: state,
|
||||
CollectedAt: b.CollectedAt, ReceivedAt: b.ReceivedAt,
|
||||
SizeBytes: b.SizeBytes, Blocked: b.Blocked, BlockedNote: b.BlockedReason,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -251,9 +251,23 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Offsite — read-only WG endpoint + peer registry (S2). Mutations stay on the admin API.
|
||||
case path == "/offsite":
|
||||
s.handleOffsite(w, r)
|
||||
// Hosts — read-only fleet view (audit F-M1). GET only; no host actions.
|
||||
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
|
||||
case path == "/hosts" || path == "/hosts/":
|
||||
s.handleHostsList(w, r)
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleRequestLogBundle(w, r, hostID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/log-bundles/"):
|
||||
rest := strings.TrimPrefix(path, "/hosts/")
|
||||
if i := strings.Index(rest, "/log-bundles/"); i > 0 {
|
||||
s.handleLogBundleView(w, r, rest[:i], rest[i+len("/log-bundles/"):])
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case strings.HasPrefix(path, "/hosts/"):
|
||||
hostID := strings.TrimPrefix(path, "/hosts/")
|
||||
s.handleHostDetail(w, r, hostID)
|
||||
|
||||
@@ -173,6 +173,66 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Diagnostics: component log bundles (v0.46.0) -->
|
||||
<section class="card">
|
||||
<h2>Diagnostics — Log Bundles</h2>
|
||||
<p class="hint" style="color: var(--text-muted); font-size: 0.85rem;">
|
||||
Pull-based: the box ships its debug ring on its own next cycle — controller ≤ one report interval (~15 min),
|
||||
agent ≈ one heartbeat. The pull is recorded in the box's own log (customer-visible). Bundles expire after 72 h.
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; margin: 0.75rem 0;">
|
||||
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="component" value="controller">
|
||||
<button type="submit" class="btn btn-sm">Request controller logs</button>
|
||||
</form>
|
||||
<form method="POST" action="/hosts/{{.HostID}}/request-logs" style="display: inline;">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="component" value="agent">
|
||||
<button type="submit" class="btn btn-sm">Request agent logs</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .LogBundles}}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>State</th>
|
||||
<th>Collected</th>
|
||||
<th>Received</th>
|
||||
<th>Size</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .LogBundles}}
|
||||
<tr>
|
||||
<td>{{.Component}}</td>
|
||||
<td>
|
||||
{{if eq .State "pending"}}<span class="badge badge-neutral" title="Waiting for the box's next cycle (requested {{timeAgo .RequestedAt}})">pending</span>
|
||||
{{else if eq .State "blocked"}}<span class="badge badge-error" title="{{.BlockedNote}}">blocked: possible secret</span>
|
||||
{{else}}<span class="badge badge-ok">available</span>{{end}}
|
||||
</td>
|
||||
<td>{{if .CollectedAt.IsZero}}—{{else}}{{timeAgo .CollectedAt}}{{end}}</td>
|
||||
<td>{{if .ReceivedAt.IsZero}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
|
||||
<td>{{if .SizeBytes}}{{.SizeBytes}} B{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if eq .State "available"}}
|
||||
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}" class="btn btn-sm">View</a>
|
||||
<a href="/hosts/{{$.HostID}}/log-bundles/{{.ID}}?download=1" class="btn btn-sm">Download</a>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state" style="border: none;">
|
||||
<p>No log bundles. Use the request buttons above — the box delivers on its next cycle.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- DR / Backup -->
|
||||
<section class="card">
|
||||
<h2>DR / Backup</h2>
|
||||
|
||||
Reference in New Issue
Block a user