Build on Phases 1–6. Telemetry already arrived on
restrict/<device>/telemetry; this phase stores it in a daily rollup and
shows it.
- store: migration 003 adds telemetry_daily (device, day, app seconds,
site seconds). InsertTelemetry maintains the affected days in the same
transaction; RebuildRollups backfills and repairs it. UsageRange and
TopUsage serve the week grid and top lists. CapAudit enforces the
size-capped append-only audit log from AGENTS.md §7.
- web: GET /devices/{id}/usage — Monday-to-Sunday totals plus the top 10
apps and top 10 sites, using CSP-safe native <progress> bars.
- theme.js: light/dark from the OS preference, with a nav toggle, loaded
as an external script so the strict CSP still holds.
- cmd/control: `rollup` subcommand; rollup rebuild and audit cap at
start-up and every 15 minutes.
- Version 0.5.0-phase7 (compose, Makefile, docs).
Tests: go test ./... and go vet ./... pass in golang:1.25-alpine.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Internet Use Control — Control Service
The Control Service is the brain and the single source of truth for the
Internet Use Control project (SYSTEM_MAP.md). It holds schedules, blocklists,
per-device state and usage, and serves the web dashboard. Only it publishes
device config.
This repository currently contains Phase 7 — Telemetry and usage (AGENTS.md §11), on top of Phases 1–6:
- Go service with its own image (
Dockerfile) and compose file. - SQLite schema for the whole data model (§7), applied through versioned migrations.
- Configuration from the process environment (
environment.d); no secrets in the repo. - A separate session login for Sam.
GET /health.- HTMX + server-rendered dashboard styled with the Family Home Lab design
system (
tokens.csscopied in). - Device registry: add and edit a device, assign it to a child, choose its
platform. Persisted in
devices. - Dashboard cards: name, child, platform, status, current state, time left.
- Manual lock/unlock from the dashboard, persisted in
stateand written to the append-onlyauditlog (FR-15). - MQTT: each device's desired state is published retained on
restrict/<device>/config; device status and telemetry are subscribed and written to SQLite; presence comes from the broker's Last Will and Testament. The broker is transport only — SQLite stays the source of truth. - Weekly schedules: a per-device grid of blocking windows, Monday to Sunday, including windows that cross midnight. The Control Service computes the effective state — manual lock or active window — and republishes it at each window boundary, so a schedule turns on and off with nobody touching it.
- Blocklist / allowlist: per-device
app(Android package or Windows process) andsite(hostname) entries. While a device is blocked, the device blocks every blocklist value except the allowlist values (Spotify at night). That allow decision is in the published config. - Usage: telemetry reports are stored per device, day, app and domain and rolled up into a daily summary. The dashboard shows a week at a time — the daily total plus the top apps and top sites — with dark mode and phone-sized touch targets.
- Every edit is written to the append-only audit log (FR-15).
Enforcement itself (the kids' clients), the admin API and the Hub arrive in later phases. Nothing here touches the router, Pi-hole, or DNS.
Quick start (on .13)
cd /home/sam/paseo/projects/internet_use_control
# 1. Create the env file from the template and set real values.
cp environment.d/control-service.env.example environment.d/control-service.env
$EDITOR environment.d/control-service.env
# 2. Build and start. Own image, own container, port 8791.
docker compose up -d --build
# 3. Prove it.
curl -s http://127.0.0.1:8791/health
# {"status":"ok","service":"control-service","version":"0.5.0-phase7",...}
Open http://127.0.0.1:8791/ in a browser. It redirects to /login. Sign in
with CONTROL_ADMIN_USERNAME and CONTROL_ADMIN_PASSWORD.
Configuration
All configuration is environment variables. Docker Compose loads them from
environment.d/control-service.env (gitignored). The variables can also be
exported from ~/.config/environment.d/10-secrets.conf instead; the service
reads only the process environment.
| Variable | Default | Purpose |
|---|---|---|
CONTROL_ADDR |
:8791 |
Listen address |
CONTROL_DB_PATH |
/data/control.db |
SQLite file |
CONTROL_SESSION_SECRET |
— (required) | Signs the session cookie, ≥ 16 chars |
CONTROL_SESSION_TTL |
12h |
Session lifetime |
CONTROL_COOKIE_SECURE |
true |
Secure cookie flag; keep true behind HTTPS |
CONTROL_ADMIN_USERNAME |
sam |
Dashboard login |
CONTROL_ADMIN_PASSWORD |
— | Plaintext password (option A) |
CONTROL_ADMIN_PASSWORD_HASH |
— | bcrypt hash (option B, preferred) |
CONTROL_APP_ENV |
production |
Label shown in the footer |
CONTROL_LOG_LEVEL |
info |
debug/info/warn/error |
CONTROL_TIMEZONE |
Australia/Sydney |
IANA zone weekly windows are evaluated in |
CONTROL_MQTT_ENABLED |
true |
Turn the broker link on or off |
CONTROL_MQTT_BROKER |
tcp://host.docker.internal:1883 |
Broker URL; tls://…:8883 once the TLS listener exists |
CONTROL_MQTT_CLIENT_ID |
control-service |
MQTT client id |
CONTROL_MQTT_USERNAME |
— | Control Service broker account |
CONTROL_MQTT_PASSWORD |
— | its password |
CONTROL_MQTT_CA_FILE |
/etc/webrestrict/tls/ca.crt |
PEM bundle that verifies the broker (TLS only) |
CONTROL_MQTT_INSECURE |
false |
Allow a plaintext, credential-less broker. Only for a broker with no TLS listener |
Generate a bcrypt hash without host Go:
docker run --rm --entrypoint /app/control webrestrict/control:0.5.0-phase7 \
hash-password 'your-password'
CONTROL_MQTT_USERNAME/CONTROL_MQTT_PASSWORD are required unless
CONTROL_MQTT_INSECURE=true, and a non-TLS broker is refused unless
CONTROL_MQTT_INSECURE=true (AGENTS.md §10 rule 6). The escape hatch exists for
a broker that has no TLS listener; the running deployment on .13 does not use
it. Since 2026-10-13 the shared Mosquitto also serves a TLS listener on 8883
with per-device credentials and a topic ACL
(tls://host.docker.internal:8883), so CONTROL_MQTT_INSECURE=false.
deploy/mosquitto/README.md is the design, the apply runbook and the rollback.
MQTT (Phase 3)
Broker: the shared Mosquitto on .13. A second broker is not added
(§10 rule 6).
| Topic | Direction | Retained | Payload |
|---|---|---|---|
restrict/<device>/config |
service → device | yes | desired state |
restrict/<device>/status |
device → service | yes | presence (online) |
restrict/<device>/telemetry |
device → service | no | usage totals for a day |
<device>isrestrict/<slug>_<id>fromdevices.topic, which is stored when the device is created and never changes.<id>is its MQTT username.- The service publishes each device's config retained on startup, after every lock, unlock, expiry, create and edit, and again every 15 minutes so a broker that lost its retained store is repaired from SQLite.
- Devices use the 8883 TLS listener with one account each; the broker's ACL lets a device read only its own config and write only its own status and telemetry, and the Control Service is the only identity that may publish config.
- A device registers the will
{"online": false}on its status topic, so the broker reports the drop the moment the connection dies. That is presence (FR-6); no polling. - Reports are written to SQLite. Telemetry is a rolling daily total per (device, day, app, domain), so a repeated report replaces the row rather than adding to it (migration 002).
Config payload (v1)
{"v":1,"device_id":1,"name":"Harry's phone","platform":"android",
"locked":true,"until":"2026-10-13T21:00:00Z",
"allow":["com.spotify.music"],"block":["com.youtube.android","roblox.exe"],
"reason":"schedule","generated_at":"2026-10-13T03:30:27Z"}
allow and block are always arrays, never null. locked and until are the
effective state: a reason of schedule means a weekly window is active,
manual means a lock was set from the dashboard. While locked is true the
device blocks every block value except the allow values (FR-4), so the
allowlist is the exception list. A client must ignore unknown fields, so this
shape can grow.
Status payload (device → service)
{"online":true,"app":"com.spotify.music","at":"2026-10-13T03:30:27Z"}
The will the device registers on the same topic is {"online":false}. A report
with no online field counts as online, because the device just talked to us.
Telemetry payload (device → service)
{"day":"2026-10-13",
"apps":[{"name":"com.spotify.music","seconds":600}],
"domains":[{"name":"youtube.com","seconds":120}]}
A flat single-row form ({"app":"…","seconds":600}) is also accepted. Seconds
are totals for day, in YYYY-MM-DD; a missing or malformed day uses today.
List the MQTT identity of every device (tokens, no secrets):
docker exec webrestrict-control /app/control mqtt-clients
Endpoints (Phase 2–7)
| Method | Path | Notes |
|---|---|---|
GET |
/health |
JSON. status: ok, database check. No auth. |
GET |
/login |
Session login form. |
POST |
/login |
Verifies credentials, sets a signed session cookie. |
POST |
/logout |
Clears the session. |
GET |
/ |
Dashboard: service status, device cards, recent activity. |
GET |
/partials/status |
HTMX fragment, polled every 10 s. |
GET |
/devices/new |
Add-device form. |
POST |
/devices |
Creates a device. |
GET |
/devices/{id}/edit |
Edit-device form. |
POST |
/devices/{id} |
Updates name, child, platform. |
POST |
/devices/{id}/lock |
Locks now; optional minutes (0 = until unlocked). |
POST |
/devices/{id}/unlock |
Unlocks now. |
GET |
/devices/{id}/schedule |
Weekly schedule editor (grid of windows). |
POST |
/devices/{id}/schedule |
Replaces the device's windows; clear=1 clears them. |
GET |
/devices/{id}/lists |
Blocklist / allowlist editor. |
POST |
/devices/{id}/lists |
Adds one entry (`table=block |
POST |
/devices/{id}/lists/{entryID}/delete |
Removes one entry. |
GET |
/devices/{id}/usage |
Usage for one device: a week of daily totals, top apps and top sites. ?week=YYYY-MM-DD selects the week (any day in it; default today). |
Every form POST needs the csrf field from the session cookie. State changes
append to the audit table and republish the device's retained config. Devices
are never hard-deleted (§10 rule 10).
Telemetry and usage (Phase 7)
Usage arrives on restrict/<device>/telemetry and is stored in telemetry:
one row per (device, day, app, domain), where a repeat report for the same key
replaces the earlier total (a rolling daily total, not a delta).
telemetry_daily is the daily rollup — one row per (device, day) — written
together with the detail row and rebuildable from it with control rollup.
Apps and domains are summed into separate columns on purpose: a report may
carry both for the same session, so a single total would double-count the time.
The rollup is derived data, never a second source of truth.
GET /devices/{id}/usage shows one Monday-to-Sunday week: the app total and the
site total per day, then the top 10 apps and top 10 sites for the week. The
screen uses the Family Home Lab tokens, follows the OS light/dark preference
(there is a toggle in the nav bar), and works on a phone browser.
Retention:
- The daily rollup is kept long; the detail table is small and kept too. Nothing is hard-deleted (AGENTS.md §10 rule 10).
- The append-only
auditlog is size-capped at 20 000 rows by the service's maintenance loop, oldest first (§7). It is never edited in place.
Rebuild the rollup from the detail table at any time (idempotent, safe while the service is up; the service also does it at start-up and every 15 minutes):
# inside the container
docker exec webrestrict-control /app/control rollup
# or against a database file on the host
/app/control rollup --db /data/control.db
Schedules, blocklists and allowlists (Phase 6)
A schedule is a set of weekly blocking windows per device: a weekday
(Monday = 0 … Sunday = 6), a local HH:MM start and end, and an end earlier than
the start means the window crosses midnight (Monday 20:00–07:00 ends Tuesday
07:00). A window means restrictions are ON during that time.
The effective state is a manual lock, an active window, or neither. The Control Service computes it and publishes it retained. A manual lock with no end time (“until I unlock”) never lifts; otherwise the later of the manual end and the covering window's end is used. A background loop republishes at the next window boundary, so a window starts and ends on time without a poll.
A blocklist entry is blocked while a window is active or the device is
locked. An allowlist entry (each entry is app or site) is the exception
list: the device blocks every blocklist value except the allowlist. For
example, with com.spotify.music allowlisted and com.youtube.android blocked,
Spotify stays available and YouTube does not. Hostnames are lower-cased; app
values keep their case.
Development
There is no host Go toolchain on .13; use the cached builder image.
make test # go test ./... in golang:1.25-alpine
make vet # go vet ./...
make build # build the container image
make up # docker compose up -d --build
make logs # follow container logs
make mqtt-verify # prove TLS + credentials + ACLs on a throwaway broker
make test and make vet run the container as your own user and keep the module
cache in .gocache/ (gitignored), so nothing in the repository ends up owned by
root. make mqtt-verify never touches the shared Mosquitto: it stages a scratch
broker on 127.0.0.1:18883 and removes it afterwards.
Layout
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup)
internal/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock expiry
schedules.go weekly windows and the effective state
reports.go presence, telemetry, the daily rollup, top lists, audit cap
lists.go allow/block lists and device lookup by topic
migrations/001_init.sql, 002_telemetry_unique.sql, 003_usage_rollup.sql
internal/auth/ signed-cookie session login + CSRF
internal/mqtt/ the broker link: retained config out, reports in
payload.go topic and payload contract for the kids' clients
link.go paho client, subscriptions, publish, TLS verification
internal/web/ HTTP handlers, templates, static assets
devices.go device registry + manual lock/unlock handlers
schedules.go weekly schedule and block/allow editors
usage.go usage screen: week grid, top apps, top sites
templates/ login, dashboard, device form, editors, usage, partials
static/ tokens.css (Family Home Lab), app.css, theme.js, htmx, fonts
Dockerfile own image (multi-stage, CGO-free)
docker-compose.yml own container, port 8791
environment.d/ control-service.env.example (real file gitignored)
deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au
deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
Caddy
deploy/caddy/webrestrict.caddy holds the vhost for
webrestrict.lab.audasmedia.com.au. The terminating Caddy runs on .35
(sam-Ubuntu1); see that file for the two proxy patterns and the apply commands.
Non-goals (Phase 7)
- No group/global list editing from the UI. The data model supports a NULL
device_idthat applies to every device, and the effective state merges it, but the editor manages per-device rows only. - No HTTP telemetry ingest. Devices report over MQTT; an HTTP path arrives if a client needs it.
- No usage charts or export beyond the week grid and the top lists shown.
- No admin API (Phase 8).
- No kids' clients (Phases 4–5). The topic and payload contract above is what they implement. The clients apply the allowlist exception; the Control Service only publishes it.
- No Hub contact (Phase 9).