Adds windows/, the Python client for the kids' Windows computers, and proves both halves of the Phase 4 definition of done on the real box (DESKTOP-TE2RHV2). The client: - subscribes, retained, to its own config on the 8883 TLS device plane with its own broker account (FR-3, FR-6); - keeps the last valid config on disk and loads it before connecting, so a broker outage changes nothing (FR-14); - terminates every process on the blocklist while the device is effectively locked, with the allowlist winning and the client's own tree plus critical system processes protected (FR-4, FR-16); - publishes presence and daily per-app usage; the day's totals are held on disk and republished when the link returns (FR-7); - shows an always-on-top warning window naming what is blocked, why and until when (FR-9). install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's account with an interactive token, and grants that account Modify on the state directory: a file created from an elevated shell inherits Administrators only, and the limited task is otherwise refused with Access denied. Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed inside a scheduled window (pid 16600, then 9724), the device online in the Control Service with locked/until/reason, a telemetry row for the device (app=690s), enforcement continuing with the broker unreachable (192.168.20.13:8884), the warning window on the interactive desktop, and the Scheduled Task registered at logon as New Comp. Domains are deliberately not reported: Windows has no cheap per-process DNS attribution, so the client sends "domains": [] and the gap is documented rather than guessed. 54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil). AGENTS.md §11 is now 10 of 10.
34 KiB
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.
Project status: 10 of 10 phases done. Phases 1–10 are complete, including
Phase 4 — the kids' Windows computer client in windows/ (Python, MQTT
config, process blocking, telemetry, warning overlay, keep-last-state, installed
as a logon Scheduled Task). Evidence: windows/EVIDENCE.md. The enforcement
clients are the kids' Android app in android/ and the Windows client in
windows/. See AGENTS.md §11.
This repository contains Phases 1–10 (AGENTS.md §11):
- 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).
- Overrides: a versioned admin HTTP API for the separate Kotlin admin app
(
lock,unlock,grant, query state) guarded by a bearer token, plus the shared device keycode (one code, every device) that a kid's client can redeem for a short exemption. Both are recorded inoverridesandaudit, and a state change is published retained before the API responds. - Hub API: the project side of the frozen contract
(
central_ai_llm_master/docs/ask-contract.md) —GET /capabilities, read-onlyPOST /ask, andPOST /notify— all behind the Hub bearer token./askanswers from SQLite and writes nothing. - Alerts: a background engine tells Sam through the house Apprise API when
a device reaches its limit (a blocking window turns on, or a manual lock is
applied) or has been offline too long. Every alert is recorded in
alertsand the audit log, with its delivery outcome.
Nothing here touches the router, Pi-hole, or DNS. Enforcement is device-side and kids-only.
Architecture
flowchart LR
SAM["Sam<br/>browser dashboard"]
ADMIN["Admin app<br/>kotlin_phone_app_admin"]
HUB["Hub :8788<br/>read-only /ask"]
CADDY["Caddy on .35<br/>webrestrict.lab.audasmedia.com.au"]
CS["Control Service<br/>Go + SQLite :8791"]
APPRISE["Apprise :8210<br/>alerts + tamper reports"]
subgraph MQ["Mosquitto on .13 - two planes"]
TLS["8883 TLS<br/>per-device creds + topic ACLs"]
PLAIN["1883 plain + anonymous"]
end
PHONE["Kids' Android client<br/>Kotlin, Accessibility + overlay"]
SAM -->|HTTPS| CADDY
CADDY --> CS
ADMIN -->|"Bearer token /api/v1"| CS
HUB -->|"Bearer token /ask"| CS
CS -->|"retained config"| TLS
TLS -->|"own config topic"| PHONE
PHONE -->|"status + telemetry"| TLS
TLS -->|reports| CS
CS --> APPRISE
Interactive map: https://maps.lab.audasmedia.com.au/internet_use_control/docs/
(Archify, 9/9 showcase checks). The Mermaid source is docs/architecture.mmd,
the rendered image is docs/diagrams/architecture.png, and the Archify
specification is docs/archify/architecture.architecture.json.
Documentation set
| Layer | Artefact | Audience |
|---|---|---|
| Gitea | this README.md |
developers |
| Obsidian | portfolio note in 010 inbox/ on .27, filed by the sorter |
humans |
| Mermaid + Archify | docs/architecture.mmd, docs/diagrams/architecture.png, docs/architecture-map.html |
architects |
| Vikunja | project Internet Use Control (id 20), copy in docs/vikunja-tasks.json |
humans |
| Outline | docs/outline/00-index.md … 04-agent-runbook.md (Simplified Technical English) |
AI agents |
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.7.0-phase9",...}
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_ADMIN_API_TOKEN |
— | Bearer token for the versioned admin API (Phase 8); empty disables it with 503 |
CONTROL_DEVICE_KEYCODE_HASH |
— | bcrypt hash of the shared device keycode, seeded into SQLite at start-up |
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 |
CONTROL_HUB_TOKEN |
— | Bearer token the Hub sends to /capabilities, /ask and /notify (Phase 9); empty disables them with 503 |
CONTROL_APPRISE_URL |
http://192.168.20.35:8210 |
House Apprise API that alerts and notifications are delivered through |
CONTROL_APPRISE_KEY |
— | Stored Apprise configuration key (option A) |
CONTROL_APPRISE_URLS |
— | Stateless Apprise destination list (option B, lab default) |
CONTROL_APPRISE_TOKEN |
— | Optional bearer token for a protected Apprise API |
CONTROL_ALERTS_ENABLED |
true |
Turn the limit/offline alert engine on or off |
CONTROL_ALERT_OFFLINE_AFTER |
30m |
How long a device may be unreachable before Sam is told |
CONTROL_ALERT_INTERVAL |
30s |
How often the alert engine re-evaluates device state |
Generate a bcrypt hash without host Go:
docker run --rm --entrypoint /app/control webrestrict/control:0.7.0-phase9 \
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). The broker has two planes. They are separate listeners on the
same container, and only one of them is trusted.
| Plane | Port | Security | Who uses it |
|---|---|---|---|
| Plain | 1883 | Plain text, anonymous | Legacy and other house consumers. Unchanged, and not used by this project. |
| TLS | 8883 | TLS, per-device credentials, topic ACLs | The Control Service and every kid device. |
On the 8883 plane a device may read only its own
restrict/<device>/config topic, and write only its own
restrict/<device>/status and restrict/<device>/telemetry topics. The Control
Service is the only identity that may publish config. Without that ACL a child
could publish a fake "unlocked". The listener, the password file and the ACL are
in deploy/mosquitto/conf.d/webrestrict.conf and webrestrict.acl;
deploy/mosquitto/README.md holds the design, the live apply and the rollback.
Caveat to carry forward: per_listener_settings true is deprecated in Mosquitto
2.1 and is removed in 3.0. Pin the broker image tag before an upgrade.
| 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 or the admin API, and grant
means a time-limited exemption is active, in which case grant_until (RFC3339)
is set and locked is false. 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–9)
| 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). |
Admin API (Phase 8)
All admin endpoints are under /api/v1, use JSON, and require
Authorization: Bearer $CONTROL_ADMIN_API_TOKEN. This token is separate from
the dashboard session login. Responses use one envelope:
{"ok":true,"action":"lock","device":{"id":1,"name":"Harry's phone",
"child":"Harry","platform":"android","topic":"restrict/harry_s_phone_1/config",
"online":true,"last_seen":null,"locked":true,"until":"2026-10-13T21:00:00Z",
"grant_until":null,"reason":"manual","updated_at":"2026-10-13T10:29:59Z"}}
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
/api/v1/admin/devices |
— | Every device with its effective state. |
GET |
/api/v1/admin/devices/{id} |
— | One device. 404 if unknown. |
POST |
/api/v1/admin/devices/{id}/lock |
{"minutes":30} or {"until":"2026-10-13T21:00:00+11:00"} or {} |
Lock now. {} means until unlocked; the two time fields are mutually exclusive. |
POST |
/api/v1/admin/devices/{id}/unlock |
{} or empty |
Clear the manual lock and any grant. |
POST |
/api/v1/admin/devices/{id}/grant |
{"minutes":30} or {"until":"…"} |
Grant time: unlocked until then, even during a schedule window, then the schedule re-applies. A time is required. |
POST |
/api/v1/device/override |
{"device_id":1,"keycode":"…","minutes":15} |
The on-device keycode path. No bearer token. Wrong codes are throttled and audited. |
minutes is capped (grants at 24 h, locks at 7 days). Errors are
{"ok":false,"error":"…"} with 400 (bad body), 401 (bad token or
keycode), 404 (unknown device), 429 (keycode throttled) or 503 (admin API
or keycode not configured). A 200 means the state was stored and the
retained config was accepted by the broker, so the device changes without a
poll.
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).
Hub API (Phase 9)
The project side of the frozen contract
(central_ai_llm_master/docs/ask-contract.md). All three endpoints require
Authorization: Bearer $CONTROL_HUB_TOKEN, a shared token separate from the
dashboard session and the admin API. With no token set they answer 503.
| Method | Path | Notes |
|---|---|---|
GET |
/capabilities |
{"project":"internet_control","topics":[…],"people":[…]}. The Hub reads it to route a question; the name matches the Hub's routing.md row. |
POST |
/ask |
Read-only. Body is the contract's AskRequest; the reply is always 200 with an answer or answer:null plus a reason. |
POST |
/notify |
Accepts the contract's {"from","level","message","person"} and delivers it to Sam through Apprise. |
/ask answers these intents from SQLite, deterministically and without an LLM:
curl -sS -H "Authorization: Bearer $CONTROL_HUB_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"question":"how long have the kids been on screens today?","asker":"sam"}' \
http://127.0.0.1:8791/ask
# {"answer":"The kids have had 1h 22m of screen time today.",
# "data":{"day":"2026-10-13","total_seconds":4925,
# "by_device":[{"device":"Harry's phone","child":"Harry",
# "seconds":4925,"app_seconds":4925,"domain_seconds":4800}]},
# "expires":"2026-10-13T05:52:33Z"}
- Screen time: a device-day is measured by its foreground-app total; the domain total is used only when a device reports no apps at all, so a report that carries both is not double-counted.
- What is blocked: the currently blocked devices and until when.
- Presence: which devices are online, and for how long an offline one has been down.
- One person at most: the
personfield (or a child named in the question) scopes the answer; onlysammay ask.
/ask is read-only (AGENTS.md §10 rule 9). It calls only store read methods
and writes nothing, including no audit row; TestAskWritesNothing asserts it.
Alerts (Phase 9)
A background engine (internal/alert) evaluates device state every
CONTROL_ALERT_INTERVAL and raises one alert per transition:
- Limit hit — a device became blocked (a weekly window turned on, or a manual lock was applied), or
- Offline too long — presence is false and the last report is older than
CONTROL_ALERT_OFFLINE_AFTER. A device that has never reported is not alerted.
The alert is recorded in alerts before delivery, then sent through
Apprise. The delivered flag and any error are written back, so a delivery
failure is visible rather than silent. The dedup state in alert_state makes an
alert fire once per transition, and a restart seeds that state without
replaying an old limit.
POST /notify uses the same Apprise path for on-demand messages (a tamper
report from a phone client, FR-17, will use it).
Operational views of the same data:
docker exec webrestrict-control /app/control alerts --limit 10
docker exec webrestrict-control /app/control usage --day 2026-10-13 --detail
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.
Overrides (Phase 8)
An override is a deliberate change to a device's desired state. There are two paths (AGENTS.md §4 FR-10):
- The admin app, over the versioned HTTP API above. It is the remote lock/unlock/grant surface.
- The device keycode, entered on the kid's device. One code is shared by
every device (§10 rule 3). A correct code grants a short exemption through
POST /api/v1/device/override; the clients in Phases 4–5 will call it.
Semantics:
- lock sets the manual lock (
state.locked). With no end it never lifts; with an end it lifts at that time. - unlock clears the manual lock and any grant, returning the device to its schedule.
- grant unlocks the device until its end, even inside a schedule window, and
records
grant_until. When the grant ends the weekly schedule applies again — the Control Service treats the grant end as a republish boundary, so the device re-locks on time.
Every override is written to the overrides table and to the append-only
audit log (FR-15), with the actor: admin-api, device-keycode, or sam
from the dashboard. A lock clears a grant and a grant clears a lock, so the two
cannot contradict each other.
The shared device keycode
The code is never stored or logged in plaintext. It is a bcrypt hash in SQLite.
CONTROL_DEVICE_KEYCODE_HASH seeds the first run; after that the stored code is
authoritative, so a change made with control keycode survives a restart:
# Set a code you choose (read from stdin, so it never reaches the process list).
docker exec -i webrestrict-control /app/control keycode <<< '246810'
# Or generate a random six-digit code and print it once to put on each device.
docker exec webrestrict-control /app/control keycode --generate
The keycode endpoint is throttled: five wrong codes from one address freeze that
address for five minutes, and every attempt is audited. It returns 503 when no
keycode is configured.
Prove a lock reaches a device in under a second
The Phase 8 definition of done. make verify-phase8 issues a lock through the
API and measures the time until a broker subscriber receives the retained
restrict/<device>/config message:
make verify-phase8
It needs the control-service broker credentials
(deploy/mosquitto/clients.env) and a device that exists. It never restarts or
reconfigures anything shared.
Prove the Hub answers and the alerts deliver
The Phase 9 definition of done. make verify-phase9 calls /capabilities and
/ask, checks the answer against an independent count from the telemetry detail
table, triggers a limit alert and an offline-too-long alert, and posts a
notification:
make verify-phase9
Each alert is checked in the alerts table for delivered; a recorded but
undelivered alert fails the script with the delivery error shown. It needs
CONTROL_HUB_TOKEN, CONTROL_ADMIN_API_TOKEN, a working Apprise destination
and the device broker credentials. It leaves the device unlocked and online.
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 verify-phase8 # prove an API lock reaches a device in under a second
make verify-phase9 # prove the Hub answers and the Apprise alerts deliver
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.
Kids' Windows computer client (Phase 4)
windows/ is the kids' computer client: Python, one process, installed as a
Scheduled Task at logon. It subscribes, retained, to its own config on the 8883
device plane, and while the device is locked it terminates any process on the
blocklist that is not on the allowlist (FR-3, FR-4).
- Keep last state (FR-14). The last valid config is cached on disk and loaded before the first connect, so a broker outage changes nothing.
- Presence (FR-6). A retained
{"online": true}status plus the broker's Last Will{"online": false}; a force-stop or a power-off shows up as an offline device. - Usage (FR-7). Daily per-app seconds, sampled from the apps that own a visible window, published as day totals on the device's telemetry topic.
- Warning (FR-9). An always-on-top Tkinter window naming what is blocked, why and until when.
- Domains are not reported. Windows has no cheap per-process DNS
attribution, so the client sends
"domains": []rather than a guess; there is no Windows site filter in this phase.
windows/EVIDENCE.md holds the transcript of the Definition of Done on the real
machine: a listed process killed inside a scheduled window, the device online in
this service, a telemetry row for it, enforcement continuing with the broker
unreachable, and the Scheduled Task registered at logon. Run the client's tests
with make windows-test.
Kids' Android client (Phase 5.1)
android/ is the kids' phone app: a normal Gradle project with its own
gradlew. It blocks by interruption — an Accessibility Service sees the
foreground package and a full-screen overlay covers any app on the blocklist
while the device is locked (FR-16). There is no Device Owner, no factory
reset and no managed profile; the child can disable it, and the enforcement is
the tamper alert (FR-17).
- Telemetry comes from Usage Access (
UsageCollector.kt), reported per app and per day over the device's own telemetry topic. - Site filtering uses a device-local
VpnService(SiteFilterVpnService.kt,SiteFilter.kt,DnsMessage.kt,IpPacket.kt). No router, no Pi-hole, no change to the house DNS (FR-18). - The block decision is in
BlockEngine.kt, from the config payload inDeviceConfig.kt.
A tamper report rides the device's own status topic, because that is the only topic its broker ACL may write:
{"online":true,"tamper":{"kind":"accessibility_disabled","detail":"the service was destroyed"}}
internal/mqtt parses it, internal/alert records a tamper alert and delivers
it through Apprise, so it reaches Sam. The client also reports a revoked overlay
permission and a force-stop on the next start. It keeps the last config on disk,
so a broker-less device keeps its rules (FR-14).
The block path and the tamper alert are proven on the headless pixel35
emulator on .27; see android/README.md and android/tools/emu-proof.sh.
Layout
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage)
windows/ kids' Windows computer client (Python; process block, overlay, keep-last-state)
webrestrict/ MQTT link, block rule, enforcer, telemetry, warning window, agent loop
install-scheduled-task.ps1 logon Scheduled Task (+ the state directory ACL)
EVIDENCE.md the Phase 4 proof transcript from the real machine
android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay)
app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report
tools/emu-proof.sh headless emulator proof (block overlay + tamper)
internal/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock + grant expiry
schedules.go weekly windows and the effective state (incl. grants)
reports.go presence, telemetry, the daily rollup, top lists, audit cap
alerts.go alert records and per-device dedup state
lists.go allow/block lists and device lookup by topic
overrides.go override history + the shared device keycode
migrations/001_init.sql … 005_alerts.sql
internal/auth/ signed-cookie session login + CSRF
internal/apprise/ Apprise API client (alerts and notifications)
internal/alert/ the limit/offline alert engine (transition -> Apprise)
internal/hub/ the Hub contract: capabilities, read-only answers, notify
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
admin_api.go versioned admin API + the device keycode endpoint
hub_api.go /capabilities, /ask (read-only), /notify
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
deploy/verify-phase8.sh lock latency proof
deploy/verify-phase9.sh Hub + alerts proof
docs/architecture.mmd Mermaid source for the diagram in this README
docs/diagrams/ rendered Mermaid PNG
docs/archify/ Archify specification for the interactive map
docs/architecture-map.html interactive Archify map (published to the maps site)
docs/outline/ AI-context collection (Simplified Technical English)
docs/vikunja-tasks.json copy of the Vikunja board tasks
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 10)
- The Windows client does not filter sites. Blocking is by process only. There
is no per-process DNS attribution on Windows without a second dependency or an
administrator-only channel, so the client publishes
"domains": []; FR-18 site filtering is satisfied on the phone only (android/). Seewindows/README.md. - No grant button in the dashboard. Grant is on the admin API; the dashboard has lock and unlock. Adding the button is a later UI change.
- No per-child or per-device keycode. One code, every device (§10 rule 3).
- No HTTP telemetry ingest. Telemetry arrives over MQTT only. The kids' phone client exists (Phase 5) and the kids' Windows client exists (Phase 4).
- No Alerts screen in the dashboard yet. The alert history is in
alertsand is readable withcontrol alerts; the screen is a later UI change. - The Hub is wired to this service: the running Hub on
.13:8788routes screen-time questions here and returns the answer. Registration is host configuration, not code: the project is in the Hub'sHUB_PROJECTSandHUB_PROJECT_INTERNET_CONTROL_TOKEN, and the Hub's human-ownedrouting.mdrow already names the projectinternet_control(which is why/capabilitiesuses that name).
