Phase 6: schedules, blocklists, allowlists (editors + effective state)

- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-10-13 16:12:02 +11:00
parent 2f8e788fe6
commit 9af543c394
21 changed files with 2341 additions and 94 deletions

View File

@@ -35,6 +35,12 @@ var version = "dev"
// service repairs it from the database without a restart.
const republishInterval = 15 * time.Minute
// republishPoll is how often the loop re-evaluates the next schedule boundary.
// It bounds how late a transition can be after the schedule is edited, and it
// keeps the service from sleeping past a boundary that did not exist yet when
// the loop last looked.
const republishPoll = 30 * time.Second
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
@@ -104,11 +110,12 @@ func run() error {
Password: cfg.MQTTPassword,
CAFile: cfg.MQTTCAFile,
Actor: "device",
Location: cfg.ScheduleLocation,
Log: log,
Devices: st,
Source: st,
Reports: st,
OnConnected: func() { republish(ctx, log, link) },
OnConnected: func() { republish(ctx, log, link, st) },
})
if err != nil {
return err
@@ -116,7 +123,7 @@ func run() error {
pub = link
link.Start(ctx)
defer link.Stop()
go republishLoop(ctx, log, link)
go republishLoop(ctx, log, link, st, cfg.ScheduleLocation)
if !cfg.MQTTTLS() {
log.Warn("mqtt is running without TLS; the shared broker has no TLS listener yet (AGENTS.md §10 rule 6)",
"broker", cfg.MQTTBroker)
@@ -169,14 +176,20 @@ func run() error {
}
// republish pushes every device's retained config. It runs once per connect and
// then periodically, so the broker's view of the desired state is rebuilt from
// SQLite, the source of truth.
func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) {
// whenever the desired state may have changed, so the broker's view is rebuilt
// from SQLite, the source of truth. It first clears any manual lock that has
// expired, so the published state is never stale.
func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *store.Store) {
if link == nil {
return
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if n, err := st.ExpireLocks(ctx); err != nil {
log.Error("expire locks", "err", err)
} else if n > 0 {
log.Info("expired timed locks", "count", n)
}
n, err := link.PublishAll(ctx)
if err != nil {
log.Error("mqtt publish all", "published", n, "err", err)
@@ -185,19 +198,39 @@ func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) {
log.Info("mqtt retained config refreshed", "devices", n)
}
func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link) {
ticker := time.NewTicker(republishInterval)
defer ticker.Stop()
// republishLoop refreshes the retained config when a schedule boundary is
// crossed and otherwise on a long interval. It re-evaluates often instead of
// sleeping until one computed boundary, so a schedule edited after the loop
// started still turns on and off on time (FR-2) without a poll from the device.
func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *store.Store, loc *time.Location) {
if loc == nil {
loc = time.Local
}
lastEval := time.Now()
lastPublish := lastEval
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !link.Connected() {
continue
}
republish(ctx, log, link)
case <-time.After(republishPoll):
}
if !link.Connected() {
lastEval = time.Now()
continue
}
now := time.Now()
shouldPublish := !now.Before(lastPublish.Add(republishInterval))
// A boundary between the last evaluation and now has been crossed.
if boundary, err := st.NextScheduleBoundary(ctx, lastEval.In(loc)); err != nil {
log.Error("next schedule boundary", "err", err)
} else if boundary != nil && !boundary.After(now) {
shouldPublish = true
}
if shouldPublish {
republish(ctx, log, link, st)
lastPublish = time.Now()
}
lastEval = now
}
}