Phase 1: Control Service skeleton (Go, SQLite, session login, Docker, Caddy)

- Go service with its own multi-stage Dockerfile and docker-compose.yml; runs
  as container webrestrict-control on port 8791.
- SQLite schema for the full AGENTS.md §7 data model via versioned migrations
  (PRAGMA user_version); audit log is append-only.
- Configuration from the process environment; the real env file is gitignored
  and a template is committed. No secrets in the repo.
- Separate session login (signed HMAC cookie + CSRF) for Sam.
- GET /health; HTMX + server-rendered dashboard styled with the Family Home Lab
  tokens.css (copied) and Inter fonts; dark mode; phone-friendly.
- Caddy vhost for webrestrict.lab.audasmedia.com.au applied on .35 (Let's
  Encrypt cert issued); route recorded in deploy/caddy/webrestrict.caddy.
- Unit tests for config, auth and store.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 14:11:38 +11:00
parent 5368225f23
commit c83c9df0a3
29 changed files with 2119 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
-- Migration 001 — Phase 1 foundation (AGENTS.md §7).
-- Tables may grow in later phases. Never hard-delete devices or history.
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
child TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('android', 'windows')),
topic TEXT NOT NULL UNIQUE,
online INTEGER NOT NULL DEFAULT 0,
last_seen TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
weekday INTEGER NOT NULL CHECK (weekday BETWEEN 0 AND 6), -- 0 = Monday
start_time TEXT NOT NULL, -- HH:MM
end_time TEXT NOT NULL, -- HH:MM
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id);
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY,
device_id INTEGER REFERENCES devices(id), -- NULL = applies to every device
kind TEXT NOT NULL CHECK (kind IN ('app', 'site')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_blocklist_device ON blocklist(device_id);
CREATE TABLE IF NOT EXISTS allowlist (
id INTEGER PRIMARY KEY,
device_id INTEGER REFERENCES devices(id), -- NULL = applies to every device
kind TEXT NOT NULL CHECK (kind IN ('app', 'site')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_allowlist_device ON allowlist(device_id);
-- Desired state per device. The Control Service is the only writer.
CREATE TABLE IF NOT EXISTS state (
device_id INTEGER PRIMARY KEY REFERENCES devices(id),
locked INTEGER NOT NULL DEFAULT 0,
until TEXT,
allow_json TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
app TEXT,
domain TEXT,
seconds INTEGER NOT NULL DEFAULT 0,
day TEXT NOT NULL, -- YYYY-MM-DD, local day
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_telemetry_device_day ON telemetry(device_id, day);
CREATE TABLE IF NOT EXISTS overrides (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
actor TEXT NOT NULL,
action TEXT NOT NULL,
until TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_overrides_device ON overrides(device_id);
-- Append-only audit log. Size-capped by a later phase, never edited in place.
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
device_id INTEGER REFERENCES devices(id),
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);

132
internal/store/store.go Normal file
View File

@@ -0,0 +1,132 @@
// Package store owns the SQLite database. It is the single source of truth for
// the Control Service (SYSTEM_MAP.md).
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps the database handle.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// modernc/sqlite serialises writes; one connection avoids lock churn.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
// DB exposes the handle for read-only queries in later phases.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies the database is reachable.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Migrate applies every embedded migration that has not run yet. It tracks the
// applied version with PRAGMA user_version, so reruns are safe.
func (s *Store) Migrate(ctx context.Context) error {
files, err := migrationFiles()
if err != nil {
return err
}
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := version; i < len(files); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", files[i].name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set user_version %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
}
}
return nil
}
type migration struct {
name string
sql string
}
func migrationFiles() ([]migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // 001_, 002_, ... apply in order
out := make([]migration, 0, len(names))
for _, n := range names {
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", n, err)
}
out = append(out, migration{name: n, sql: string(b)})
}
return out, nil
}
// Audit appends one row to the append-only audit log.
func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`,
actor, action, deviceID, detail,
)
if err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
// CountDevices returns the number of registered devices (0 in Phase 1).
func (s *Store) CountDevices(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices`).Scan(&n); err != nil {
return 0, fmt.Errorf("count devices: %w", err)
}
return n, nil
}

View File

@@ -0,0 +1,52 @@
package store
import (
"context"
"path/filepath"
"testing"
)
func TestMigrateAndFoundation(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "control.db")
st, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Migrate is idempotent.
if err := st.Migrate(ctx); err != nil {
t.Fatalf("second Migrate: %v", err)
}
n, err := st.CountDevices(ctx)
if err != nil {
t.Fatalf("CountDevices: %v", err)
}
if n != 0 {
t.Errorf("CountDevices = %d, want 0", n)
}
if err := st.Audit(ctx, "sam", "test", nil, "phase1"); err != nil {
t.Fatalf("Audit: %v", err)
}
// Every §7 table must exist after migration.
want := []string{
"devices", "schedules", "blocklist", "allowlist",
"state", "telemetry", "overrides", "audit",
}
for _, table := range want {
var name string
err := st.DB().QueryRowContext(ctx,
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name)
if err != nil {
t.Errorf("table %q missing: %v", table, err)
}
}
}