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:
89
internal/store/migrations/001_init.sql
Normal file
89
internal/store/migrations/001_init.sql
Normal 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
132
internal/store/store.go
Normal 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
|
||||
}
|
||||
52
internal/store/store_test.go
Normal file
52
internal/store/store_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user