diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 9bfa22a3..0e516951 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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; diff --git a/hub/internal/api/deliberate_off.go b/hub/internal/api/deliberate_off.go index e5e9843d..1a0a69b1 100644 --- a/hub/internal/api/deliberate_off.go +++ b/hub/internal/api/deliberate_off.go @@ -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) diff --git a/hub/internal/api/deliberate_off_test.go b/hub/internal/api/deliberate_off_test.go index ffad736e..d1e38a56 100644 --- a/hub/internal/api/deliberate_off_test.go +++ b/hub/internal/api/deliberate_off_test.go @@ -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) + } +} diff --git a/hub/internal/monitor/deliberate_off_test.go b/hub/internal/monitor/deliberate_off_test.go index 5e5aeb35..99cbf8b7 100644 --- a/hub/internal/monitor/deliberate_off_test.go +++ b/hub/internal/monitor/deliberate_off_test.go @@ -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) + } +} diff --git a/hub/internal/store/deliberateoff.go b/hub/internal/store/deliberateoff.go index 8906fc2e..ebc39214 100644 --- a/hub/internal/store/deliberateoff.go +++ b/hub/internal/store/deliberateoff.go @@ -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 }