Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)

Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.

- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
  deterministic, read-only Answerer. It answers screen time today/yesterday,
  what is blocked now, and device presence, scoped to one child or all kids.
  Screen time is the foreground-app total per device-day; the domain total is
  used only when a device reports no apps, so a report carrying both is never
  double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
  stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
  Apprise alert per transition: a limit hit (a device becomes blocked) or a
  device offline longer than the threshold. A never-seen device is never
  alerted, and the first observation seeds state without replaying an old
  limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
  `alert_state` (per-device dedup). New usage reads from the telemetry detail
  table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
  shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
  /notify accepts the contract's {from,level,message,person} and delivers it
  through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
  CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
  `usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
  version 0.7.0-phase9.

Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
  had 1h 22m of screen time today." (total_seconds=4925); the independent
  count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
  raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
  three report delivered=1 in `alerts`.

Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 16:41:07 +11:00
parent 4f233a8012
commit 3cf353d933
23 changed files with 2881 additions and 24 deletions

133
README.md
View File

@@ -5,8 +5,8 @@ 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 8 — Overrides** (AGENTS.md §11), on
top of Phases 1–7:
This repository currently contains **Phase 9 — Hub and alerts** (AGENTS.md §11),
on top of Phases 1–8:
- Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned
@@ -44,9 +44,17 @@ top of Phases 1–7:
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.
Enforcement itself (the kids' clients) and the Hub arrive in later phases.
Nothing here touches the router, Pi-hole, or DNS.
Enforcement itself (the kids' clients) arrives in later phases. Nothing here
touches the router, Pi-hole, or DNS.
## Quick start (on `.13`)
@@ -62,7 +70,7 @@ docker compose up -d --build
# 3. Prove it.
curl -s http://127.0.0.1:8791/health
# {"status":"ok","service":"control-service","version":"0.6.0-phase8",...}
# {"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
@@ -97,11 +105,19 @@ reads only the process environment.
| `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.6.0-phase8 \
docker run --rm --entrypoint /app/control webrestrict/control:0.7.0-phase9 \
hash-password 'your-password'
```
@@ -184,7 +200,7 @@ List the MQTT identity of every device (tokens, no secrets):
docker exec webrestrict-control /app/control mqtt-clients
```
## Endpoints (Phase 2–7)
## Endpoints (Phase 2–9)
| Method | Path | Notes |
|---|---|---|
@@ -240,6 +256,72 @@ 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_use_control","topics":[…],"people":[…]}`. The Hub reads it to route a question. |
| `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`:
@@ -353,6 +435,22 @@ 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.
@@ -365,6 +463,7 @@ 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
@@ -375,22 +474,27 @@ broker on `127.0.0.1:18883` and removes it afterwards.
## Layout
```
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode)
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage)
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 … 004_overrides_grant.sql
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
@@ -400,6 +504,8 @@ 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
```
## Caddy
@@ -408,11 +514,16 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
`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 8)
## Non-goals (Phase 9)
- 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 and no kids' clients (Phases 4–5). The topic and
payload contract above is what they implement.
- No Hub contact (Phase 9).
- 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 not wired to this service yet. `/ask` is proven directly with the
exact Hub request shape (`make verify-phase9`); registering the project with
the running Hub (its `HUB_PROJECTS` and a project token) and restarting the
`hub` container is a host change pending approval.