Hub v0.149.0: clamp a future deliberate-off time; hold a customer only when all its hosts are off (R-998)
gates / gates (push) Successful in 6m27s

Security review of 50e47e29: requested_at is the box's clock — a future value is cut to the hub's
now; CustomerDeliberatelyOff now needs every host of the customer held (one machine of two no
longer silences the other), and fails closed on a read error or no host. Both red-proofed.
This commit is contained in:
2026-10-13 19:52:12 +02:00
parent 50e47e29af
commit a6acb0f762
5 changed files with 64 additions and 14 deletions
+4
View File
@@ -13,6 +13,10 @@ Needs **agent ≥ 0.155.0** to see anything: the agent's new `deliberate_off.req
(`os_update_stale`, `agent_behind`, `os_config_bundle_behind`, `os_reboot_needed`, `os_not_covered`, `os_ring0_stalled`)
and the household's kernel notice mail. **After the hold**, still silent: the usual `host_down` + `node_down`, once,
with a text naming the household power-off. Back on inside the hold: no `*_recovered` mail.
- **Security review (same day):** the time is the BOX's clock, so a future `requested_at` is cut to the hub's now (it
could otherwise stretch the hold past 14 days); and a customer is held only when EVERY one of its hosts is held —
one switched-off machine no longer silences the customer's other machine. A read error or a customer with no host is
not held (the alarm fires).
- **Pages:** hosts list, host detail and the customer/dashboard roll-up show **KIKAPCSOLVA** (grey) and „Kikapcsolva a
háztartás által, T — riasztás szünetel eddig: T+14 nap" instead of DOWN. Delete logic still reads the raw status.
- Tests (consequence, each with a control that must alarm): `monitor/deliberate_off_test.go` (no alarm in the hold;
+6
View File
@@ -24,6 +24,12 @@ func (h *Handler) checkDeliberateOff(hostID, custID string, rep *hostReportPaylo
return
}
t = t.UTC()
// The time is the BOX's clock (security review 2026-10-13): a future value would stretch the hold past 14
// days, so it is cut to the hub's own now. An earlier value only shortens the hold — the safe direction.
if now := time.Now().UTC().Truncate(time.Second); t.After(now) {
h.logger.Printf("[WARN] host-report from %s: deliberate_off.requested_at %s is in the future — using the hub's time", hostID, t.Format(time.RFC3339))
t = now
}
at = &t
}
prev, err := h.store.SetHostDeliberateOff(hostID, at)
+19 -3
View File
@@ -15,7 +15,8 @@ func TestHostReport_DeliberateOffSetThenCleared(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"})
withOff := strings.TrimSuffix(validReportBody("h1"), "}") + `,"deliberate_off":{"requested_at":"2026-10-13T18:00:00Z"}}`
when := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
withOff := strings.TrimSuffix(validReportBody("h1"), "}") + `,"deliberate_off":{"requested_at":"` + when.Format(time.RFC3339) + `"}}`
for i := 0; i < 2; i++ { // the field rides every report until the box is down; the second must not re-record
if rr := do(h, http.MethodPost, "/host-report", "HKEY", withOff); rr.Code != 200 {
@@ -23,8 +24,8 @@ func TestHostReport_DeliberateOffSetThenCleared(t *testing.T) {
}
}
at := st.HostDeliberateOffAt("h1")
if at == nil || !at.Equal(time.Date(2026, 10, 13, 18, 0, 0, 0, time.UTC)) {
t.Fatalf("hold = %v, want 2026-10-13 18:00 UTC", at)
if at == nil || !at.Equal(when) {
t.Fatalf("hold = %v, want %v", at, when)
}
evs, _ := st.GetEventsByType("c1", eventHostDeliberateOff, time.Now().Add(-time.Hour))
if len(evs) != 1 || evs[0].Severity != "info" {
@@ -42,3 +43,18 @@ func TestHostReport_DeliberateOffSetThenCleared(t *testing.T) {
t.Fatalf("ended events = %d, want 1", len(ended))
}
}
// A future requested_at (the box's clock) must not stretch the hold: it is cut to the hub's now.
func TestHostReport_DeliberateOffFutureTimeClamped(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"})
body := strings.TrimSuffix(validReportBody("h1"), "}") + `,"deliberate_off":{"requested_at":"2099-01-01T00:00:00Z"}}`
if rr := do(h, http.MethodPost, "/host-report", "HKEY", body); rr.Code != 200 {
t.Fatalf("report: %d", rr.Code)
}
at := st.HostDeliberateOffAt("h1")
if at == nil || at.After(time.Now().Add(time.Minute)) {
t.Fatalf("hold anchored at %v — a future box time must be cut to the hub's now", at)
}
}
@@ -168,3 +168,20 @@ func TestDeliberateOff_DeadlineSkips(t *testing.T) {
t.Fatalf("a switched-off box got deadline alarms: %v", got)
}
}
// One switched-off machine must not hold the alarms of the customer's OTHER machine (security review 2026-10-13).
func TestDeliberateOff_OneOfTwoHostsDoesNotHoldTheCustomer(t *testing.T) {
r := newOffRig(t)
if err := r.st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c1", APIKey: "k2"}); err != nil {
t.Fatal(err)
}
r.offAt(t, 3*time.Hour) // only h1 is switched off
if _, held := r.st.CustomerDeliberatelyOff("c1", time.Now()); held {
t.Fatal("the customer is held although its second machine was never switched off")
}
r.silent(t, 3*time.Hour)
r.node.Check()
if !hasEvent(r.events, "node_down") {
t.Fatalf("the customer-level alarm was held by one machine of two: %v", r.events)
}
}
+18 -11
View File
@@ -61,27 +61,34 @@ func (s *Store) HostDeliberateOffAt(hostID string) *time.Time {
return parseDeliberateOff(v.String)
}
// CustomerDeliberatelyOff reports whether any of the customer's hosts is deliberately off and still within the
// hold at now — the checkers keyed by customer use it. The time is the newest such request.
// CustomerDeliberatelyOff reports whether the customer is switched off by the household and still within the hold
// at now — the checkers keyed by customer use it. EVERY host of the customer must be held (security review
// 2026-10-13): one switched-off machine must not silence the alarms of the customer's other machine. A read error
// or a customer with no host is NOT held (fail closed: the alarm fires). The time is the newest request.
func (s *Store) CustomerDeliberatelyOff(customerID string, now time.Time) (time.Time, bool) {
rows, err := s.db.Query(`SELECT deliberate_off_at FROM hosts WHERE customer_id = ? AND deliberate_off_at IS NOT NULL`, customerID)
rows, err := s.db.Query(`SELECT COALESCE(deliberate_off_at, '') FROM hosts WHERE customer_id = ?`, customerID)
if err != nil {
return time.Time{}, false
}
defer rows.Close()
var newest time.Time
held := false
n := 0
for rows.Next() {
var v string
if rows.Scan(&v) != nil {
continue
return time.Time{}, false
}
if t := parseDeliberateOff(v); DeliberateOffHeld(t, now) {
held = true
if t.After(newest) {
newest = *t
}
n++
t := parseDeliberateOff(v)
if !DeliberateOffHeld(t, now) {
return time.Time{}, false
}
if t.After(newest) {
newest = *t
}
}
return newest, held
if rows.Err() != nil || n == 0 {
return time.Time{}, false
}
return newest, true
}