Phase 8: overrides (shared keycode + versioned admin lock/unlock/grant API)

Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11:
the shared device keycode and the versioned admin HTTP API the separate Kotlin
admin app will call.

- store: migration 004 adds state.grant_until and a settings table. New
  overrides.go records every override in `overrides` and verifies the shared
  keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also
  clears an expired grant, and NextScheduleBoundary includes the grant end so a
  device re-locks on time.
- effective state: a grant unlocks a device inside a schedule window; a manual
  lock clears a grant and a grant clears a manual lock. The retained config
  payload gains grant_until (additive, v stays 1).
- web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by
  CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device
  keycode endpoint POST /api/v1/device/override with per-address throttling.
  A state change is published retained before the API responds.
- cmd/control: `keycode` subcommand and start-up seeding from
  CONTROL_DEVICE_KEYCODE_HASH.
- deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done:
  an API lock reaches a real broker subscriber in well under a second.
- Version 0.6.0-phase8 (compose, Makefile, docs).

Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler
1 ms, request to retained publish accepted).

Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 16:27:46 +11:00
parent b58993d994
commit 4f233a8012
19 changed files with 1833 additions and 67 deletions

View File

@@ -30,6 +30,15 @@ type Config struct {
AdminPassword string
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
AdminPasswordHash string
// AdminAPIToken authenticates the versioned admin API (Phase 8). It is
// separate from the session login and comes from environment.d. When it is
// empty the admin API answers 503 and nothing else changes.
AdminAPIToken string
// DeviceKeycodeHash is a bcrypt hash of the shared device keycode (Phase 8).
// It is optional: the code is seeded into SQLite at start-up and can be
// changed at runtime with `control keycode`. It is never stored in plaintext
// and never logged.
DeviceKeycodeHash string
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
CookieSecure bool
// SessionTTL is how long a session cookie stays valid.
@@ -74,6 +83,8 @@ func Load() (*Config, error) {
AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"),
AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"),
AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"),
AdminAPIToken: os.Getenv("CONTROL_ADMIN_API_TOKEN"),
DeviceKeycodeHash: os.Getenv("CONTROL_DEVICE_KEYCODE_HASH"),
CookieSecure: envBool("CONTROL_COOKIE_SECURE", true),
AppEnv: env("CONTROL_APP_ENV", "production"),
MQTTEnabled: envBool("CONTROL_MQTT_ENABLED", true),

View File

@@ -356,6 +356,7 @@ func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error
Allow: eff.Allow,
Block: eff.Block,
Reason: eff.Reason,
GrantUntil: eff.GrantUntil,
GeneratedAt: time.Now().UTC(),
}.Marshal()
}

View File

@@ -86,9 +86,15 @@ type Config struct {
Allow []string `json:"allow"`
Block []string `json:"block"`
// Reason is why the device is locked: "schedule" for an active weekly
// window, "manual" for a lock from the dashboard. It is omitted when the
// device is not locked. It is additive (Phase 6), so v stays 1.
Reason string `json:"reason,omitempty"`
// window, "manual" for a lock from the dashboard or the admin API. It is
// omitted when the device is not locked. It is additive (Phase 6), so v
// stays 1.
Reason string `json:"reason,omitempty"`
// GrantUntil is set when a time-limited exemption is active (Phase 8): the
// device is unlocked and must not re-lock before this instant. It is omitted
// when there is no grant. Additive, so v stays 1.
GrantUntil *time.Time `json:"grant_until,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}

View File

@@ -41,9 +41,13 @@ type Device struct {
UpdatedAt time.Time
// Desired state, from the state table.
Locked bool
Until *time.Time
StateAt time.Time
Locked bool
Until *time.Time
// GrantUntil is a time-limited exemption (Phase 8): while it is in the
// future the device is unlocked even during a schedule window. It is nil
// when no grant is active or one has been cleared.
GrantUntil *time.Time
StateAt time.Time
}
// AuditEntry is one row of the append-only audit log (FR-15).
@@ -61,7 +65,7 @@ type AuditEntry struct {
const selectDevice = `
SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen,
d.created_at, d.updated_at,
COALESCE(s.locked, 0), s.until, s.updated_at
COALESCE(s.locked, 0), s.until, s.grant_until, s.updated_at
FROM devices d
LEFT JOIN state s ON s.device_id = d.id`
@@ -69,21 +73,22 @@ func scanDevice(row interface {
Scan(dest ...any) error
}) (*Device, error) {
var (
dev Device
online int
lastSeen, until, stateAt sql.NullString
createdAt, updatedAt sql.NullString
dev Device
online int
lastSeen, until, grantUntil sql.NullString
createdAt, updatedAt, stateAt sql.NullString
)
if err := row.Scan(
&dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online,
&lastSeen, &createdAt, &updatedAt,
&dev.Locked, &until, &stateAt,
&dev.Locked, &until, &grantUntil, &stateAt,
); err != nil {
return nil, err
}
dev.Online = online != 0
dev.LastSeen = parseTimePtr(lastSeen)
dev.Until = parseTimePtr(until)
dev.GrantUntil = parseTimePtr(grantUntil)
if t := parseTime(createdAt.String); !t.IsZero() {
dev.CreatedAt = t
}
@@ -268,11 +273,12 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO state (device_id, locked, until, updated_at)
VALUES (?, ?, ?, datetime('now'))
INSERT INTO state (device_id, locked, until, grant_until, updated_at)
VALUES (?, ?, ?, NULL, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET
locked = excluded.locked,
until = excluded.until,
grant_until = NULL,
updated_at = datetime('now')`,
id, boolToInt(locked), untilArg); err != nil {
return nil, fmt.Errorf("set state: %w", err)
@@ -280,15 +286,18 @@ func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until
if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil {
return nil, err
}
if err := insertOverride(ctx, tx, id, actor, action, until); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit state: %w", err)
}
return s.GetDevice(ctx, id)
}
// ExpireLocks unlocks every device whose timed lock has passed and appends one
// audit entry per device. It returns how many locks it cleared. It is safe to
// call often.
// ExpireLocks unlocks every device whose timed lock has passed and clears every
// expired grant, appending one audit entry per device whose lock lifted. It
// returns how many locks it cleared. It is safe to call often.
func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
now := time.Now().UTC().Format(time.RFC3339)
rows, err := s.db.QueryContext(ctx,
@@ -316,6 +325,14 @@ func (s *Store) ExpireLocks(ctx context.Context) (int, error) {
return 0, err
}
}
// A grant is silent: it changes no audit row and needs no device callback,
// because EffectiveState already ignores a past grant. Clearing it keeps the
// stored state tidy and the API response honest.
if _, err := s.db.ExecContext(ctx, `
UPDATE state SET grant_until = NULL, updated_at = datetime('now')
WHERE grant_until IS NOT NULL AND grant_until <= ?`, now); err != nil {
return len(ids), fmt.Errorf("expire grants: %w", err)
}
return len(ids), nil
}

