60244727ad
GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub completes it the instant any operator intent bumps that customer's in-memory generation, then the box fires its ordinary report and the ACK delivers everything through the unchanged machinery. 240s hold with a 25s heartbeat newline defeats the nginx 60s proxy_read_timeout with no ingress annotation; WriteTimeout lifted per-connection via ResponseController. - internal/intent: per-customer generation counter + waiter registry (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design. Red-proofs: counter-vs-queue + race-closer (run-fail-reverted). - api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B). - web bumps after every intent write (fire-after-commit): config CRUD, claim resend, offsite re-issue/freeze, password regen, block/unblock, floors (global bumps all config-managed), controller log-tail + log-bundle. - main.go: one intent hub shared by web+api; Close() before server.Shutdown. Pairs with controller v0.140.0 (the long-poll client). Grounding: documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
161 lines
5.5 KiB
Go
161 lines
5.5 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)
|
|
// Direction-2: only the CONTROLLER ring rides the report ACK (the wait channel wakes the
|
|
// controller). The AGENT ring rides the heartbeat envelope — a separate plane this task does not
|
|
// touch — so it is deliberately NOT bumped here.
|
|
if component == store.LogBundleComponentController {
|
|
s.bumpIntent(host.CustomerID)
|
|
}
|
|
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
|
|
}
|