- Weekly schedule editor per device: a Monday-Sunday grid of blocking windows, including windows that cross midnight. - Blocklist/allowlist editor per device: app (Android package / Windows process) and site (hostname) entries, tagged by the device platform. - Effective desired state: a manual lock, an active schedule window, or neither. While blocked the allowlist is honoured (Spotify stays while other apps are blocked). Published retained with reason schedule/manual. - Boundary-aware republish loop so a window turns on and off on time, and manual lock expiry is cleared promptly. - CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so wall-clock windows are evaluated in the family zone, not the container. - Every change is written to the append-only audit log (FR-15). Co-Authored-By: Paperclip <noreply@paperclip.ing>
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 6 — Schedules, blocklists, allowlists (AGENTS.md §11), on top of Phases 1–3:
- 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. - Every edit is written to the append-only audit log (FR-15).
Enforcement itself (the kids' clients), the admin API, usage screens 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.4.0-phase6",...}
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.4.0-phase6 \
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–3)
| 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. |
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).
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)
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 and telemetry written from broker reports
lists.go allow/block lists and device lookup by topic
migrations/001_init.sql, 002_telemetry_unique.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
templates/ login, dashboard, device form, editors, HTMX partials
static/ tokens.css (Family Home Lab), app.css, htmx, Inter 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 6)
- 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 usage screens (Phase 7). Telemetry is stored, not yet 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).