View File

@@ -0,0 +1,20 @@
-- Migration 004 — Phase 8. Overrides (AGENTS.md §7, §11 Phase 8).
--
-- The shared device keycode lives in `settings` as a bcrypt hash under the key
-- 'device_keycode_hash'. It is never stored or logged in plaintext. The
-- key/value table also gives later phases a place for small settings without a
-- new migration.
--
-- grant_until is a time-limited exemption: while it is in the future the device
-- is unlocked even when a weekly window would otherwise block it. It is a
-- separate column from the manual lock's `until` so "grant 30 minutes" and
-- "lock until I unlock" cannot fight over the same value. A manual lock clears
-- a grant; a grant clears a manual lock.
ALTER TABLE state ADD COLUMN grant_until TEXT;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

233
internal/store/overrides.go Normal file
View File

@@ -0,0 +1,233 @@
// Overrides and the shared device keycode (AGENTS.md §4 FR-10, §7 overrides,
// §10 rule 3, §11 Phase 8).
//
// An override is a deliberate change to a device's desired state: a lock, an
// unlock, or a grant of time. Every override is written to the `overrides`
// table and, through the caller, to the append-only `audit` log (FR-15). The
// state table holds only the current value; `overrides` is the history.
//
// The device keycode is a single code shared by every device (§10 rule 3). It
// is stored only as a bcrypt hash in `settings`, is seeded from the environment
// at start-up, and can be changed with `control keycode` (or, later, from the
// dashboard).
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// keycodeSettingKey is the settings row that holds the shared device keycode.
const keycodeSettingKey = "device_keycode_hash"
// ErrNoKeycode is returned when no device keycode has been set.
var ErrNoKeycode = errors.New("device keycode is not set")
// Override is one row of the override history.
type Override struct {
ID int64
DeviceID int64
Actor string
Action string
Until *time.Time
CreatedAt time.Time
}
// insertOverride appends one override row using the given executor, so it can
// be written atomically with the state change it records.
func insertOverride(ctx context.Context, ex interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, deviceID int64, actor, action string, until *time.Time) error {
var untilArg any
if until != nil {
untilArg = until.UTC().Format(time.RFC3339)
}
if _, err := ex.ExecContext(ctx,
`INSERT INTO overrides (device_id, actor, action, until) VALUES (?, ?, ?, ?)`,
deviceID, actor, action, untilArg); err != nil {
return fmt.Errorf("override insert: %w", err)
}
return nil
}
// ListOverrides returns a device's override history, newest first.
func (s *Store) ListOverrides(ctx context.Context, deviceID int64, limit int) ([]Override, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, device_id, actor, action, until, created_at
FROM overrides WHERE device_id = ?
ORDER BY id DESC LIMIT ?`, deviceID, limit)
if err != nil {
return nil, fmt.Errorf("list overrides: %w", err)
}
defer rows.Close()
out := make([]Override, 0, limit)
for rows.Next() {
var (
o Override
until sql.NullString
created sql.NullString
)
if err := rows.Scan(&o.ID, &o.DeviceID, &o.Actor, &o.Action, &until, &created); err != nil {
return nil, fmt.Errorf("scan override: %w", err)
}
o.Until = parseTimePtr(until)
o.CreatedAt = parseTime(created.String)
out = append(out, o)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list overrides: %w", err)
}
return out, nil
}
// SetDeviceGrant gives a device a time-limited exemption: it is unlocked until
// `until`, even if a weekly window is active. It clears any manual lock, writes
// one override row and one audit row in the same transaction, and returns the
// fresh device. `until` must be in the future.
func (s *Store) SetDeviceGrant(ctx context.Context, id int64, until time.Time, actor, action string) (*Device, error) {
if until.IsZero() {
return nil, errors.New("grant needs an end time")
}
until = until.UTC()
if !until.After(time.Now()) {
return nil, errors.New("grant end time must be in the future")
}
if _, err := s.GetDevice(ctx, id); err != nil {
return nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("set grant: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
INSERT INTO state (device_id, locked, until, grant_until, updated_at)
VALUES (?, 0, NULL, ?, datetime('now'))
ON CONFLICT(device_id) DO UPDATE SET
locked = 0,
until = NULL,
grant_until = excluded.grant_until,
updated_at = datetime('now')`,
id, until.Format(time.RFC3339)); err != nil {
return nil, fmt.Errorf("set grant: %w", err)
}
if err := insertAudit(ctx, tx, actor, action, &id,
"granted time until "+until.Format(time.RFC3339)); err != nil {
return nil, err
}
if err := insertOverride(ctx, tx, id, actor, action, &until); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit grant: %w", err)
}
return s.GetDevice(ctx, id)
}
// ---------- device keycode ----------
// SetKeycodeHash stores the bcrypt hash of the shared device keycode and
// appends one audit row. The hash is validated first, so a corrupt value never
// replaces a working code.
func (s *Store) SetKeycodeHash(ctx context.Context, hash, actor string) error {
if _, err := bcrypt.Cost([]byte(hash)); err != nil {
return fmt.Errorf("device keycode hash is not a valid bcrypt hash: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("set keycode: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
keycodeSettingKey, hash); err != nil {
return fmt.Errorf("set keycode: %w", err)
}
if err := insertAudit(ctx, tx, actor, "keycode.updated", nil, "shared device keycode changed"); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit keycode: %w", err)
}
return nil
}
// KeycodeHash returns the stored bcrypt hash, or ErrNoKeycode.
func (s *Store) KeycodeHash(ctx context.Context) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, keycodeSettingKey).Scan(&value)
if errors.Is(err, sql.ErrNoRows) || value == "" {
return "", ErrNoKeycode
}
if err != nil {
return "", fmt.Errorf("keycode hash: %w", err)
}
return value, nil
}
// VerifyKeycode reports whether code matches the stored shared keycode. A
// missing or invalid stored hash never matches, so a broken settings row cannot
// unlock a device by accident.
func (s *Store) VerifyKeycode(ctx context.Context, code string) bool {
code = strings.TrimSpace(code)
if code == "" {
return false
}
hash, err := s.KeycodeHash(ctx)
if err != nil {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(code)) == nil
}
// SeedKeycodeHash stores hash only when no code is stored yet. The Control
// Service calls it at start-up so CONTROL_DEVICE_KEYCODE_HASH in environment.d
// is the first-run source of the code. Once a code exists, the settings row is
// authoritative: a restart never overwrites a code changed with `control
// keycode`, so a runtime change is durable. It returns whether it stored a code.
func (s *Store) SeedKeycodeHash(ctx context.Context, hash string) (bool, error) {
hash = strings.TrimSpace(hash)
if hash == "" {
return false, nil
}
if _, err := bcrypt.Cost([]byte(hash)); err != nil {
return false, fmt.Errorf("CONTROL_DEVICE_KEYCODE_HASH is not a valid bcrypt hash: %w", err)
}
if _, err := s.KeycodeHash(ctx); err == nil {
return false, nil // already set; never overwrite at start-up
} else if !errors.Is(err, ErrNoKeycode) {
return false, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, fmt.Errorf("seed keycode: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
keycodeSettingKey, hash); err != nil {
return false, fmt.Errorf("seed keycode: %w", err)
}
if err := insertAudit(ctx, tx, "system", "keycode.updated", nil, "seeded from CONTROL_DEVICE_KEYCODE_HASH"); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, fmt.Errorf("commit seeded keycode: %w", err)
}
return true, nil
}

