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

@@ -11,6 +11,9 @@ import (
"strconv"
"strings"
"time"
// Embed the IANA time zone database so CONTROL_TIMEZONE resolves even in a
// container without tzdata, such as the test image.
_ "time/tzdata"
)
// Config is the fully resolved runtime configuration.
@@ -37,6 +40,11 @@ type Config struct {
AppEnv string
// Version is injected at build time.
Version string
// ScheduleLocation is the timezone weekly schedule windows are evaluated in
// (CONTROL_TIMEZONE). Windows are wall-clock, so this must be the family's
// zone, not the container's default of UTC. It defaults to
// DefaultTimezone.
ScheduleLocation *time.Location
// MQTTEnabled turns the broker link on. When false the service runs
// HTTP-only and never touches the broker.
@@ -102,6 +110,12 @@ func Load() (*Config, error) {
}
cfg.LogLevel = lvl
loc, err := time.LoadLocation(env("CONTROL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("CONTROL_TIMEZONE: %w", err)
}
cfg.ScheduleLocation = loc
if err := cfg.validateMQTT(); err != nil {
return nil, err
}
@@ -114,6 +128,10 @@ func Load() (*Config, error) {
// to the host gateway.
const DefaultMQTTBroker = "tcp://host.docker.internal:1883"
// DefaultTimezone is the schedule timezone used when CONTROL_TIMEZONE is unset.
// It matches the lab host's configured zone; set CONTROL_TIMEZONE to override.
const DefaultTimezone = "Australia/Sydney"
// MQTTTLS reports whether the broker URL asks for TLS.
func (c *Config) MQTTTLS() bool {
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")

View File

@@ -37,6 +37,29 @@ func TestLoadDefaultsAndValidation(t *testing.T) {
if !cfg.CookieSecure {
t.Error("CookieSecure = false, want true by default")
}
if cfg.ScheduleLocation == nil || cfg.ScheduleLocation.String() != DefaultTimezone {
t.Errorf("ScheduleLocation = %v, want %s", cfg.ScheduleLocation, DefaultTimezone)
}
}
func TestScheduleTimezone(t *testing.T) {
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
t.Setenv("CONTROL_MQTT_INSECURE", "true")
t.Setenv("CONTROL_TIMEZONE", "UTC")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ScheduleLocation == nil || cfg.ScheduleLocation.String() != "UTC" {
t.Errorf("ScheduleLocation = %v, want UTC", cfg.ScheduleLocation)
}
t.Setenv("CONTROL_TIMEZONE", "Not/AZone")
if _, err := Load(); err == nil {
t.Error("expected an error for an unknown CONTROL_TIMEZONE")
}
}
func TestLoadRequiresSecretAndPassword(t *testing.T) {