hub v0.131.0: the tunnel status is true (R-841: three states, tunnel_down after two not_running reports, unknown never alarms); OS updates per layer (guest/host separate approved sets, host candidate leaves out kernel/boot/firmware, host_release in the box block), the fleet view and four hourly operator alarms (11 §8 steps 3+4); red-proofs in audits/os-host-lane-2026-10-04
gates / gates (push) Successful in 30s
gates / gates (push) Successful in 30s
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:
@@ -689,7 +689,8 @@ type hostReportPayload struct {
|
||||
RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6
|
||||
PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B
|
||||
Cloudflared struct {
|
||||
Status string `json:"status"`
|
||||
Status string `json:"status"` // agent >= 0.141.0: running | not_running | unknown (older: active | inactive | …)
|
||||
Detail string `json:"detail,omitempty"`
|
||||
} `json:"cloudflared"`
|
||||
// DR recipe — the agent's storage/guest/PBS half (secret-free). RawMessage = stored verbatim,
|
||||
// ignore-unknown (forward-compat). Persisted to dr_recipe, assembled with the controller half.
|
||||
@@ -861,6 +862,8 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.checkTunnel(hostID, custID, rep.Cloudflared.Status, rep.Cloudflared.Detail)
|
||||
|
||||
for _, g := range rep.Guests {
|
||||
status := g.Status
|
||||
if status == "" {
|
||||
@@ -2835,3 +2838,39 @@ func (h *Handler) handleAssetFile(w http.ResponseWriter, r *http.Request, filena
|
||||
|
||||
h.assetsMgr.ServeFile(w, r, filename)
|
||||
}
|
||||
|
||||
// Tunnel alarm (R-841, hub v0.131.0). The agent (>= 0.141.0) reports the box's tunnel as running | not_running |
|
||||
// unknown, read from the cloudflared container's own readiness check. `not_running` in TWO reports in a row — more
|
||||
// than one report cycle — is an operator alarm (`tunnel_down`, error); the first `running` after that is
|
||||
// `tunnel_recovered`. `unknown` never alarms and breaks a run of not_running (R-96 rule 3: a probe that could not
|
||||
// ask is not evidence of down). Pinned by TestTunnelAlarm_*.
|
||||
const (
|
||||
eventTunnelDown = "tunnel_down"
|
||||
eventTunnelRecovered = "tunnel_recovered"
|
||||
)
|
||||
|
||||
func (h *Handler) checkTunnel(hostID, custID, status, detail string) {
|
||||
st, err := h.store.RecentCloudflaredStatuses(hostID, 3)
|
||||
if err != nil || len(st) < 2 {
|
||||
return
|
||||
}
|
||||
down := func(i int) bool { return i < len(st) && st[i] == "not_running" }
|
||||
var typ, sev, msg string
|
||||
switch {
|
||||
case down(0) && down(1) && !down(2):
|
||||
typ, sev = eventTunnelDown, "error"
|
||||
msg = fmt.Sprintf("The tunnel of %s is NOT running in two reports in a row (%s). The box is not reachable from outside its home.", hostID, detail)
|
||||
case st[0] == "running" && down(1) && down(2):
|
||||
typ, sev = eventTunnelRecovered, "info"
|
||||
msg = fmt.Sprintf("The tunnel of %s is running again.", hostID)
|
||||
default:
|
||||
return
|
||||
}
|
||||
h.logger.Printf("[WARN] host %s tunnel: %s (%s)", hostID, typ, detail)
|
||||
details, _ := json.Marshal(map[string]any{"host_id": hostID, "status": status, "detail": detail})
|
||||
if _, eerr := h.store.SaveEvent(custID, typ, sev, msg, string(details), "hub"); eerr != nil {
|
||||
h.logger.Printf("[WARN] %s event save FAILED for %s: %v", typ, hostID, eerr)
|
||||
} else if h.dispatcher != nil {
|
||||
go h.dispatcher.ProcessEvent(custID, typ, sev, msg, string(details), "hub")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ func TestOSUpdate_DesiredBlockMatchesTheGolden(t *testing.T) {
|
||||
svc := &osupdates.Service{Store: st, ApproveAfter: 0, NightsRequired: 0, Logger: log.New(os.Stderr, "", 0)}
|
||||
h.SetOSUpdateService(svc)
|
||||
rel := `[{"name":"libc6","version":"2.41-12+deb13u4","origin":"Debian"},{"name":"openssl","version":"3.5.7-1~deb13u3","origin":"Debian-Security"}]`
|
||||
if err := st.SaveOSRelease(storeRelease("os-20261004-120000", rel)); err != nil {
|
||||
if err := st.SaveOSRelease(storeRelease("os-guest-20261004-120000", "guest", rel)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hrel := `[{"name":"libssl3t64","version":"3.5.7-1~deb13u3","origin":"Debian-Security"}]`
|
||||
if err := st.SaveOSRelease(storeRelease("os-host-20261004-120000", "host", hrel)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := do(h, http.MethodGet, "/hosts/h1/desired-state", "HKEY1", "")
|
||||
@@ -66,18 +70,18 @@ func TestOSReport_SelfScoped(t *testing.T) {
|
||||
if rr := do(h, http.MethodPost, "/hosts/h1/os-report", "HKEY2", body); rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-host report → %d, want 403", rr.Code)
|
||||
}
|
||||
if r, _ := st.LatestOSReport("h1"); r != nil {
|
||||
if r, _ := st.LatestOSReport("h1", "guest"); r != nil {
|
||||
t.Fatal("a refused report was stored")
|
||||
}
|
||||
if rr := do(h, http.MethodPost, "/hosts/h1/os-report", "HKEY1", body); rr.Code != http.StatusOK {
|
||||
t.Fatalf("own report → %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if r, _ := st.LatestOSReport("h1"); r == nil || r.Outcome != "applied" {
|
||||
if r, _ := st.LatestOSReport("h1", "guest"); r == nil || r.Outcome != "applied" {
|
||||
t.Fatalf("report not stored: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func storeRelease(id, pkgs string) store.OSRelease {
|
||||
func storeRelease(id, layer, pkgs string) store.OSRelease {
|
||||
at, _ := time.Parse(time.RFC3339, "2026-10-04T12:00:00Z")
|
||||
return store.OSRelease{ID: id, Fingerprint: "fp", ApprovedAt: at, ApprovedBy: "auto", PackagesJSON: pkgs}
|
||||
return store.OSRelease{ID: id, Layer: layer, Fingerprint: "fp-" + layer, ApprovedAt: at, ApprovedBy: "auto", PackagesJSON: pkgs}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
"ring": 1,
|
||||
"enabled": true,
|
||||
"release": {
|
||||
"id": "os-20261004-120000",
|
||||
"id": "os-guest-20261004-120000",
|
||||
"snapshot": "20261004T120000Z",
|
||||
"packages": [
|
||||
{"name": "libc6", "version": "2.41-12+deb13u4", "origin": "Debian"},
|
||||
{"name": "openssl", "version": "3.5.7-1~deb13u3", "origin": "Debian-Security"}
|
||||
]
|
||||
},
|
||||
"host_release": {
|
||||
"id": "os-host-20261004-120000",
|
||||
"snapshot": "20261004T120000Z",
|
||||
"packages": [
|
||||
{"name": "libssl3t64", "version": "3.5.7-1~deb13u3", "origin": "Debian-Security"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
func tunnelReport(status, detail string) string {
|
||||
return strings.Replace(validReportBody("h1"), `"cloudflared":{"status":"active"}`,
|
||||
`"cloudflared":{"status":"`+status+`","detail":"`+detail+`"}`, 1)
|
||||
}
|
||||
|
||||
// R-841: the tunnel alarm fires when the tunnel is not running in TWO reports in a row; running never alarms;
|
||||
// one bad report never alarms; `unknown` never alarms and breaks a run; recovery is announced once.
|
||||
// Red-proof: make checkTunnel alarm on ONE not_running (drop down(1)) and "a single bad report" fails.
|
||||
func TestTunnelAlarm_Sequences(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seq []string
|
||||
down int
|
||||
recov int
|
||||
}{
|
||||
{"running never alarms", []string{"running", "running", "running"}, 0, 0},
|
||||
{"a single bad report does not alarm", []string{"running", "not_running", "running"}, 0, 0},
|
||||
{"two in a row alarm once", []string{"running", "not_running", "not_running", "not_running"}, 1, 0},
|
||||
{"unknown never alarms and breaks the run", []string{"not_running", "unknown", "not_running", "unknown"}, 0, 0},
|
||||
{"recovery after an alarm", []string{"not_running", "not_running", "running"}, 1, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
for _, s := range c.seq {
|
||||
if rr := do(h, http.MethodPost, "/host-report", "HKEY", tunnelReport(s, "exited, exit code 1")); rr.Code != 200 {
|
||||
t.Fatalf("report: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
since := time.Now().Add(-time.Hour)
|
||||
d, _ := st.GetEventsByType("c1", eventTunnelDown, since)
|
||||
r, _ := st.GetEventsByType("c1", eventTunnelRecovered, since)
|
||||
if len(d) != c.down || len(r) != c.recov {
|
||||
t.Fatalf("tunnel_down=%d tunnel_recovered=%d, want %d / %d", len(d), len(r), c.down, c.recov)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user