View File

@@ -0,0 +1,213 @@
package store
import (
"context"
"errors"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// TestGrantOverridesScheduleAndReLocks is the Phase 8 grant contract: a grant
// unlocks a device inside an active schedule window, and the window re-applies
// the moment the grant ends.
func TestGrantOverridesScheduleAndReLocks(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) // Tuesday
if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{
{Weekday: weekdayMon0(now), Start: "19:00", End: "21:00"},
}, "sam"); err != nil {
t.Fatalf("ReplaceSchedules: %v", err)
}
// Before the grant the window blocks the device.
eff, err := st.EffectiveState(ctx, *dev, now)
if err != nil {
t.Fatalf("EffectiveState: %v", err)
}
if !eff.Locked || eff.Reason != "schedule" {
t.Fatalf("before grant: %+v, want locked by schedule", eff)
}
// Grant time: the device is unlocked even though the window is active.
grantUntil := now.Add(15 * time.Minute)
granted, err := st.SetDeviceGrant(ctx, dev.ID, grantUntil, "admin-api", "override.granted")
if err != nil {
t.Fatalf("SetDeviceGrant: %v", err)
}
eff, err = st.EffectiveState(ctx, *granted, now)
if err != nil {
t.Fatalf("EffectiveState: %v", err)
}
if eff.Locked || eff.Reason != "grant" {
t.Fatalf("during grant: %+v, want unlocked by grant", eff)
}
if eff.GrantUntil == nil || !eff.GrantUntil.Equal(grantUntil) {
t.Errorf("grant until = %v, want %v", eff.GrantUntil, grantUntil)
}
// The grant end is a republish boundary, so the device re-locks on time.
next, err := st.NextScheduleBoundary(ctx, now)
if err != nil {
t.Fatalf("NextScheduleBoundary: %v", err)
}
if next == nil || !next.Equal(grantUntil) {
t.Errorf("next boundary = %v, want the grant end %v", next, grantUntil)
}
// After the grant ends the schedule applies again.
eff, err = st.EffectiveState(ctx, *granted, grantUntil.Add(time.Minute))
if err != nil {
t.Fatalf("EffectiveState: %v", err)
}
if !eff.Locked || eff.Reason != "schedule" {
t.Fatalf("after grant: %+v, want locked by schedule again", eff)
}
// The override history holds the grant.
overrides, err := st.ListOverrides(ctx, dev.ID, 10)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
if len(overrides) != 1 || overrides[0].Action != "override.granted" || overrides[0].Actor != "admin-api" {
t.Fatalf("overrides = %+v, want one grant", overrides)
}
if overrides[0].Until == nil || !overrides[0].Until.Equal(grantUntil) {
t.Errorf("override until = %v, want %v", overrides[0].Until, grantUntil)
}
}
func TestLockClearsGrant(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Finn PC", "Finn", "windows")
until := time.Now().Add(time.Hour).UTC()
dev, err := st.SetDeviceGrant(ctx, dev.ID, until, "admin-api", "override.granted")
if err != nil {
t.Fatalf("SetDeviceGrant: %v", err)
}
if dev.GrantUntil == nil {
t.Fatal("grant was not stored")
}
dev, err = st.SetDeviceLock(ctx, dev.ID, true, nil, "admin-api", "state.locked")
if err != nil {
t.Fatalf("SetDeviceLock: %v", err)
}
if !dev.Locked || dev.GrantUntil != nil {
t.Fatalf("after lock: locked=%v grant=%v, want locked with no grant", dev.Locked, dev.GrantUntil)
}
// Both actions are in the override history.
overrides, err := st.ListOverrides(ctx, dev.ID, 10)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
if len(overrides) != 2 {
t.Fatalf("overrides = %d, want 2", len(overrides))
}
}
func TestExpireClearsGrant(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
// Set an already-expired grant directly: SetDeviceGrant refuses the past,
// which is the right guard for callers, but the cleanup path must still work.
past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
if _, err := st.DB().ExecContext(ctx,
`UPDATE state SET grant_until = ? WHERE device_id = ?`, past, dev.ID); err != nil {
t.Fatalf("seed past grant: %v", err)
}
if _, err := st.ExpireLocks(ctx); err != nil {
t.Fatalf("ExpireLocks: %v", err)
}
got, err := st.GetDevice(ctx, dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if got.GrantUntil != nil {
t.Errorf("grant_until = %v after expiry, want nil", got.GrantUntil)
}
}
func TestSetDeviceGrantRejectsPastAndMissing(t *testing.T) {
ctx := context.Background()
st := testStore(t)
dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android")
if _, err := st.SetDeviceGrant(ctx, dev.ID, time.Now().Add(-time.Minute), "admin-api", "override.granted"); err == nil {
t.Error("expected a past grant to be refused")
}
if _, err := st.SetDeviceGrant(ctx, 999, time.Now().Add(time.Hour), "admin-api", "override.granted"); !errors.Is(err, ErrNotFound) {
t.Errorf("missing device: err = %v, want ErrNotFound", err)
}
}
func TestDeviceKeycode(t *testing.T) {
ctx := context.Background()
st := testStore(t)
if _, err := st.KeycodeHash(ctx); !errors.Is(err, ErrNoKeycode) {
t.Fatalf("KeycodeHash with none: err = %v, want ErrNoKeycode", err)
}
if st.VerifyKeycode(ctx, "1234") {
t.Error("an unset keycode must never verify")
}
hash, err := bcrypt.GenerateFromPassword([]byte("2468"), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
if err := st.SetKeycodeHash(ctx, string(hash), "admin"); err != nil {
t.Fatalf("SetKeycodeHash: %v", err)
}
if !st.VerifyKeycode(ctx, "2468") {
t.Error("correct keycode did not verify")
}
if st.VerifyKeycode(ctx, "8642") {
t.Error("wrong keycode verified")
}
if st.VerifyKeycode(ctx, "") {
t.Error("empty keycode verified")
}
// Invalid hashes are refused and do not replace the working one.
if err := st.SetKeycodeHash(ctx, "not-a-hash", "admin"); err == nil {
t.Error("expected an invalid hash to be refused")
}
if !st.VerifyKeycode(ctx, "2468") {
t.Error("the working keycode was replaced by an invalid one")
}
// Seeding never overwrites an existing code: a runtime change made with
// `control keycode` survives a restart even if the environment still holds
// the old value.
other, _ := bcrypt.GenerateFromPassword([]byte("1357"), bcrypt.MinCost)
if seeded, err := st.SeedKeycodeHash(ctx, string(other)); err != nil || seeded {
t.Fatalf("SeedKeycodeHash over existing = (%v, %v), want (false, nil)", seeded, err)
}
if !st.VerifyKeycode(ctx, "2468") || st.VerifyKeycode(ctx, "1357") {
t.Error("seeding must not overwrite a configured keycode")
}
// A fresh store takes the environment value once.
fresh := testStore(t)
seeded, err := fresh.SeedKeycodeHash(ctx, string(other))
if err != nil || !seeded {
t.Fatalf("SeedKeycodeHash on empty store = (%v, %v), want (true, nil)", seeded, err)
}
if !fresh.VerifyKeycode(ctx, "1357") {
t.Error("seeded keycode did not verify")
}
if seeded, err := fresh.SeedKeycodeHash(ctx, string(other)); err != nil || seeded {
t.Errorf("SeedKeycodeHash repeat = (%v, %v), want (false, nil)", seeded, err)
}
}

View File

@@ -53,9 +53,13 @@ type EffectiveState struct {
DeviceID int64
Locked bool
Until *time.Time
// Reason is "manual" (a lock from the dashboard), "schedule" (an active
// weekly window) or "" (not blocked).
// Reason is "manual" (a lock from the dashboard or the admin API),
// "schedule" (an active weekly window), "grant" (a time-limited exemption)
// or "" (not blocked).
Reason string
// GrantUntil is set when a grant is active: the device is unlocked, and a
// schedule window must not block it before this instant (Phase 8).
GrantUntil *time.Time
// Allow and Block are the configured list values, always non-nil.
Allow []string
Block []string
@@ -279,8 +283,13 @@ func atClock(now time.Time, minutes int) time.Time {
}
// EffectiveState computes the desired state for one device at now: a manual
// lock, an active schedule window, or neither. While blocked, the configured
// allowlist is honoured: the device blocks Block except Allow (FR-4).
// lock, an active schedule window, a time-limited grant, or none of them.
// While blocked, the configured allowlist is honoured: the device blocks Block
// except Allow (FR-4).
//
// A grant wins over a schedule window but not over a manual lock; the write
// paths keep the two mutually exclusive (a lock clears a grant, a grant clears
// a lock), so the order below only matters for hand-edited data.
//
// A manual lock with no end time ("until I unlock") never lifts, so it wins over
// a schedule end. Otherwise Until is the latest of the manual end and the
@@ -314,6 +323,10 @@ func (s *Store) EffectiveState(ctx context.Context, dev Device, now time.Time) (
if dev.Until != nil && schedUntil != nil && schedUntil.After(*dev.Until) {
eff.Until = schedUntil
}
case dev.GrantUntil != nil && dev.GrantUntil.After(now):
eff.Locked = false
eff.Reason = "grant"
eff.GrantUntil = dev.GrantUntil
case scheduled:
eff.Locked = true
eff.Reason = "schedule"
@@ -346,26 +359,30 @@ func (s *Store) NextScheduleBoundary(ctx context.Context, now time.Time) (*time.
}
best := NextWindowBoundary(schedules, now)
// A manual lock that expires is a boundary too, so the loop lifts it from
// the device promptly instead of waiting for the next interval.
// A manual lock that expires and a grant that ends are boundaries too, so
// the loop lifts or re-locks from the device promptly instead of waiting for
// the next interval. A grant end matters because the device may be unlocked
// only because of the grant: when it passes, an active window must re-lock.
lockRows, err := s.db.QueryContext(ctx,
`SELECT until FROM state WHERE locked = 1 AND until IS NOT NULL`)
`SELECT until, grant_until FROM state WHERE (locked = 1 AND until IS NOT NULL) OR grant_until IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("next lock boundary: %w", err)
}
defer lockRows.Close()
for lockRows.Next() {
var until sql.NullString
if err := lockRows.Scan(&until); err != nil {
var until, grantUntil sql.NullString
if err := lockRows.Scan(&until, &grantUntil); err != nil {
return nil, fmt.Errorf("scan lock until: %w", err)
}
t := parseTime(until.String)
if t.IsZero() || !t.After(now) {
continue
}
if best == nil || t.Before(*best) {
c := t
best = &c
for _, ns := range []sql.NullString{until, grantUntil} {
t := parseTime(ns.String)
if t.IsZero() || !t.After(now) {
continue
}
if best == nil || t.Before(*best) {
c := t
best = &c
}
}
}
if err := lockRows.Err(); err != nil {

514
internal/web/admin_api.go Normal file
View File

@@ -0,0 +1,514 @@
// The versioned admin API (AGENTS.md §4 FR-11, §11 Phase 8).
//
// Another project, the Kotlin admin app (kotlin_phone_app_admin), consumes this
// surface. It is deliberately small and stable: bearer-token auth, JSON in and
// out, and one clear shape for lock, unlock, grant and a state query. Control
// never goes through the Hub (AGENTS.md §8): the app calls this service
// directly.
//
// Every state change is persisted to SQLite, recorded in `overrides` and the
// append-only `audit` log (FR-15), and only then published retained on the
// device's MQTT config topic. The publish is synchronous, so a 200 response
// means the broker has accepted the retained message and every subscriber
// receives it at once (FR-5, FR-11).
package web
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// apiVersion is the version of this surface, carried in the path.
const apiVersion = "v1"
// Actor names written to the audit log and the override history.
const (
apiActor = "admin-api"
keycodeActor = "device-keycode"
apiMaxBody = 8 << 10 // 8 KiB is plenty for these requests
apiMaxGrant = 24 * 60 // minutes; a day at a time
apiMaxLockMin = 7 * 24 * 60
)
// apiDevice is the wire shape of one device. It is versioned by its path, and
// clients must ignore unknown fields so it can grow without a v2.
type apiDevice struct {
ID int64 `json:"id"`
Name string `json:"name"`
Child string `json:"child"`
Platform string `json:"platform"`
Topic string `json:"topic"`
Online bool `json:"online"`
LastSeen *time.Time `json:"last_seen"`
Locked bool `json:"locked"`
Until *time.Time `json:"until"`
GrantUntil *time.Time `json:"grant_until"`
Reason string `json:"reason"`
UpdatedAt time.Time `json:"updated_at"`
}
// apiResponse is the one response envelope for every endpoint. Exactly one of
// Device or Devices is set on success; Error is set on failure.
type apiResponse struct {
OK bool `json:"ok"`
Action string `json:"action,omitempty"`
Device *apiDevice `json:"device,omitempty"`
Devices []apiDevice `json:"devices,omitempty"`
Error string `json:"error,omitempty"`
}
// overrideRequest is the body of lock, grant and keycode requests.
type overrideRequest struct {
// Minutes grants time relative to now. Mutually exclusive with Until.
Minutes int `json:"minutes"`
// Until is an absolute RFC3339 instant.
Until string `json:"until"`
// DeviceID and Keycode are used only by the device keycode endpoint.
DeviceID int64 `json:"device_id"`
Keycode string `json:"keycode"`
}
// handleAPIListDevices returns every device with its effective state.
func (s *Server) handleAPIListDevices(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
devs, err := s.store.ListDevices(ctx)
if err != nil {
s.log.Error("api list devices", "err", err)
writeAPIError(w, http.StatusInternalServerError, "could not read devices")
return
}
out := make([]apiDevice, 0, len(devs))
for _, dev := range devs {
item, err := s.apiDevice(ctx, dev)
if err != nil {
s.log.Error("api effective state", "device", dev.Name, "err", err)
writeAPIError(w, http.StatusInternalServerError, "could not read device state")
return
}
out = append(out, item)
}
writeAPIJSON(w, http.StatusOK, apiResponse{OK: true, Devices: out})
}
// handleAPIState returns one device with its effective state.
func (s *Server) handleAPIState(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
writeAPIError(w, http.StatusNotFound, "device not found")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.apiDeviceError(w, err)
return
}
item, err := s.apiDevice(ctx, *dev)
if err != nil {
s.log.Error("api effective state", "device", dev.Name, "err", err)
writeAPIError(w, http.StatusInternalServerError, "could not read device state")
return
}
writeAPIJSON(w, http.StatusOK, apiResponse{OK: true, Device: &item})
}
// handleAPILock locks a device now, for a duration or indefinitely.
func (s *Server) handleAPILock(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
writeAPIError(w, http.StatusNotFound, "device not found")
return
}
req, ok := decodeOverride(w, r, false)
if !ok {
return
}
until, err := resolveUntil(req, apiMaxLockMin)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
started := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
dev, err := s.store.SetDeviceLock(ctx, id, true, until, apiActor, "state.locked")
if err != nil {
s.apiDeviceError(w, err)
return
}
s.publishConfig(ctx, *dev)
s.apiRespond(w, ctx, "lock", dev, started)
}
// handleAPIUnlock clears a manual lock and any grant, returning the device to
// its schedule. It is the opposite of lock.
func (s *Server) handleAPIUnlock(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
writeAPIError(w, http.StatusNotFound, "device not found")
return
}
// An empty body is fine; reject a malformed one.
if !decodeEmpty(w, r) {
return
}
started := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
dev, err := s.store.SetDeviceLock(ctx, id, false, nil, apiActor, "state.unlocked")
if err != nil {
s.apiDeviceError(w, err)
return
}
s.publishConfig(ctx, *dev)
s.apiRespond(w, ctx, "unlock", dev, started)
}
// handleAPIGrant gives a device time: it is unlocked until the grant ends, even
// if a weekly window is active, and re-locks automatically when the grant ends
// (the republish loop treats the grant end as a boundary).
func (s *Server) handleAPIGrant(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
writeAPIError(w, http.StatusNotFound, "device not found")
return
}
req, ok := decodeOverride(w, r, true)
if !ok {
return
}
until, err := resolveUntil(req, apiMaxGrant)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if until == nil {
writeAPIError(w, http.StatusBadRequest, "grant requires minutes or until")
return
}
started := time.Now()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
dev, err := s.store.SetDeviceGrant(ctx, id, *until, apiActor, "override.granted")
if err != nil {
s.apiDeviceError(w, err)
return
}
s.publishConfig(ctx, *dev)
s.apiRespond(w, ctx, "grant", dev, started)
}
// handleDeviceOverride is the on-device keycode path (FR-10). A kid's client
// posts the shared code with its own device id; a correct code grants a short,
// bounded exemption. It intentionally does not use the admin bearer token: the
// device never holds that secret. Failures are throttled per client address and
// audited, because the keycode is the enforcement that the tamper alert
// protects (AGENTS.md §13).
func (s *Server) handleDeviceOverride(w http.ResponseWriter, r *http.Request) {
if !s.keycodeConfigured(r.Context()) {
writeAPIError(w, http.StatusServiceUnavailable, "device keycode is not configured")
return
}
req, ok := decodeOverride(w, r, true)
if !ok {
return
}
if req.DeviceID <= 0 {
writeAPIError(w, http.StatusBadRequest, "device_id is required")
return
}
req.Keycode = strings.TrimSpace(req.Keycode)
if req.Keycode == "" {
writeAPIError(w, http.StatusBadRequest, "keycode is required")
return
}
remote := clientIP(r)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if s.keycodeThrottled(remote) {
_ = s.store.Audit(ctx, keycodeActor, "override.keycode_throttled", &req.DeviceID, remote)
writeAPIError(w, http.StatusTooManyRequests, "too many attempts; try again later")
return
}
if !s.store.VerifyKeycode(ctx, req.Keycode) {
s.noteKeycodeFailure(remote)
_ = s.store.Audit(ctx, keycodeActor, "override.keycode_rejected", &req.DeviceID, remote)
s.log.Warn("device keycode rejected", "device_id", req.DeviceID, "remote", remote)
writeAPIError(w, http.StatusUnauthorized, "invalid keycode")
return
}
s.noteKeycodeSuccess(remote)
until, err := resolveUntil(req, apiMaxGrant)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if until == nil {
// A keycode run without a duration gives a short, fixed exemption.
t := time.Now().Add(15 * time.Minute).UTC()
until = &t
}
started := time.Now()
dev, err := s.store.SetDeviceGrant(ctx, req.DeviceID, *until, keycodeActor, "override.keycode")
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeAPIError(w, http.StatusNotFound, "device not found")
return
}
s.log.Error("device keycode grant", "device_id", req.DeviceID, "err", err)
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
s.publishConfig(ctx, *dev)
s.apiRespond(w, ctx, "keycode", dev, started)
}
// ---------- helpers ----------
// apiDevice combines the stored device with its effective state, so a client
// sees the same `locked`/`until`/`reason` a device is given.
func (s *Server) apiDevice(ctx context.Context, dev store.Device) (apiDevice, error) {
eff, err := s.store.EffectiveState(ctx, dev, s.now())
if err != nil {
return apiDevice{}, err
}
return apiDevice{
ID: dev.ID,
Name: dev.Name,
Child: dev.Child,
Platform: dev.Platform,
Topic: dev.Topic,
Online: dev.Online,
LastSeen: dev.LastSeen,
Locked: eff.Locked,
Until: eff.Until,
GrantUntil: eff.GrantUntil,
Reason: eff.Reason,
UpdatedAt: dev.StateAt,
}, nil
}
// apiRespond writes the fresh device after a state change, logging the time
// from request receipt to the retained publish being accepted. That duration is
// the number the Phase 8 definition of done measures.
func (s *Server) apiRespond(w http.ResponseWriter, ctx context.Context, action string, dev *store.Device, started time.Time) {
item, err := s.apiDevice(ctx, *dev)
if err != nil {
s.log.Error("api effective state", "device", dev.Name, "err", err)
writeAPIError(w, http.StatusInternalServerError, "state saved but could not be read back")
return
}
s.log.Info("admin api "+action, "device", dev.Name, "device_id", dev.ID,
"locked", item.Locked, "reason", item.Reason,
"duration_ms", time.Since(started).Milliseconds())
writeAPIJSON(w, http.StatusOK, apiResponse{OK: true, Action: action, Device: &item})
}
// requireAPIToken guards the admin endpoints. It is deliberately a separate
// credential from the session login (AGENTS.md §11 Phase 8).
func (s *Server) requireAPIToken(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(s.cfg.AdminAPIToken) == "" {
writeAPIError(w, http.StatusServiceUnavailable, "admin API is not configured")
return
}
token, ok := bearerToken(r)
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.AdminAPIToken)) != 1 {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
_ = s.store.Audit(ctx, "anonymous", "admin.token_rejected", nil, r.URL.Path+" from "+clientIP(r))
cancel()
s.log.Warn("admin api rejected", "path", r.URL.Path, "remote", r.RemoteAddr)
w.Header().Set("WWW-Authenticate", `Bearer realm="internet-use-control"`)
writeAPIError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
// bearerToken reads "Authorization: Bearer <token>".
func bearerToken(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
kind, token, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(kind, "Bearer") {
return "", false
}
token = strings.TrimSpace(token)
return token, token != ""
}
// decodeOverride reads and bounds the JSON body. When requireBody is false an
// empty body is the same as {}.
func decodeOverride(w http.ResponseWriter, r *http.Request, requireBody bool) (overrideRequest, bool) {
var req overrideRequest
if r.Body == nil {
if requireBody {
writeAPIError(w, http.StatusBadRequest, "a JSON body is required")
return req, false
}
return req, true
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, apiMaxBody))
err := dec.Decode(&req)
if err != nil {
if strings.Contains(err.Error(), "EOF") && !requireBody {
return req, true
}
writeAPIError(w, http.StatusBadRequest, "invalid JSON body")
return req, false
}
// A second JSON value in the body is a mistake.
if dec.Decode(&struct{}{}) == nil {
writeAPIError(w, http.StatusBadRequest, "body must contain one JSON object")
return req, false
}
return req, true
}
// decodeEmpty accepts an empty or absent body and rejects anything malformed.
func decodeEmpty(w http.ResponseWriter, r *http.Request) bool {
if r.Body == nil || r.ContentLength == 0 {
return true
}
_, ok := decodeOverride(w, r, false)
return ok
}
// resolveUntil turns a request into an absolute end time. Exactly one of
// minutes or until may be set. A nil result with no error means "no end time"
// (only meaningful for a lock).
func resolveUntil(req overrideRequest, maxMinutes int) (*time.Time, error) {
if req.Minutes < 0 {
return nil, errors.New("minutes must not be negative")
}
if req.Until != "" && req.Minutes > 0 {
return nil, errors.New("set either minutes or until, not both")
}
if req.Until != "" {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.Until))
if err != nil {
return nil, errors.New("until must be an RFC3339 instant, for example 2026-10-13T21:00:00+11:00")
}
t = t.UTC()
if !t.After(time.Now()) {
return nil, errors.New("until must be in the future")
}
return &t, nil
}
if req.Minutes > maxMinutes {
return nil, errors.New("minutes is too large")
}
if req.Minutes > 0 {
t := time.Now().Add(time.Duration(req.Minutes) * time.Minute).UTC()
return &t, nil
}
return nil, nil
}
// apiDeviceError maps a store error to an HTTP status.
func (s *Server) apiDeviceError(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) {
writeAPIError(w, http.StatusNotFound, "device not found")
return
}
s.log.Error("admin api device", "err", err)
writeAPIError(w, http.StatusBadRequest, err.Error())
}
func writeAPIJSON(w http.ResponseWriter, code int, body apiResponse) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func writeAPIError(w http.ResponseWriter, code int, message string) {
writeAPIJSON(w, code, apiResponse{OK: false, Error: message})
}
// clientIP is the direct peer address. No proxy header is trusted: the service
// is reached through Caddy, but X-Forwarded-For is caller-controlled.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// ---------- keycode throttle ----------
// keycodeThrottle bounds brute force on the shared code: five failures from one
// address freeze that address for five minutes. It is in memory on purpose —
// the Control Service is a single process, and a restart clearing it is an
// acceptable trade for keeping the store schema small.
const (
keycodeMaxFailures = 5
keycodeFreeze = 5 * time.Minute
)
type keycodeAttempts struct {
failures int
blocked time.Time
}
func (s *Server) keycodeThrottled(remote string) bool {
s.keycodeMu.Lock()
defer s.keycodeMu.Unlock()
a, ok := s.keycode[remote]
if !ok {
return false
}
if time.Now().Before(a.blocked) {
return true
}
if !a.blocked.IsZero() {
delete(s.keycode, remote) // freeze over; start fresh
}
return false
}
func (s *Server) noteKeycodeFailure(remote string) {
s.keycodeMu.Lock()
defer s.keycodeMu.Unlock()
a := s.keycode[remote]
a.failures++
if a.failures >= keycodeMaxFailures {
a.blocked = time.Now().Add(keycodeFreeze)
a.failures = 0
}
s.keycode[remote] = a
}
func (s *Server) noteKeycodeSuccess(remote string) {
s.keycodeMu.Lock()
defer s.keycodeMu.Unlock()
delete(s.keycode, remote)
}
// keycodeConfigured reports whether any keycode can be verified.
func (s *Server) keycodeConfigured(ctx context.Context) bool {
_, err := s.store.KeycodeHash(ctx)
return err == nil
}

View File

@@ -0,0 +1,337 @@
package web
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// fakePublisher records the retained config publishes an API call makes, so a
// test can prove a state change reaches the broker before the response.
type fakePublisher struct {
mu sync.Mutex
topics []string
}
func (f *fakePublisher) Broker() string { return "fake" }
func (f *fakePublisher) Connected() bool { return true }
func (f *fakePublisher) Stats() (int64, int64, time.Time, string) {
return 0, 0, time.Time{}, ""
}
func (f *fakePublisher) PublishDeviceConfig(_ context.Context, dev store.Device) error {
f.mu.Lock()
defer f.mu.Unlock()
f.topics = append(f.topics, dev.Topic)
return nil
}
func (f *fakePublisher) PublishAll(context.Context) (int, error) { return 0, nil }
func (f *fakePublisher) published() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.topics...)
}
type apiTestApp struct {
handler http.Handler
store *store.Store
pub *fakePublisher
token string
}
func newAPITestApp(t *testing.T, token, keycode string) *apiTestApp {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "control.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("auth.New: %v", err)
}
pub := &fakePublisher{}
cfg := &config.Config{
AdminUsername: "sam",
Version: "test",
AppEnv: "test",
DBPath: ":memory:",
AdminAPIToken: token,
ScheduleLocation: time.UTC,
}
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), pub)
if err != nil {
t.Fatalf("web.New: %v", err)
}
if keycode != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(keycode), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
if err := st.SetKeycodeHash(context.Background(), string(hash), "test"); err != nil {
t.Fatalf("SetKeycodeHash: %v", err)
}
}
return &apiTestApp{handler: srv.Handler(), store: st, pub: pub, token: token}
}
func (a *apiTestApp) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func decodeAPIResponse(t *testing.T, rec *httptest.ResponseRecorder) apiResponse {
t.Helper()
var resp apiResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
}
return resp
}
func mustCreateDevice(t *testing.T, st *store.Store, name string) store.Device {
t.Helper()
dev, err := st.CreateDevice(context.Background(), name, "Harry", "android", "test")
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
return *dev
}
func TestAdminAPIAuth(t *testing.T) {
app := newAPITestApp(t, "s3cret", "")
dev := mustCreateDevice(t, app.store, "Harry Phone")
if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("no token = %d, want 401", rec.Code)
}
if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "wrong"); rec.Code != http.StatusUnauthorized {
t.Errorf("wrong token = %d, want 401", rec.Code)
}
if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret"); rec.Code != http.StatusOK {
t.Errorf("good token = %d, want 200", rec.Code)
}
// The rejection is audited.
audit, err := app.store.ListAudit(context.Background(), 10)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
var rejected int
for _, e := range audit {
if e.Action == "admin.token_rejected" {
rejected++
}
}
if rejected != 2 {
t.Errorf("admin.token_rejected = %d, want 2", rejected)
}
// The token is separate from the session login: a session cookie does not
// authorise the API and the API token does not open the dashboard.
if rec := app.do(t, http.MethodGet, "/", "", ""); rec.Code != http.StatusSeeOther {
t.Errorf("dashboard without session = %d, want 303", rec.Code)
}
_ = dev
}
func TestAdminAPIUnconfigured(t *testing.T) {
app := newAPITestApp(t, "", "")
if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "anything"); rec.Code != http.StatusServiceUnavailable {
t.Errorf("unconfigured admin API = %d, want 503", rec.Code)
}
}
func TestAdminAPILockUnlockGrant(t *testing.T) {
app := newAPITestApp(t, "s3cret", "")
dev := mustCreateDevice(t, app.store, "Harry Phone")
// Lock for 30 minutes.
rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/lock", `{"minutes":30}`, "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("lock = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
resp := decodeAPIResponse(t, rec)
if !resp.OK || resp.Action != "lock" || resp.Device == nil {
t.Fatalf("lock response = %+v", resp)
}
if !resp.Device.Locked || resp.Device.Reason != "manual" || resp.Device.Until == nil {
t.Errorf("locked device = %+v", resp.Device)
}
if got := app.pub.published(); len(got) != 1 || got[0] != dev.Topic {
t.Errorf("published = %v, want the device config topic", got)
}
// Grant time (unlocked, with an end).
rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", `{"minutes":15}`, "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("grant = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
resp = decodeAPIResponse(t, rec)
if resp.Device == nil || resp.Device.Locked || resp.Device.Reason != "grant" || resp.Device.GrantUntil == nil {
t.Fatalf("granted device = %+v", resp.Device)
}
// Unlock clears the grant.
rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/unlock", "", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("unlock = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
resp = decodeAPIResponse(t, rec)
if resp.Device == nil || resp.Device.Locked || resp.Device.GrantUntil != nil {
t.Fatalf("unlocked device = %+v", resp.Device)
}
// Query one device and the list.
if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices/1", "", "s3cret"); rec.Code != http.StatusOK {
t.Errorf("state = %d, want 200", rec.Code)
}
rec = app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret")
if rec.Code != http.StatusOK {
t.Fatalf("list = %d, want 200", rec.Code)
}
if resp = decodeAPIResponse(t, rec); len(resp.Devices) != 1 || resp.Devices[0].Name != "Harry Phone" {
t.Errorf("device list = %+v", resp.Devices)
}
// Every mutation is in the override history and the audit log.
overrides, err := app.store.ListOverrides(context.Background(), 1, 10)
if err != nil {
t.Fatalf("ListOverrides: %v", err)
}
if len(overrides) != 3 {
t.Errorf("overrides = %d, want 3 (lock, grant, unlock)", len(overrides))
}
audit, _ := app.store.ListAudit(context.Background(), 20)
var stateChanges int
for _, e := range audit {
switch e.Action {
case "state.locked", "override.granted", "state.unlocked":
stateChanges++
}
}
if stateChanges != 3 {
t.Errorf("audited state changes = %d, want 3", stateChanges)
}
// A publish happens for every mutation.
if got := app.pub.published(); len(got) != 3 {
t.Errorf("publishes = %v, want 3", got)
}
}
func TestAdminAPIBadRequests(t *testing.T) {
app := newAPITestApp(t, "s3cret", "")
mustCreateDevice(t, app.store, "Harry Phone")
cases := []struct {
name string
body string
code int
}{
{"unknown field JSON is rejected", `not json`, http.StatusBadRequest},
{"negative minutes", `{"minutes":-5}`, http.StatusBadRequest},
{"past until", `{"until":"2000-01-01T00:00:00Z"}`, http.StatusBadRequest},
{"both minutes and until", `{"minutes":5,"until":"2999-01-01T00:00:00Z"}`, http.StatusBadRequest},
{"grant with no time", `{}`, http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", tc.body, "s3cret")
if rec.Code != tc.code {
t.Errorf("grant %q = %d, want %d; body=%s", tc.body, rec.Code, tc.code, rec.Body.String())
}
})
}
if rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/999/lock", `{}`, "s3cret"); rec.Code != http.StatusNotFound {
t.Errorf("lock unknown device = %d, want 404", rec.Code)
}
}
func TestDeviceKeycodeOverride(t *testing.T) {
app := newAPITestApp(t, "s3cret", "246810")
mustCreateDevice(t, app.store, "Harry Phone")
// A wrong code is refused and audited.
rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"000000","minutes":10}`, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong keycode = %d, want 401; body=%s", rec.Code, rec.Body.String())
}
// The right code grants time without the admin token.
rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":10}`, "")
if rec.Code != http.StatusOK {
t.Fatalf("keycode = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
resp := decodeAPIResponse(t, rec)
if !resp.OK || resp.Action != "keycode" || resp.Device == nil || resp.Device.Reason != "grant" {
t.Fatalf("keycode response = %+v", resp)
}
// A keycode with no duration still grants a short, fixed exemption.
rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810"}`, "")
if rec.Code != http.StatusOK {
t.Fatalf("keycode without duration = %d, want 200", rec.Code)
}
if resp = decodeAPIResponse(t, rec); resp.Device == nil || resp.Device.GrantUntil == nil {
t.Fatalf("default grant = %+v", resp.Device)
}
}
func TestDeviceKeycodeThrottle(t *testing.T) {
app := newAPITestApp(t, "s3cret", "246810")
mustCreateDevice(t, app.store, "Harry Phone")
for i := 0; i < 5; i++ {
rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"wrong"}`, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d = %d, want 401", i+1, rec.Code)
}
}
// The sixth attempt is throttled even with the right code.
rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":5}`, "")
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("after five failures = %d, want 429; body=%s", rec.Code, rec.Body.String())
}
}
func TestDeviceKeycodeUnconfigured(t *testing.T) {
app := newAPITestApp(t, "s3cret", "")
mustCreateDevice(t, app.store, "Harry Phone")
rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"1234"}`, "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured keycode = %d, want 503", rec.Code)
}
}

View File

@@ -29,6 +29,7 @@ type deviceView struct {
Locked bool
ManualLocked bool
Scheduled bool
Granted bool
Until *time.Time
UntilLocal string
TimeLeft string
@@ -81,9 +82,14 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
v.ReasonLabel = "by schedule"
case "manual":
v.ReasonLabel = "manually"
case "grant":
v.Granted = true
v.ReasonLabel = "time granted"
}
if eff.Locked {
v.StateLabel = "Locked"
} else if v.Granted {
v.StateLabel = "Granted"
} else {
v.StateLabel = "Unlocked"
}
@@ -92,6 +98,12 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*eff.Until)
}
if eff.GrantUntil != nil {
v.Until = eff.GrantUntil
local := eff.GrantUntil.In(loc)
v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*eff.GrantUntil)
}
if d.LastSeen != nil {
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
v.HasLastSeen = true
@@ -167,6 +179,18 @@ func auditLabel(action string) string {
return "Unlocked"
case "state.expired":
return "Lock expired"
case "override.granted":
return "Time granted"
case "override.keycode":
return "Keycode override"
case "override.keycode_rejected":
return "Keycode rejected"
case "override.keycode_throttled":
return "Keycode throttled"
case "keycode.updated":
return "Keycode changed"
case "admin.token_rejected":
return "Admin API rejected"
case "schedule.updated":
return "Schedule saved"
case "list.added":

View File

@@ -12,6 +12,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
@@ -33,6 +34,12 @@ type Server struct {
tmpl *template.Template
log *slog.Logger
pub Publisher
// keycodeMu guards the in-memory throttle for the device keycode endpoint
// (admin_api.go). Failures are remembered per client address, so a wrong
// code cannot be tried without limit.
keycodeMu sync.Mutex
keycode map[string]keycodeAttempts
}
// Publisher pushes device config to the broker and reports the link's health.
@@ -60,7 +67,15 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, pub: pub}, nil
return &Server{
cfg: cfg,
store: st,
auth: am,
tmpl: tmpl,
log: log,
pub: pub,
keycode: make(map[string]keycodeAttempts),
}, nil
}
// publishConfig pushes one device's desired state to the broker, retained. A
@@ -103,6 +118,19 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage))
// Versioned admin API (Phase 8). Bearer-token auth, separate from the
// session login. The Kotlin admin app calls these directly (AGENTS.md §8,
// §11 Phase 8).
api := "/api/" + apiVersion
mux.HandleFunc("GET "+api+"/admin/devices", s.requireAPIToken(s.handleAPIListDevices))
mux.HandleFunc("GET "+api+"/admin/devices/{id}", s.requireAPIToken(s.handleAPIState))
mux.HandleFunc("POST "+api+"/admin/devices/{id}/lock", s.requireAPIToken(s.handleAPILock))
mux.HandleFunc("POST "+api+"/admin/devices/{id}/unlock", s.requireAPIToken(s.handleAPIUnlock))
mux.HandleFunc("POST "+api+"/admin/devices/{id}/grant", s.requireAPIToken(s.handleAPIGrant))
// The on-device keycode path is not behind the admin token: a device does
// not hold that secret. It is throttled and audited instead (admin_api.go).
mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride)
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())

View File

@@ -12,9 +12,9 @@
</header>
<p class="device-card__state">
{{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
{{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else if .Granted}}Time granted{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
</p>
{{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}}
{{if .Until}}<p class="device-card__until">{{if .Granted}}Grant ends{{else}}Lifts{{end}} {{.UntilLocal}}</p>{{end}}
<div class="device-card__actions">
<form method="post" action="/devices/{{.ID}}/lock" class="inline-form">