Files
felhom.eu/hub/internal/web/logbundle.go
T
admin e35b1ae0e6 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
2026-07-11 16:57:47 +02:00

155 lines
5.2 KiB
Go

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
}