Phase 10 (the last phase of this project) is the project-docs pass. Update the Gitea README, add the Mermaid source and rendered PNG, build the interactive Archify map, add the Vikunja board tasks, and write the Outline AI-context collection in Simplified Technical English. Deliverables: - README.md: status header, Phase 4 gap statement, "Architecture" section with embedded Mermaid + image + Archify link, "Documentation set" table, an explicit two-plane MQTT section (1883 plain+anonymous, 8883 TLS+ACLs), and the Android client's Usage Access and VpnService paths. - AGENTS.md section 11: tick Phase 10, leave Phase 4 unticked, status line now "9 of 10 done - only Phase 4 outstanding". - docs/architecture.mmd + docs/diagrams/architecture.png (mmdc). - docs/archify/architecture.architecture.json + docs/architecture-map.html (Archify, showcase, 9/9 checks, 0 errors). - docs/outline/00-index.md .. 04-agent-runbook.md (STE). - docs/vikunja-tasks.json (Vikunja project "Internet Use Control", id 20). - Obsidian note written to 010 inbox on .27; the obsidian-sorter proposed a folder and waits for the vault owner's approval. Phase 4 (Python Windows computer client) is deliberately deferred and is NOT built. Every document says so. Co-Authored-By: Paperclip <noreply@paperclip.ing>
644 lines
32 KiB
Markdown
644 lines
32 KiB
Markdown
# 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: 9 of 10 phases done.** Phases 1–3 and 5–10 are complete (Phase 10,
|
||
this documentation set, included). **Phase 4 — the Windows computer client — is
|
||
deliberately deferred and is NOT built.** There is no Python computer client and
|
||
no Windows process blocking in this repository. The only enforcement client is
|
||
the kids' Android app in `android/`. See `AGENTS.md` §11.
|
||
|
||
This repository contains **Phases 1–3 and 5–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.css` copied 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 `state` and written
|
||
to the append-only `audit` log (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) and `site` (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 in `overrides` and `audit`, 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-only `POST /ask`, and `POST /notify` — all behind the Hub bearer token.
|
||
`/ask` answers 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 `alerts`
|
||
and the audit log, with its delivery outcome.
|
||
|
||
Nothing here touches the router, Pi-hole, or DNS. Enforcement is device-side and
|
||
kids-only.
|
||
|
||
## Architecture
|
||
|
||

|
||
|
||
```mermaid
|
||
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`)
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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>` is `restrict/<slug>_<id>` from `devices.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)
|
||
|
||
```json
|
||
{"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)
|
||
|
||
```json
|
||
{"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)
|
||
|
||
```json
|
||
{"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):
|
||
|
||
```bash
|
||
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|allow`, `kind=app|site`, `value`). |
|
||
| `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:
|
||
|
||
```json
|
||
{"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:
|
||
|
||
```bash
|
||
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 `person` field (or a child named in the question)
|
||
scopes the answer; only `sam` may 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:
|
||
|
||
```bash
|
||
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 `audit` log 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):
|
||
|
||
```bash
|
||
# 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):
|
||
|
||
1. **The admin app**, over the versioned HTTP API above. It is the remote
|
||
lock/unlock/grant surface.
|
||
2. **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:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
```bash
|
||
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' 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 in
|
||
`DeviceConfig.kt`.
|
||
|
||
A tamper report rides the device's own status topic, because that is the only
|
||
topic its broker ACL may write:
|
||
|
||
```json
|
||
{"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)
|
||
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)
|
||
|
||
- **Phase 4, the Windows computer client, is not built.** It is deliberate. The
|
||
Python client, process blocking, and the Windows overlay warning do not exist.
|
||
The project stands at **9 of 10** because of this single gap.
|
||
- 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). The kids' computer client does not (Phase 4, deferred).
|
||
- No Alerts screen in the dashboard yet. The alert history is in `alerts` and is
|
||
readable with `control alerts`; the screen is a later UI change.
|
||
- The Hub is wired to this service: the running Hub on `.13:8788` routes
|
||
screen-time questions here and returns the answer. Registration is host
|
||
configuration, not code: the project is in the Hub's `HUB_PROJECTS` and
|
||
`HUB_PROJECT_INTERNET_CONTROL_TOKEN`, and the Hub's human-owned `routing.md`
|
||
row already names the project `internet_control` (which is why
|
||
`/capabilities` uses that name).
|