Files
felhom.eu/hub/internal/store/deliberateoff.go
T
admin a6acb0f762
gates / gates (push) Successful in 6m27s
Hub v0.149.0: clamp a future deliberate-off time; hold a customer only when all its hosts are off (R-998)
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.
2026-10-13 19:52:12 +02:00

95 lines
3.6 KiB
Go

package store
import (
"database/sql"
"time"
)
// ── The household's deliberate power-off (R-998, documentation/architecture/14-host-power-off.md) ──────
//
// The agent reports `deliberate_off.requested_at` from the moment the household pressed „A gép kikapcsolása"
// until the box goes down; the next boot's reports do not carry it. The hub keeps the time on the host row.
// While it is set and younger than DeliberateOffHold, every silence alarm about that box is held: host and
// controller staleness, the backup deadlines, off-site and restore-test freshness, the OS alarms and the
// kernel notice. After the hold, the alarms fire as usual — a box nobody switched back on in two weeks is
// worth a look. Pinned by monitor/deliberate_off_test.go, api/deliberate_off_test.go and web/deliberate_off_view_test.go.
// DeliberateOffHold is how long a deliberate power-off silences the box's alarms (operator default, 14 days).
const DeliberateOffHold = 14 * 24 * time.Hour
// DeliberateOffHeld reports whether a deliberate-off time still holds the alarms at now.
func DeliberateOffHeld(at *time.Time, now time.Time) bool {
return at != nil && !at.IsZero() && now.Sub(*at) < DeliberateOffHold
}
func parseDeliberateOff(v string) *time.Time {
if v == "" {
return nil
}
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil
}
return &t
}
// SetHostDeliberateOff records the report's deliberate-off field. at == nil clears it (a report without the
// field: the box is in a new boot). It returns the previous value, so the caller can emit an event on a change.
func (s *Store) SetHostDeliberateOff(hostID string, at *time.Time) (prev *time.Time, err error) {
var old sql.NullString
if err := s.db.QueryRow(`SELECT deliberate_off_at FROM hosts WHERE host_id = ?`, hostID).Scan(&old); err != nil {
return nil, err
}
prev = parseDeliberateOff(old.String)
var val any
if at != nil {
val = at.UTC().Format(time.RFC3339)
}
if (prev == nil && at == nil) || (prev != nil && at != nil && prev.Equal(*at)) {
return prev, nil
}
_, err = s.db.Exec(`UPDATE hosts SET deliberate_off_at = ?, updated_at = datetime('now') WHERE host_id = ?`, val, hostID)
return prev, err
}
// HostDeliberateOffAt returns the host's deliberate-off time (nil = none).
func (s *Store) HostDeliberateOffAt(hostID string) *time.Time {
var v sql.NullString
if err := s.db.QueryRow(`SELECT deliberate_off_at FROM hosts WHERE host_id = ?`, hostID).Scan(&v); err != nil {
return nil
}
return parseDeliberateOff(v.String)
}
// 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 COALESCE(deliberate_off_at, '') FROM hosts WHERE customer_id = ?`, customerID)
if err != nil {
return time.Time{}, false
}
defer rows.Close()
var newest time.Time
n := 0
for rows.Next() {
var v string
if rows.Scan(&v) != nil {
return time.Time{}, false
}
n++
t := parseDeliberateOff(v)
if !DeliberateOffHeld(t, now) {
return time.Time{}, false
}
if t.After(newest) {
newest = *t
}
}
if rows.Err() != nil || n == 0 {
return time.Time{}, false
}
return newest, true
}