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
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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user