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:
133
README.md
133
README.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user