hub v0.135.0: CSRF on the Basic-auth path (R-135), console passwords sealed at rest (R-133), boxes left behind listed and alarmed (R-604, R-530), no-e-mail banner (R-508)
gates / gates (push) Successful in 29s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 11:12:56 +02:00
parent 9bb45eaaa2
commit 3d7a2761fc
24 changed files with 1148 additions and 17 deletions
+55
View File
@@ -375,6 +375,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
// Save-triggered (applyOffsite), and the re-enroll auto-re-issue deliberately skips an
// unprovisioned target — so this state is stable and silent until someone presses Save.
OffsiteUnprovisioned bool
// WaitingNoEmail (R-508, v0.135.0): a configured customer with NO box and NO registered e-mail — the
// connect link and the setup code can reach nobody. The hub logged it; the page now says it.
WaitingNoEmail bool
}
pendingSet := make(map[string]bool, len(pendingTails))
@@ -476,6 +480,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
StaleSinceReset: staleSinceReset,
ResetAt: resetAt,
OffsiteUnprovisioned: offsiteUnprovisioned,
WaitingNoEmail: cfg != nil && len(hostViews) == 0 && strings.TrimSpace(email) == "",
LatestVersion: latestVersion,
UpdateAvailable: updateAvailable,
@@ -1162,6 +1167,7 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
return
}
s.logger.Printf("[INFO] Global controller-version floor set to %q (declared MinAgent %q)", v, ma)
s.reportFloorHeldBack(v)
// Direction-2: the global floor affects every config-managed customer — wake each long-polling
// box so the new floor lands in seconds (nil-safe; a customer with no held wait just advances).
if configs, cerr := s.store.ListCustomerConfigs(); cerr == nil {
@@ -1172,6 +1178,55 @@ func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther)
}
// heldBackByOwnFloor lists the customers a global floor `global` does NOT move because their own per-customer
// floor override is LOWER (R-604). An override at or above the global is not held back — it already asks for
// at least as much. Pure on the store; ordered by customer id.
func (s *Server) heldBackByOwnFloor(global string) []store.CustomerFloorOverride {
if global == "" || !semver.Valid(global) {
return nil
}
ovs, err := s.store.CustomerFloorOverrides()
if err != nil {
s.logger.Printf("[ERROR] floor overrides: %v", err)
return nil
}
var out []store.CustomerFloorOverride
for _, o := range ovs {
if semver.Valid(o.Version) && semver.Compare(global, o.Version) > 0 {
out = append(out, o)
}
}
return out
}
// reportFloorHeldBack says, at the moment of the raise, which boxes it did NOT move (R-604, hub v0.135.0): one log
// line per customer and ONE operator mail naming them all. Before v0.135.0 the raise logged nothing for such a box,
// and demo-hp silently missed four raises. Pinned by r604_floor_held_back_test.go.
func (s *Server) reportFloorHeldBack(global string) {
held := s.heldBackByOwnFloor(global)
if len(held) == 0 {
return
}
var names []string
for _, o := range held {
age := "set before hub v0.135.0 — age unknown"
if !o.SetAt.IsZero() {
age = "set " + o.SetAt.UTC().Format("2006-01-02 15:04") + " UTC"
}
s.logger.Printf("[WARN] global floor %s does NOT move customer %s: its own floor %s wins (%s) — clear it on the customer page to let the global floor apply",
global, o.CustomerID, o.Version, age)
names = append(names, fmt.Sprintf("%s (own floor %s, %s)", o.CustomerID, o.Version, age))
}
if s.emit == nil {
return
}
details, _ := json.Marshal(map[string]any{"global_floor": global, "held_back": names})
s.emit("", "floor_raise_skipped", "warning",
fmt.Sprintf("Floor: the global controller floor is now %s, but %d box(es) keep their own LOWER floor and were not moved: %s. "+
"Clear each per-customer floor (or raise it) on the customer page.", global, len(held), strings.Join(names, "; ")),
string(details), "hub")
}
// floorDeclaredMinAgent reads and validates the `min_agent` a floor form declares (R-472). It returns
// the normalised value, or a flash key when the form must be REFUSED with nothing stored:
//
@@ -0,0 +1,32 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-133: with the wrong sealing key the reveal endpoint fails CLOSED — 500, no password in the body, nothing
// in the log, and no "revealed" event (nothing was delivered). The right-key path is TestReveal_B.
func TestR133_RevealWithWrongKeyFailsClosed(t *testing.T) {
s, st, logBuf := newRevealServer(t)
cookie, csrf := newRevealSession(t, s)
seedRevealHost(t, st, "demo-hp-bb76ea", "demo-hp", revealCanary)
if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/hosts/demo-hp-bb76ea/reveal-recovery-credential", nil)
req.AddCookie(cookie)
req.Header.Set("X-CSRF-Token", csrf)
rr := serveReveal(t, s, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("reveal with a wrong key = %d, want 500", rr.Code)
}
if strings.Contains(rr.Body.String(), revealCanary) || strings.Contains(logBuf.String(), revealCanary) {
t.Fatal("the secret leaked into the body or the log")
}
if n := countEvents(t, st, "demo-hp", "recovery_credential_revealed"); n != 0 {
t.Fatalf("%d reveal event(s) for a reveal that delivered nothing", n)
}
}
+162
View File
@@ -0,0 +1,162 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// R-135 (hub v0.135.0): a state-changing request passes the gate only with (a) a live session cookie AND its
// token, or (b) NO cookie, Basic credentials AND the OperatorCLIHeader. Everything else is 403 — on every
// route, because the gate sits in ServeHTTP BEFORE the route switch.
//
// RED-PROOF (recorded in felhom.eu/documentation/audits/hub-safety-2026-10-05/partA/red-proof.txt): restore
// the pre-v0.135.0 `return true` for a request with no cookie → TestR135_BasicAuthWithoutHeaderIsRefused
// fails on every route (the handlers answer 303/404/400/200 instead of 403).
// r135PostRoutes is EVERY state-changing route of the hub web server (server.go ServeHTTP), one
// representative path each. /login and /bind/<token> are the two documented exemptions (no operator session
// to ride; the bind URL token is the capability) and are NOT in this list.
var r135PostRoutes = []string{
"/configuration",
"/apps/demo/reset-telemetry",
"/apps/demo/dismiss-issues",
"/offsite/endpoints",
"/offsite/endpoints/1/delete",
"/appliances/1/bind",
"/appliances/1/discard",
"/hosts/h1/delete",
"/hosts/h1/reveal-recovery-credential",
"/hosts/h1/request-logs",
"/customers/c1/block",
"/customers/c1/selfbind-link",
"/customers/c1/unblock",
"/customers/c1/geo/disable",
"/customers/c1/floor",
"/customers/c1/create-config",
"/customers/c1/request-log-tail",
"/configs/new",
"/configuration/global-floor",
"/configuration/artifacts",
"/configuration/password",
"/configs/c1/delete",
"/configs/c1/edit",
"/configs/c1/offsite-reissue",
"/configs/c1/claim-resend",
"/configs/c1/pbsdr-reissue",
"/configs/c1/offsite-freeze",
"/configs/c1/regen-password",
"/configs/c1/reset",
"/offsite/remove-unpinned/c1",
"/offsite/abandon-cancel/c1",
"/offsite/window-grant/c1",
"/offsite/windows-enabled",
"/offsite/key-audit",
"/os/ring/h1",
"/os/enabled/h1",
"/os/approve-now",
"/os/approve-docker",
// Not a route: the gate must refuse BEFORE routing, so even an unknown path is 403, never 404.
"/no-such-route",
}
// r135Handler is the production wiring: RequireAuth around ServeHTTP (cmd/hub/main.go).
func r135Handler(t *testing.T) (*Server, http.Handler) {
t.Helper()
s, _ := serverWithPassword(t, "op-pass")
return s, s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
}
func r135Post(h http.Handler, path string, mut func(*http.Request)) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(url.Values{"x": {"1"}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mut(r)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// The measured shape of R-135: Basic credentials and no cookie — what a browser with cached Basic auth sends
// when another site makes it POST a form. Refused on every route.
func TestR135_BasicAuthWithoutHeaderIsRefused(t *testing.T) {
_, h := r135Handler(t)
for _, p := range r135PostRoutes {
w := r135Post(h, p, func(r *http.Request) {
r.SetBasicAuth("", "op-pass")
r.Header.Set("Origin", "https://evil.example")
})
if w.Code != http.StatusForbidden {
t.Errorf("POST %s with Basic auth and no %s header: %d, want 403", p, OperatorCLIHeader, w.Code)
}
}
}
// A browser session without its token: refused on every route (this half was already right; pinned here).
func TestR135_SessionWithoutTokenIsRefused(t *testing.T) {
s, h := r135Handler(t)
s.sessionsMu.Lock()
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
s.sessionsMu.Unlock()
for _, p := range r135PostRoutes {
w := r135Post(h, p, func(r *http.Request) { r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"}) })
if w.Code != http.StatusForbidden {
t.Errorf("POST %s with a session and no token: %d, want 403", p, w.Code)
}
w = r135Post(h, p, func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"})
r.Header.Set("X-CSRF-Token", "wrong")
})
if w.Code != http.StatusForbidden {
t.Errorf("POST %s with a session and a wrong token: %d, want 403", p, w.Code)
}
}
}
// The two ways that pass: they reach the handler (any answer but the gate's 403 body).
func TestR135_TheTwoAllowedShapesPassTheGate(t *testing.T) {
s, h := r135Handler(t)
s.sessionsMu.Lock()
s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"}
s.sessionsMu.Unlock()
gate := "CSRF token missing or invalid"
for _, p := range r135PostRoutes {
w := r135Post(h, p, func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "hub_session", Value: "sess1"})
r.Header.Set("X-CSRF-Token", "tok1")
})
if strings.Contains(w.Body.String(), gate) {
t.Errorf("POST %s with a session and its token was refused by the gate", p)
}
w = r135Post(h, p, func(r *http.Request) {
r.SetBasicAuth("", "op-pass")
r.Header.Set(OperatorCLIHeader, "cli")
})
if strings.Contains(w.Body.String(), gate) {
t.Errorf("POST %s with Basic auth and the %s header was refused by the gate", p, OperatorCLIHeader)
}
}
}
// The header alone proves nothing: without Basic credentials RequireAuth stops it before the gate.
func TestR135_HeaderWithoutCredentialsIsNotEnough(t *testing.T) {
_, h := r135Handler(t)
w := r135Post(h, "/configuration/global-floor", func(r *http.Request) { r.Header.Set(OperatorCLIHeader, "cli") })
if w.Code != http.StatusFound && w.Code != http.StatusUnauthorized {
t.Fatalf("header with no credentials: %d, want a redirect to /login or 401", w.Code)
}
}
// Reads are not gated: a GET with Basic auth and no header still works (the page renders or redirects).
func TestR135_GetIsNotGated(t *testing.T) {
_, h := r135Handler(t)
r := httptest.NewRequest(http.MethodGet, "/hosts", nil)
r.SetBasicAuth("", "op-pass")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code == http.StatusForbidden {
t.Fatalf("GET /hosts with Basic auth was refused by the CSRF gate")
}
}
@@ -0,0 +1,33 @@
package web
import (
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-508 (hub v0.135.0): a configured customer with no box and no e-mail is told on the page — one render test per
// branch of the gate (absent with an e-mail; absent once a box is bound; present when both are missing).
// RED-PROOF (audits/hub-safety-2026-10-05/partG/red-proof.txt): set WaitingNoEmail to false → the "present" case fails.
const noEmailMarker = "No registered e-mail, and no box yet"
func TestR508_NoEmailBannerBranches(t *testing.T) {
s, st := newTestServer(t)
seedCustomer(t, st, "with-mail", "")
if contains(renderCustomerPageWithQuery(t, s, "with-mail", ""), noEmailMarker) {
t.Fatal("banner shown for a customer WITH an e-mail")
}
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "no-mail", CustomerName: "x", Domain: "x.hu",
RetrievalPassword: "pw", APIKey: "k2", Status: "active"}); err != nil {
t.Fatal(err)
}
if !contains(renderCustomerPageWithQuery(t, s, "no-mail", ""), noEmailMarker) {
t.Fatal("no banner for a waiting customer with no e-mail")
}
if err := st.UpsertHost(&store.Host{HostID: "h-no-mail", CustomerID: "no-mail", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
if contains(renderCustomerPageWithQuery(t, s, "no-mail", ""), noEmailMarker) {
t.Fatal("banner shown for a customer whose box is already bound (nothing is waiting)")
}
}
@@ -0,0 +1,141 @@
package web
import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-604 (hub v0.135.0): a global floor raise says which boxes it did NOT move — one log line per customer whose
// own floor is LOWER, and ONE operator mail naming them. An override at or above the global is not named; a raise
// that moves everyone sends nothing.
// RED-PROOF (audits/hub-safety-2026-10-05/partD/red-proof.txt): remove the s.reportFloorHeldBack(v) call in
// handleSetGlobalFloor → TestR604_GlobalRaiseNamesHeldBackBoxes fails (no log line, no mail).
type emitted struct{ customer, typ, sev, msg string }
func r604Server(t *testing.T) (*Server, *store.Store, *bytes.Buffer, *[]emitted) {
t.Helper()
s, st := newTestServer(t)
var buf bytes.Buffer
s.logger = log.New(&buf, "", 0)
var got []emitted
s.SetEventEmitter(func(c, typ, sev, msg, _, _ string) { got = append(got, emitted{c, typ, sev, msg}) })
// vouch a golden ABOVE the floors used here, so a floor needs no declared MinAgent (R-472 is not under test)
if err := st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.400.0", AgentVersion: "0.145.0"}); err != nil {
t.Fatal(err)
}
for _, c := range []struct{ id, floor string }{{"c-low", "0.240.0"}, {"c-high", "0.300.0"}, {"c-none", ""}} {
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: c.id, CustomerName: c.id, RetrievalPassword: "x", APIKey: "k-" + c.id, ConfigJSON: "{}"}); err != nil {
t.Fatal(err)
}
if c.floor != "" {
if err := st.SetMinControllerVersion(c.id, c.floor); err != nil {
t.Fatal(err)
}
}
}
return s, st, &buf, &got
}
func setGlobal(t *testing.T, s *Server, v string) {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/configuration/global-floor", strings.NewReader(url.Values{"min_controller_version": {v}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=floor_set") {
t.Fatalf("global floor %s: %d %s", v, w.Code, w.Header().Get("Location"))
}
}
func TestR604_GlobalRaiseNamesHeldBackBoxes(t *testing.T) {
s, _, buf, got := r604Server(t)
setGlobal(t, s, "0.295.0")
logs := buf.String()
if !strings.Contains(logs, "does NOT move customer c-low: its own floor 0.240.0 wins") {
t.Fatalf("no log line for the held-back box:\n%s", logs)
}
if strings.Contains(logs, "customer c-high") || strings.Contains(logs, "customer c-none") {
t.Fatalf("a box that is NOT held back was named:\n%s", logs)
}
if len(*got) != 1 {
t.Fatalf("want exactly ONE operator mail, got %d: %+v", len(*got), *got)
}
e := (*got)[0]
if e.typ != "floor_raise_skipped" || e.sev != "warning" || e.customer != "" ||
!strings.Contains(e.msg, "c-low (own floor 0.240.0") || strings.Contains(e.msg, "c-high") {
t.Fatalf("the mail does not name exactly the held-back box: %+v", e)
}
if !strings.Contains(e.msg, "set 20") {
t.Fatalf("the mail must give the override's age (set time): %q", e.msg)
}
}
func TestR604_RaiseThatMovesEveryoneSendsNothing(t *testing.T) {
s, _, buf, got := r604Server(t)
setGlobal(t, s, "0.200.0") // below both overrides: nobody is held back by a LOWER own floor
if len(*got) != 0 || strings.Contains(buf.String(), "does NOT move") {
t.Fatalf("nothing held back, yet: mails %+v, log %q", *got, buf.String())
}
}
// The System page shows every per-customer floor with its age, and flags the one the global floor cannot move.
func TestR604_SystemPageListsFloorsWithAge(t *testing.T) {
s, st, _ := systemServer(t)
if err := st.SetArtifactManifest(store.ArtifactManifest{GoldenVersion: "0.400.0", AgentVersion: "0.145.0"}); err != nil {
t.Fatal(err)
}
if err := st.SetGlobalMinControllerVersion("0.295.0"); err != nil {
t.Fatal(err)
}
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c-full", CustomerName: "Full", RetrievalPassword: "x", APIKey: "k", ConfigJSON: "{}"}); err != nil {
t.Fatal(err)
}
if err := st.SetMinControllerVersion("c-full", "0.243.0"); err != nil {
t.Fatal(err)
}
b := getSystem(t, s)
for _, want := range []string{`id="version-floors"`, "Global controller floor: <strong>0.295.0", ">c-full<", "0.243.0",
time.Now().UTC().Format("2006-01-02"), "NO — its own floor is lower and wins"} {
if !strings.Contains(b, want) {
t.Errorf("System page lacks %q", want)
}
}
// the other branch of the gate: no override → the plain sentence
_ = st.SetMinControllerVersion("c-full", "")
if b := getSystem(t, s); !strings.Contains(b, "No per-customer floors") {
t.Error("no overrides: the page must say every box follows the global floor")
}
}
// R-530 on the page: the Agent cell shows box → vouched and is amber; red once the alarm's wait has passed.
func TestR530_SystemPageAgentCell(t *testing.T) {
s, st, svc := systemServer(t) // every box reports agent 0.142.0
if err := st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.145.0", AgentSHA256: "x"}); err != nil {
t.Fatal(err)
}
if _, err := svc.Alarms(); err != nil { // starts the behind-clock for every box
t.Fatal(err)
}
b := getSystem(t, s)
if !strings.Contains(b, "0.142.0 → 0.145.0") || !strings.Contains(b, `class="c-warn" title="3 minor releases behind`) {
t.Fatalf("the Agent cell does not show the box behind the vouched agent")
}
_ = st.SetAgentBehindSince("full-1", time.Now().Add(-8*24*time.Hour))
if b := getSystem(t, s); !strings.Contains(b, `class="c-bad" title="3 minor releases behind`) {
t.Fatal("past the alarm's wait the cell must be red")
}
// current branch
_ = st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.142.0", AgentSHA256: "x"})
if b := getSystem(t, s); strings.Contains(b, "→ 0.142.0") || !strings.Contains(b, `title="current (vouched 0.142.0)"`) {
t.Fatal("a current box must read current, not behind")
}
}
+29 -4
View File
@@ -100,6 +100,10 @@ type Server struct {
// the CONTROLLER plane (customer/app config) via the long-poll wait channel.
poke *poke.Notifier
// emit sends an operator event through the notification dispatcher (R-604, hub v0.135.0: the "a floor raise
// skipped boxes" mail). nil = log only. Wired in cmd/hub/main.go (SetEventEmitter).
emit func(customerID, eventType, severity, message, detailsJSON, source string)
sessions map[string]*hubSession
sessionsMu sync.RWMutex
@@ -372,6 +376,11 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
return out
}
// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY.
func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) {
s.emit = f
}
// ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
@@ -865,13 +874,29 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}
// validateCSRF checks the CSRF token for a session-based request.
// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path).
// OperatorCLIHeader is the header a programmatic (Basic-auth, cookie-less) operator request must carry
// to change state (R-135, hub v0.135.0). Any non-empty value; the docs and scripts send "cli".
//
// WHY A HEADER. Browsers cache HTTP Basic credentials per origin and resend them on cross-site
// requests, and SameSite does not govern the Authorization header — so "Basic auth and no cookie"
// does NOT prove the request is programmatic. A page on another site can make the browser POST a
// form with the operator's cached Basic credentials; it cannot add a custom header (that needs a
// CORS preflight, which the hub never answers). So the header is the proof the old check assumed.
// Decided by CC — operator may reverse (`05` §8.1). Pinned by r135_csrf_test.go.
const OperatorCLIHeader = "X-Felhom-Operator"
// validateCSRF checks a state-changing request (R-135). Two ways pass, nothing else:
// - a browser session: the hub_session cookie names a live session AND the form/header token matches it;
// - a programmatic operator call: NO session cookie, HTTP Basic credentials present (RequireAuth has
// already checked them) AND the OperatorCLIHeader is set.
//
// Before v0.135.0 a request with no session cookie passed unconditionally (measured live: a Basic-auth
// POST with no cookie reached the handler).
func (s *Server) validateCSRF(r *http.Request) bool {
cookie, err := r.Cookie("hub_session")
if err != nil {
// No session cookie — likely Basic Auth or programmatic access; skip CSRF
return true
_, _, basic := r.BasicAuth()
return basic && strings.TrimSpace(r.Header.Get(OperatorCLIHeader)) != ""
}
s.sessionsMu.RLock()
+70
View File
@@ -8,6 +8,8 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/osupdates"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts"
)
@@ -35,6 +37,7 @@ type systemRow struct {
Held, RebootSince, KernelPanic, Oops cell
CrashRestarts24h, Guard cell
Bundle cell // R-840: the root-owned config bundle
Agent cell // R-530: the box's agent against the vouched one
// guest
GuestDebian, GuestRelease, GuestPending, GuestRestart cell
// docker
@@ -51,9 +54,62 @@ type OSSystemView interface {
Candidates() []osupdates.Status
Thresholds() (stale, reboot, notCovered time.Duration)
BundleThreshold() time.Duration
AgentThreshold() time.Duration
ApproveDocker() (string, error)
}
// agentCell is the "Agent" cell (R-530, hub v0.135.0): the box's agent against the vouched one, how far behind and
// since when. Amber while behind; red from the alarm's wait on (the operator alarm fires then). An unreadable
// version is "unknown", never a guess; nothing vouched → the version alone.
func agentCell(boxAgent, vouched string, since time.Time, after time.Duration, now time.Time) cell {
if !semver.Valid(boxAgent) {
return unknownCell("")
}
c := cell{Text: boxAgent}
if !semver.Valid(vouched) {
c.Title = "no vouched agent to compare with"
return c
}
if semver.Compare(boxAgent, vouched) >= 0 {
c.Title = "current (vouched " + vouched + ")"
return c
}
c.Class = "warn"
c.Text = boxAgent + " → " + vouched
c.Title = osupdates.ReleasesBehind(boxAgent, vouched) + " — sign an agent_update for this box"
if !since.IsZero() {
c.Text += " (since " + since.UTC().Format("2006-01-02") + ")"
if now.Sub(since) >= after {
c.Class = "bad"
}
}
return c
}
// floorRow is one line of the System page's "Version floors" table (R-604).
type floorRow struct {
CustomerID, CustomerName, Version string
Age cell
HeldBack bool // the override is BELOW the global floor: the global does not move this box
}
func buildFloorRows(ovs []store.CustomerFloorOverride, global string, now time.Time) []floorRow {
var out []floorRow
for _, o := range ovs {
r := floorRow{CustomerID: o.CustomerID, CustomerName: o.CustomerName, Version: o.Version}
if o.SetAt.IsZero() {
r.Age = cell{Text: "unknown", Class: "warn", Title: "set before hub v0.135.0 — the hub did not record when"}
} else {
r.Age = plain(ago(o.SetAt, now) + " (" + o.SetAt.UTC().Format("2006-01-02") + ")")
}
if semver.Valid(global) && semver.Valid(o.Version) && semver.Compare(global, o.Version) > 0 {
r.HeldBack = true
}
out = append(out, r)
}
return out
}
func plain(s string) cell { return cell{Text: s} }
// bundleCell is the "Root files" cell (R-840): the box's config bundle against the vouched agent's. Amber while behind,
@@ -240,12 +296,26 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
stale, reboot, notCov := view.Thresholds()
rows := buildSystemRows(lines, facts, names, stale, reboot, notCov, time.Now())
man := s.store.GetArtifactManifest()
agents := map[string]string{}
for _, h := range hosts {
agents[h.HostID] = h.AgentVersion
}
for i := range rows {
rows[i].Bundle = bundleCell(facts[rows[i].HostID], man.AgentVersion, man.BundleSHA256,
s.store.BundleBehindSince(rows[i].HostID), view.BundleThreshold(), time.Now())
rows[i].Agent = agentCell(agents[rows[i].HostID], man.AgentVersion,
s.store.AgentBehindSince(rows[i].HostID), view.AgentThreshold(), time.Now())
}
global := s.store.GetGlobalMinControllerVersion()
ovs, oerr := s.store.CustomerFloorOverrides()
if oerr != nil {
s.logger.Printf("[ERROR] system page: floor overrides: %v", oerr)
}
data := map[string]interface{}{
"Rows": rows,
"GlobalFloor": global,
"VouchedAgent": man.AgentVersion,
"Floors": buildFloorRows(ovs, global, time.Now()),
"Releases": view.Releases(),
"Cancelled": view.CancelledReleases(),
"Candidates": view.Candidates(),
@@ -83,6 +83,14 @@
</div>
{{end}}
{{if .WaitingNoEmail}}
<div class="flash flash-error">
<strong>No registered e-mail, and no box yet</strong> — the connect link and the setup code cannot reach
this household. Set an address on the Edit tab before you send the install guide (R-508); the link then
goes out by itself.
</div>
{{end}}
{{if .OffsiteUnprovisioned}}
<div class="flash flash-warn">
<strong>Offsite is enabled but was never provisioned</strong> — no descriptor exists for
+23 -3
View File
@@ -77,18 +77,38 @@
</form>
</section>
<section class="card" id="version-floors">
<h3 style="margin-top: 0;">Version floors</h3>
<p>Global controller floor: <strong>{{if .GlobalFloor}}{{.GlobalFloor}}{{else}}none{{end}}</strong> · vouched agent: <strong>{{if .VouchedAgent}}{{.VouchedAgent}}{{else}}none{{end}}</strong></p>
{{if .Floors}}
<table class="data-table">
<thead><tr><th>Customer</th><th>Own floor</th><th>Set</th><th>Global floor moves it?</th></tr></thead>
<tbody>
{{range .Floors}}
<tr>
<td><a href="/customers/{{.CustomerID}}">{{.CustomerID}}</a>{{if .CustomerName}}<br><span class="text-muted">{{.CustomerName}}</span>{{end}}</td>
<td>{{.Version}}</td>
<td class="{{if .Age.Class}}c-{{.Age.Class}}{{end}}" title="{{.Age.Title}}">{{.Age.Text}}</td>
<td>{{if .HeldBack}}<span class="c-bad">NO — its own floor is lower and wins (R-604)</span>{{else}}no — its own floor applies (at or above the global){{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}<p class="text-muted">No per-customer floors: every box follows the global floor.</p>{{end}}
</section>
{{if .Rows}}
<section class="card" style="padding: 0; overflow-x: auto;">
<table class="data-table sys">
<thead>
<tr>
<th>Box</th><th>Ring / updates</th><th>Tunnel</th>
<th class="grp">Proxmox</th><th>Kernel (running)</th><th>Kernel (next boot)</th><th>Debian</th><th>Felhom release</th><th>Pending</th><th>Not covered</th><th>Held</th><th>Reboot needed</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th><th title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</th>
<th class="grp">Proxmox</th><th>Kernel (running)</th><th>Kernel (next boot)</th><th>Debian</th><th>Felhom release</th><th>Pending</th><th>Not covered</th><th>Held</th><th>Reboot needed</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th><th title="The root-owned config bundle: sudoers, wrappers, units (R-840)">Root files</th><th title="The box's agent against the vouched one (R-530). Agents update only by a per-box signed job.">Agent</th>
<th class="grp">Guest Debian</th><th>Felhom release</th><th>Pending</th><th>Restart needed</th>
<th class="grp">Docker</th><th>containerd</th><th>live-restore</th><th>Docker release</th>
<th class="grp">Last OS leg</th>
</tr>
<tr class="text-muted"><th></th><th></th><th></th><th class="grp" colspan="14">host</th><th class="grp" colspan="4">guest</th><th class="grp" colspan="4">Docker engine</th><th class="grp"></th></tr>
<tr class="text-muted"><th></th><th></th><th></th><th class="grp" colspan="15">host</th><th class="grp" colspan="4">guest</th><th class="grp" colspan="4">Docker engine</th><th class="grp"></th></tr>
</thead>
<tbody>
{{range .Rows}}
@@ -114,7 +134,7 @@
{{template "sys_cell" .KernelRunning}}{{template "sys_cell" .KernelNextBoot}}{{template "sys_cell" .HostDebian}}
{{template "sys_cell" .HostRelease}}{{template "sys_cell" .HostPending}}{{template "sys_cell" .HostNotCovered}}
{{template "sys_cell" .Held}}{{template "sys_cell" .RebootSince}}{{template "sys_cell" .KernelPanic}}{{template "sys_cell" .Oops}}
{{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}}
{{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}}{{template "sys_cell" .Agent}}
<td class="grp {{if .GuestDebian.Class}}c-{{.GuestDebian.Class}}{{end}}">{{.GuestDebian.Text}}</td>
{{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}}
<td class="grp {{if .Engine.Class}}c-{{.Engine.Class}}{{end}}">{{.Engine.Text}}</td>