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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ deploy/mosquitto/clients.env
|
|||||||
|
|
||||||
# Pi agent generated memory (local state, not source)
|
# Pi agent generated memory (local state, not source)
|
||||||
.pi/memory-vault/
|
.pi/memory-vault/
|
||||||
|
|
||||||
|
# Backups of the real env file (mode 600, secrets).
|
||||||
|
environment.d/*.bak*
|
||||||
|
|||||||
11
Makefile
11
Makefile
@@ -1,5 +1,5 @@
|
|||||||
IMAGE ?= webrestrict/control:0.6.0-phase8
|
IMAGE ?= webrestrict/control:0.7.0-phase9
|
||||||
BUILD_VERSION ?= 0.6.0-phase8
|
BUILD_VERSION ?= 0.7.0-phase9
|
||||||
|
|
||||||
# Bounded docker helper for the Go toolchain. The container runs as the current
|
# Bounded docker helper for the Go toolchain. The container runs as the current
|
||||||
# user, so the repository and the module cache stay owned by you.
|
# user, so the repository and the module cache stay owned by you.
|
||||||
@@ -29,6 +29,7 @@ help:
|
|||||||
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/"
|
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/"
|
||||||
@echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker"
|
@echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker"
|
||||||
@echo "make verify-phase8 prove an API lock reaches a device in under a second"
|
@echo "make verify-phase8 prove an API lock reaches a device in under a second"
|
||||||
|
@echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(GO_RUN) go test ./...
|
$(GO_RUN) go test ./...
|
||||||
@@ -82,3 +83,9 @@ mqtt-verify: mqtt-config
|
|||||||
# broker subscriber receives the retained config. Never touches the 1883 plane.
|
# broker subscriber receives the retained config. Never touches the 1883 plane.
|
||||||
verify-phase8:
|
verify-phase8:
|
||||||
deploy/verify-phase8.sh
|
deploy/verify-phase8.sh
|
||||||
|
|
||||||
|
# Phase 9 definition of done: /capabilities, /ask, the alert engine and /notify
|
||||||
|
# against the deployed service, with Apprise delivery checked in the alerts
|
||||||
|
# table. Never touches the 1883 plane.
|
||||||
|
verify-phase9:
|
||||||
|
deploy/verify-phase9.sh
|
||||||
|
|||||||
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
|
per-device state and usage, and serves the web dashboard. Only it publishes
|
||||||
device config.
|
device config.
|
||||||
|
|
||||||
This repository currently contains **Phase 8 — Overrides** (AGENTS.md §11), on
|
This repository currently contains **Phase 9 — Hub and alerts** (AGENTS.md §11),
|
||||||
top of Phases 1–7:
|
on top of Phases 1–8:
|
||||||
|
|
||||||
- Go service with its own image (`Dockerfile`) and compose file.
|
- Go service with its own image (`Dockerfile`) and compose file.
|
||||||
- SQLite schema for the whole data model (§7), applied through versioned
|
- 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
|
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
|
for a short exemption. Both are recorded in `overrides` and `audit`, and a
|
||||||
state change is published retained before the API responds.
|
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.
|
Enforcement itself (the kids' clients) arrives in later phases. Nothing here
|
||||||
Nothing here touches the router, Pi-hole, or DNS.
|
touches the router, Pi-hole, or DNS.
|
||||||
|
|
||||||
## Quick start (on `.13`)
|
## Quick start (on `.13`)
|
||||||
|
|
||||||
@@ -62,7 +70,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
# 3. Prove it.
|
# 3. Prove it.
|
||||||
curl -s http://127.0.0.1:8791/health
|
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
|
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_PASSWORD` | — | its password |
|
||||||
| `CONTROL_MQTT_CA_FILE` | `/etc/webrestrict/tls/ca.crt` | PEM bundle that verifies the broker (TLS only) |
|
| `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_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:
|
Generate a bcrypt hash without host Go:
|
||||||
|
|
||||||
```bash
|
```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'
|
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
|
docker exec webrestrict-control /app/control mqtt-clients
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoints (Phase 2–7)
|
## Endpoints (Phase 2–9)
|
||||||
|
|
||||||
| Method | Path | Notes |
|
| 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
|
append to the `audit` table and republish the device's retained config. Devices
|
||||||
are never hard-deleted (§10 rule 10).
|
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)
|
## Telemetry and usage (Phase 7)
|
||||||
|
|
||||||
Usage arrives on `restrict/<device>/telemetry` and is stored in `telemetry`:
|
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
|
(`deploy/mosquitto/clients.env`) and a device that exists. It never restarts or
|
||||||
reconfigures anything shared.
|
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
|
## Development
|
||||||
|
|
||||||
There is no host Go toolchain on `.13`; use the cached builder image.
|
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 logs # follow container logs
|
||||||
make mqtt-verify # prove TLS + credentials + ACLs on a throwaway broker
|
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-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
|
`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
|
## 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/config/ environment configuration
|
||||||
internal/store/ SQLite open, migrations, audit, devices + state
|
internal/store/ SQLite open, migrations, audit, devices + state
|
||||||
devices.go device registry, desired state, lock + grant expiry
|
devices.go device registry, desired state, lock + grant expiry
|
||||||
schedules.go weekly windows and the effective state (incl. grants)
|
schedules.go weekly windows and the effective state (incl. grants)
|
||||||
reports.go presence, telemetry, the daily rollup, top lists, audit cap
|
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
|
lists.go allow/block lists and device lookup by topic
|
||||||
overrides.go override history + the shared device keycode
|
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/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
|
internal/mqtt/ the broker link: retained config out, reports in
|
||||||
payload.go topic and payload contract for the kids' clients
|
payload.go topic and payload contract for the kids' clients
|
||||||
link.go paho client, subscriptions, publish, TLS verification
|
link.go paho client, subscriptions, publish, TLS verification
|
||||||
internal/web/ HTTP handlers, templates, static assets
|
internal/web/ HTTP handlers, templates, static assets
|
||||||
devices.go device registry + manual lock/unlock handlers
|
devices.go device registry + manual lock/unlock handlers
|
||||||
admin_api.go versioned admin API + the device keycode endpoint
|
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
|
schedules.go weekly schedule and block/allow editors
|
||||||
usage.go usage screen: week grid, top apps, top sites
|
usage.go usage screen: week grid, top apps, top sites
|
||||||
templates/ login, dashboard, device form, editors, usage, partials
|
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)
|
environment.d/ control-service.env.example (real file gitignored)
|
||||||
deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au
|
deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au
|
||||||
deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
|
deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
|
||||||
|
deploy/verify-phase8.sh lock latency proof
|
||||||
|
deploy/verify-phase9.sh Hub + alerts proof
|
||||||
```
|
```
|
||||||
|
|
||||||
## Caddy
|
## Caddy
|
||||||
@@ -408,11 +514,16 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier
|
|||||||
`webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35`
|
`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.
|
(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
|
- 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.
|
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 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
|
- No HTTP telemetry ingest and no kids' clients (Phases 4–5). The topic and
|
||||||
payload contract above is what they implement.
|
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.
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/alert"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/mqtt"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/mqtt"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/web"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/web"
|
||||||
@@ -66,6 +69,12 @@ func main() {
|
|||||||
case "rollup":
|
case "rollup":
|
||||||
rollup(os.Args[2:])
|
rollup(os.Args[2:])
|
||||||
return
|
return
|
||||||
|
case "alerts":
|
||||||
|
alertsCommand(os.Args[2:])
|
||||||
|
return
|
||||||
|
case "usage":
|
||||||
|
usageCommand(os.Args[2:])
|
||||||
|
return
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Println(version)
|
fmt.Println(version)
|
||||||
return
|
return
|
||||||
@@ -178,6 +187,22 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 9: the alert engine turns a limit hit or a too-long offline spell
|
||||||
|
// into one Apprise message to Sam (FR-13). It is optional; /ask and
|
||||||
|
// /notify keep working without it.
|
||||||
|
if cfg.AlertsEnabled {
|
||||||
|
engine := alert.New(st, srv.Apprise(), log, cfg.AlertOfflineAfter, cfg.ScheduleLocation)
|
||||||
|
engine.Interval = cfg.AlertInterval
|
||||||
|
go engine.Run(ctx)
|
||||||
|
if !cfg.AppriseConfigured() {
|
||||||
|
log.Warn("alerts are enabled but Apprise is not configured; alerts will be recorded as undelivered")
|
||||||
|
} else {
|
||||||
|
log.Info("alert engine started", "offline_after", cfg.AlertOfflineAfter.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Info("alert engine disabled")
|
||||||
|
}
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
@@ -305,6 +330,122 @@ func runMaintenance(ctx context.Context, log *slog.Logger, st *store.Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alertsCommand prints the most recent alerts, newest first, one per line as
|
||||||
|
// tab-separated fields:
|
||||||
|
//
|
||||||
|
// <id>\t<kind>\t<delivered|pending>\t<created>\t<device>\t<message>
|
||||||
|
//
|
||||||
|
// It is a read-only operational view. The Phase 9 verification script uses it
|
||||||
|
// to check that an alert was raised and delivered.
|
||||||
|
func alertsCommand(args []string) {
|
||||||
|
dbPath := env("CONTROL_DB_PATH", "/data/control.db")
|
||||||
|
limit := 20
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--db", "-db":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
dbPath = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--limit", "-limit":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
if n, err := strconv.Atoi(args[i+1]); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "control:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if err := st.Migrate(context.Background()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "control:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
list, err := st.ListAlerts(context.Background(), limit)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "control:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, a := range list {
|
||||||
|
delivered := "pending"
|
||||||
|
if a.Delivered {
|
||||||
|
delivered = "delivered"
|
||||||
|
}
|
||||||
|
device := a.DeviceName
|
||||||
|
if device == "" {
|
||||||
|
device = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%d\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
a.ID, a.Kind, delivered, a.CreatedAt.Format(time.RFC3339), device, a.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageCommand prints one day's per-device screen time. --detail reads the raw
|
||||||
|
// telemetry detail table instead of the derived rollup, so an operator can
|
||||||
|
// check the daily answer independently.
|
||||||
|
func usageCommand(args []string) {
|
||||||
|
dbPath := env("CONTROL_DB_PATH", "/data/control.db")
|
||||||
|
day := time.Now().Format("2006-01-02")
|
||||||
|
detail := false
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--db", "-db":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
dbPath = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--day", "-day":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
day = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case "--detail", "-detail":
|
||||||
|
detail = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "control:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := st.Migrate(ctx); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "control:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
source := "rollup"
|
||||||
|
var rows []store.DeviceDayUsage
|
||||||
|
if detail {
|
||||||
|
source = "detail"
|
||||||
|
rows, err = st.DetailUsageByDeviceForDay(ctx, day)
|
||||||
|
} else {
|
||||||
|
rows, err = st.UsageByDeviceForDay(ctx, day)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "control:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("day=%s source=%s\n", day, source)
|
||||||
|
total := 0
|
||||||
|
for _, u := range rows {
|
||||||
|
screen := hub.ScreenSeconds(u.AppSeconds, u.DomainSeconds)
|
||||||
|
total += screen
|
||||||
|
fmt.Printf("%s\t%s\tapp=%d\tdomain=%d\tscreen=%d\n",
|
||||||
|
u.Name, u.Child, u.AppSeconds, u.DomainSeconds, screen)
|
||||||
|
}
|
||||||
|
fmt.Printf("total_screen=%d\n", total)
|
||||||
|
}
|
||||||
|
|
||||||
// rollup rebuilds the daily usage rollup from the telemetry detail table. It is
|
// rollup rebuilds the daily usage rollup from the telemetry detail table. It is
|
||||||
// idempotent and safe to run while the service is up; it exits non-zero on a
|
// idempotent and safe to run while the service is up; it exits non-zero on a
|
||||||
// failure so a script can tell.
|
// failure so a script can tell.
|
||||||
|
|||||||
203
deploy/verify-phase9.sh
Executable file
203
deploy/verify-phase9.sh
Executable file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Phase 9 definition of done: prove the Hub surface answers correctly and that
|
||||||
|
# the Apprise alerts fire and are delivered.
|
||||||
|
#
|
||||||
|
# It exercises the deployed Control Service, not a test double:
|
||||||
|
#
|
||||||
|
# 1. GET /capabilities (the Hub's routing read)
|
||||||
|
# 2. POST /ask "how long have the kids been on screens today?"
|
||||||
|
# 3. the same total independently, from the telemetry detail table
|
||||||
|
# 4. a limit-hit alert, by locking a device through the admin API
|
||||||
|
# 5. an offline-too-long alert, by publishing a device status over MQTT
|
||||||
|
# 6. POST /notify (accepted notification -> Apprise)
|
||||||
|
#
|
||||||
|
# Steps 4-6 require `CONTROL_ALERTS_ENABLED=true` and a working Apprise
|
||||||
|
# destination. The script checks the `alerts` table for `delivered` rows, so a
|
||||||
|
# recorded-but-undelivered alert is a failure, with the delivery error shown.
|
||||||
|
#
|
||||||
|
# The device is unlocked and marked online again at the end.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make verify-phase9
|
||||||
|
# DEVICE_ID=1 deploy/verify-phase9.sh
|
||||||
|
#
|
||||||
|
# Bounded docker commands only. Nothing shared is restarted or reconfigured.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
SERVICE_URL="${CONTROL_BASE_URL:-http://127.0.0.1:8791}"
|
||||||
|
ENV_FILE="${CONTROL_ENV_FILE:-environment.d/control-service.env}"
|
||||||
|
CLIENTS_FILE="${MQTT_CLIENTS_FILE:-deploy/mosquitto/clients.env}"
|
||||||
|
CA_FILE="${MQTT_CA_FILE:-deploy/mosquitto/tls/ca.crt}"
|
||||||
|
CONTAINER="${CONTROL_CONTAINER:-webrestrict-control}"
|
||||||
|
MQTT_IMAGE="${MQTT_IMAGE:-eclipse-mosquitto:latest}"
|
||||||
|
DEVICE_ID="${DEVICE_ID:-}"
|
||||||
|
WAIT_SECONDS="${WAIT_SECONDS:-90}"
|
||||||
|
|
||||||
|
die() { echo "verify-phase9: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
envval() { grep -E "^$1=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true; }
|
||||||
|
|
||||||
|
# Give the engine at least one full poll to observe a state change before the
|
||||||
|
# script relies on the transition. Read from the deployed configuration.
|
||||||
|
INTERVAL_RAW="$(envval CONTROL_ALERT_INTERVAL)"; INTERVAL_RAW="${INTERVAL_RAW:-30s}"
|
||||||
|
INTERVAL_S="${INTERVAL_RAW%s}"
|
||||||
|
[[ "$INTERVAL_S" =~ ^[0-9]+$ ]] || INTERVAL_S=30
|
||||||
|
SETTLE_SECONDS=$((INTERVAL_S + 3))
|
||||||
|
|
||||||
|
HUB_TOKEN="${CONTROL_HUB_TOKEN:-$(envval CONTROL_HUB_TOKEN)}"
|
||||||
|
ADMIN_TOKEN="${CONTROL_ADMIN_API_TOKEN:-$(envval CONTROL_ADMIN_API_TOKEN)}"
|
||||||
|
[[ -n "$HUB_TOKEN" ]] || die "set CONTROL_HUB_TOKEN or add it to $ENV_FILE"
|
||||||
|
[[ -n "$ADMIN_TOKEN" ]] || die "set CONTROL_ADMIN_API_TOKEN or add it to $ENV_FILE"
|
||||||
|
[[ -f "$CLIENTS_FILE" ]] || die "missing device credentials $CLIENTS_FILE"
|
||||||
|
[[ -f "$CA_FILE" ]] || die "missing CA file $CA_FILE"
|
||||||
|
|
||||||
|
hub() { curl -sS -H "Authorization: Bearer $HUB_TOKEN" -H 'Content-Type: application/json' "$@"; }
|
||||||
|
admin() { curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' "$@"; }
|
||||||
|
|
||||||
|
# control_alerts prints the alerts table, newest first.
|
||||||
|
control_alerts() { timeout 30 docker exec "$CONTAINER" /app/control alerts --limit 50; }
|
||||||
|
|
||||||
|
# latest_alert_id <kind> prints the newest alert id of that kind, or empty.
|
||||||
|
latest_alert_id() {
|
||||||
|
control_alerts 2>/dev/null | awk -F'\t' -v k="$1" '$2==k {print $1}' | sort -n | tail -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_alert <kind> <baseline_id>: waits for a newer delivered alert.
|
||||||
|
wait_alert() {
|
||||||
|
local kind="$1" base="$2" deadline=$((SECONDS + WAIT_SECONDS)) id line status
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
id="$(latest_alert_id "$kind")"
|
||||||
|
if [[ -n "$id" && "$id" -gt "$base" ]]; then
|
||||||
|
line="$(control_alerts 2>/dev/null | awk -F'\t' -v i="$id" '$1==i')"
|
||||||
|
status="$(echo "$line" | cut -f3)"
|
||||||
|
if [[ "$status" == "delivered" ]]; then
|
||||||
|
echo "$line"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
die "alert $id ($kind) is recorded but not delivered: $line"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_status() { # publish_status <status_topic> <token> <password> <payload>
|
||||||
|
timeout 60 docker run --rm --network host \
|
||||||
|
-v "$PWD/$CA_FILE:/tls/ca.crt:ro" \
|
||||||
|
--entrypoint sh "$MQTT_IMAGE" -c \
|
||||||
|
"mosquitto_pub -h 127.0.0.1 -p 8883 --cafile /tls/ca.crt \
|
||||||
|
-u '$2' -P '$3' -t '$1' -r -m '$4'" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
DEVICE_TOPIC=""
|
||||||
|
DEVICE_TOKEN=""
|
||||||
|
DEVICE_PASSWORD=""
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$DEVICE_ID" ]]; then
|
||||||
|
timeout 20 curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{}' \
|
||||||
|
"$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [[ -n "$DEVICE_TOPIC" && -n "$DEVICE_PASSWORD" ]]; then
|
||||||
|
local now
|
||||||
|
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
publish_status "$DEVICE_STATUS_TOPIC" "$DEVICE_TOKEN" "$DEVICE_PASSWORD" \
|
||||||
|
"{\"online\":true,\"at\":\"$now\",\"app\":\"verify-phase9-cleanup\"}" || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "service: $SERVICE_URL"
|
||||||
|
|
||||||
|
# ---- 1. capabilities ------------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "== GET /capabilities"
|
||||||
|
code="$(hub -o "$WORK/caps.json" -w '%{http_code}' "$SERVICE_URL/capabilities")"
|
||||||
|
[[ "$code" == "200" ]] || die "capabilities returned HTTP $code: $(cat "$WORK/caps.json")"
|
||||||
|
cat "$WORK/caps.json"; echo
|
||||||
|
jq -e '.project == "internet_use_control"' "$WORK/caps.json" >/dev/null \
|
||||||
|
|| die "unexpected capabilities payload"
|
||||||
|
|
||||||
|
# ---- 2. ask ---------------------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "== POST /ask"
|
||||||
|
ASK_BODY='{"question":"how long have the kids been on screens today?","asker":"sam","source":"console"}'
|
||||||
|
code="$(hub -o "$WORK/ask.json" -w '%{http_code}' -X POST -d "$ASK_BODY" "$SERVICE_URL/ask")"
|
||||||
|
[[ "$code" == "200" ]] || die "ask returned HTTP $code: $(cat "$WORK/ask.json")"
|
||||||
|
cat "$WORK/ask.json"; echo
|
||||||
|
answer_total="$(jq -r '.data.total_seconds // empty' "$WORK/ask.json")"
|
||||||
|
[[ -n "$answer_total" ]] || die "ask returned no answer/data"
|
||||||
|
ask_day="$(jq -r '.data.day' "$WORK/ask.json")"
|
||||||
|
|
||||||
|
# ---- 3. independent count -------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "== independent count (telemetry detail table, day $ask_day)"
|
||||||
|
detail="$(timeout 30 docker exec "$CONTAINER" /app/control usage --day "$ask_day" --detail)"
|
||||||
|
echo "$detail"
|
||||||
|
indep_total="$(echo "$detail" | awk -F= '/^total_screen=/{print $2}')"
|
||||||
|
echo
|
||||||
|
echo "ask total_seconds = $answer_total"
|
||||||
|
echo "independent total = $indep_total"
|
||||||
|
[[ "$answer_total" == "$indep_total" ]] \
|
||||||
|
|| die "ask total ($answer_total) does not match the independent count ($indep_total)"
|
||||||
|
echo "PASS: the answer matches the stored telemetry"
|
||||||
|
|
||||||
|
# ---- 4. limit-hit alert ---------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "== limit-hit alert"
|
||||||
|
devices="$(admin "$SERVICE_URL/api/v1/admin/devices")"
|
||||||
|
if [[ -z "$DEVICE_ID" ]]; then
|
||||||
|
DEVICE_ID="$(echo "$devices" | jq -r '.devices[0].id // empty')"
|
||||||
|
fi
|
||||||
|
[[ -n "$DEVICE_ID" ]] || die "no device is registered"
|
||||||
|
device_json="$(admin "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID")"
|
||||||
|
DEVICE_TOPIC="$(echo "$device_json" | jq -r '.device.topic')"
|
||||||
|
DEVICE_STATUS_TOPIC="${DEVICE_TOPIC%/config}/status"
|
||||||
|
DEVICE_TOKEN="${DEVICE_TOPIC#restrict/}"; DEVICE_TOKEN="${DEVICE_TOKEN%/config}"
|
||||||
|
DEVICE_PASSWORD="$(awk -F'\t' -v t="$DEVICE_TOPIC" '$3==t {print $2}' "$CLIENTS_FILE" | head -1)"
|
||||||
|
[[ -n "$DEVICE_PASSWORD" ]] || die "no broker account for $DEVICE_TOPIC in $CLIENTS_FILE"
|
||||||
|
echo "device: $(echo "$device_json" | jq -r '.device.name') (id $DEVICE_ID)"
|
||||||
|
|
||||||
|
# Make sure the engine has seen the device unlocked, then lock it.
|
||||||
|
admin -X POST -d '{}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/unlock" >/dev/null
|
||||||
|
sleep "$SETTLE_SECONDS"
|
||||||
|
limit_base="$(latest_alert_id limit)"; limit_base="${limit_base:-0}"
|
||||||
|
admin -X POST -d '{"minutes":30}' "$SERVICE_URL/api/v1/admin/devices/$DEVICE_ID/lock" >/dev/null
|
||||||
|
limit_line="$(wait_alert limit "$limit_base")" \
|
||||||
|
|| die "no delivered limit alert within ${WAIT_SECONDS}s"
|
||||||
|
echo " limit alert: $limit_line"
|
||||||
|
|
||||||
|
# ---- 5. offline-too-long alert -------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "== offline-too-long alert"
|
||||||
|
offline_base="$(latest_alert_id offline)"; offline_base="${offline_base:-0}"
|
||||||
|
# An old `at` makes the device immediately past the offline threshold.
|
||||||
|
old_at="$(date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
||||||
|
|| date -u -v-2H +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
publish_status "$DEVICE_STATUS_TOPIC" "$DEVICE_TOKEN" "$DEVICE_PASSWORD" \
|
||||||
|
"{\"online\":false,\"at\":\"$old_at\",\"app\":\"verify-phase9\"}"
|
||||||
|
offline_line="$(wait_alert offline "$offline_base")" \
|
||||||
|
|| die "no delivered offline alert within ${WAIT_SECONDS}s"
|
||||||
|
echo " offline alert: $offline_line"
|
||||||
|
|
||||||
|
# ---- 6. notify ------------------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "== POST /notify"
|
||||||
|
notify_base="$(latest_alert_id notify)"; notify_base="${notify_base:-0}"
|
||||||
|
code="$(hub -o "$WORK/notify.json" -w '%{http_code}' -X POST \
|
||||||
|
-d '{"from":"internet_use_control","level":"important","message":"Phase 9 verification: notify path reached Sam.","person":"sam"}' \
|
||||||
|
"$SERVICE_URL/notify")"
|
||||||
|
[[ "$code" == "200" ]] || die "notify returned HTTP $code: $(cat "$WORK/notify.json")"
|
||||||
|
cat "$WORK/notify.json"; echo
|
||||||
|
notify_line="$(wait_alert notify "$notify_base")" \
|
||||||
|
|| die "no delivered notify alert within ${WAIT_SECONDS}s"
|
||||||
|
echo " notify alert: $notify_line"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "PASS: Phase 9 — Hub answers, limit/offline alerts delivered, notify delivered"
|
||||||
@@ -12,8 +12,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
VERSION: "0.6.0-phase8"
|
VERSION: "0.7.0-phase9"
|
||||||
image: webrestrict/control:0.6.0-phase8
|
image: webrestrict/control:0.7.0-phase9
|
||||||
container_name: webrestrict-control
|
container_name: webrestrict-control
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ CONTROL_ADMIN_USERNAME=sam
|
|||||||
CONTROL_ADMIN_PASSWORD=change-me
|
CONTROL_ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty.
|
# Option B (preferred): a bcrypt hash; leave CONTROL_ADMIN_PASSWORD empty.
|
||||||
# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.6.0-phase8 hash-password 'your-password'
|
# Generate: docker run --rm --entrypoint /app/control webrestrict/control:0.7.0-phase9 hash-password 'your-password'
|
||||||
# CONTROL_ADMIN_PASSWORD_HASH=$2a$10$...
|
# CONTROL_ADMIN_PASSWORD_HASH=$2a$10$...
|
||||||
|
|
||||||
# --- Admin API (Phase 8) ---------------------------------------------------
|
# --- Admin API (Phase 8) ---------------------------------------------------
|
||||||
@@ -51,7 +51,7 @@ CONTROL_ADMIN_API_TOKEN=change-me-to-a-long-random-token
|
|||||||
|
|
||||||
# The shared device keycode (one code, every device — AGENTS.md §10 rule 3).
|
# The shared device keycode (one code, every device — AGENTS.md §10 rule 3).
|
||||||
# Give a bcrypt hash, never the code itself. Generate one with:
|
# Give a bcrypt hash, never the code itself. Generate one with:
|
||||||
# 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 'the-code'
|
# hash-password 'the-code'
|
||||||
# The hash is seeded into SQLite at start-up and can be changed later with
|
# The hash is seeded into SQLite at start-up and can be changed later with
|
||||||
# `docker exec webrestrict-control /app/control keycode`.
|
# `docker exec webrestrict-control /app/control keycode`.
|
||||||
@@ -87,3 +87,35 @@ CONTROL_MQTT_CA_FILE=/etc/webrestrict/tls/ca.crt
|
|||||||
# unless this is true, and it logs a warning when it is. Leave it false against
|
# unless this is true, and it logs a warning when it is. Leave it false against
|
||||||
# the shared Mosquitto (AGENTS.md §10 rule 6).
|
# the shared Mosquitto (AGENTS.md §10 rule 6).
|
||||||
CONTROL_MQTT_INSECURE=false
|
CONTROL_MQTT_INSECURE=false
|
||||||
|
|
||||||
|
# --- Hub API (Phase 9) -----------------------------------------------------
|
||||||
|
# The project side of central_ai_llm_master/docs/ask-contract.md. The Hub sends
|
||||||
|
# this bearer token; /capabilities and /ask refuse an unsigned request. When it
|
||||||
|
# is empty the three endpoints answer 503 and nothing else changes.
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
|
CONTROL_HUB_TOKEN=change-me-to-a-long-random-token
|
||||||
|
|
||||||
|
# --- Alerts (Phase 9) ------------------------------------------------------
|
||||||
|
# Apprise: the house notification gateway on `.35` (AGENTS.md §8). Alerts and
|
||||||
|
# accepted notifications are delivered through it (FR-13).
|
||||||
|
CONTROL_APPRISE_URL=http://192.168.20.35:8210
|
||||||
|
|
||||||
|
# Option A: a stored Apprise configuration key.
|
||||||
|
# CONTROL_APPRISE_KEY=sam_email
|
||||||
|
|
||||||
|
# Option B (lab default): the stateless destination list. This is the same
|
||||||
|
# shared value as APPRISE_URLS in ~/.config/environment.d/10-secrets.conf.
|
||||||
|
# CONTROL_APPRISE_URLS='mailto://user:app-password@gmail.com'
|
||||||
|
|
||||||
|
# Optional bearer token if the Apprise API is protected. Empty in this lab.
|
||||||
|
# CONTROL_APPRISE_TOKEN=
|
||||||
|
|
||||||
|
# Turn the limit/offline alert engine on. MQTT-style: with it off the service
|
||||||
|
# still answers /ask and /notify.
|
||||||
|
CONTROL_ALERTS_ENABLED=true
|
||||||
|
|
||||||
|
# How long a device may be unreachable before Sam is told. Go duration.
|
||||||
|
CONTROL_ALERT_OFFLINE_AFTER=30m
|
||||||
|
|
||||||
|
# How often the alert engine re-evaluates device state. Go duration.
|
||||||
|
CONTROL_ALERT_INTERVAL=30s
|
||||||
|
|||||||
263
internal/alert/alert.go
Normal file
263
internal/alert/alert.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// Package alert watches device state and tells Sam when something needs his
|
||||||
|
// attention (AGENTS.md §4 FR-13, §5 screen 6, §11 Phase 9).
|
||||||
|
//
|
||||||
|
// Two conditions raise an alert:
|
||||||
|
//
|
||||||
|
// - a limit is hit: a device becomes blocked (a weekly window turned on, or a
|
||||||
|
// manual lock was applied), and
|
||||||
|
// - a device is offline too long: presence is false and the last report is
|
||||||
|
// older than the configured threshold.
|
||||||
|
//
|
||||||
|
// Alerts fire once per transition, not once per poll. The last observed
|
||||||
|
// condition is kept in `alert_state`; the first time the engine sees a device it
|
||||||
|
// seeds that state without alerting, so a restart never replays an old limit.
|
||||||
|
//
|
||||||
|
// Delivery goes through Apprise. The `alerts` row is written before delivery, so
|
||||||
|
// a delivery failure leaves a visible, auditable record rather than silence.
|
||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultOfflineAfter is how long a device may be unreachable before Sam is
|
||||||
|
// told. Overridable with CONTROL_ALERT_OFFLINE_AFTER.
|
||||||
|
const DefaultOfflineAfter = 30 * time.Minute
|
||||||
|
|
||||||
|
// DefaultInterval is how often the engine re-evaluates. It bounds how late an
|
||||||
|
// alert can be, and it is short enough that a limit hit feels immediate.
|
||||||
|
const DefaultInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// Store is the data surface the engine needs. *store.Store satisfies it.
|
||||||
|
type Store interface {
|
||||||
|
ListDevices(ctx context.Context) ([]store.Device, error)
|
||||||
|
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
|
||||||
|
AlertStates(ctx context.Context) (map[int64]store.AlertState, error)
|
||||||
|
SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error
|
||||||
|
CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*store.Alert, error)
|
||||||
|
MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier delivers one alert. *apprise.Client satisfies it; a nil Notifier is
|
||||||
|
// not configured and the engine records the alert as undelivered.
|
||||||
|
type Notifier interface {
|
||||||
|
Configured() bool
|
||||||
|
SendType(ctx context.Context, title, body, notifyType string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine evaluates device state on an interval and raises transition alerts.
|
||||||
|
type Engine struct {
|
||||||
|
Store Store
|
||||||
|
Notifier Notifier
|
||||||
|
Log *slog.Logger
|
||||||
|
OfflineAfter time.Duration
|
||||||
|
Interval time.Duration
|
||||||
|
Location *time.Location
|
||||||
|
// Now is injectable so tests are deterministic.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds an engine. A zero offlineAfter uses DefaultOfflineAfter; a zero
|
||||||
|
// interval uses DefaultInterval.
|
||||||
|
func New(st Store, notifier Notifier, log *slog.Logger, offlineAfter time.Duration, loc *time.Location) *Engine {
|
||||||
|
if offlineAfter <= 0 {
|
||||||
|
offlineAfter = DefaultOfflineAfter
|
||||||
|
}
|
||||||
|
if log == nil {
|
||||||
|
log = slog.Default()
|
||||||
|
}
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
return &Engine{
|
||||||
|
Store: st,
|
||||||
|
Notifier: notifier,
|
||||||
|
Log: log,
|
||||||
|
OfflineAfter: offlineAfter,
|
||||||
|
Interval: DefaultInterval,
|
||||||
|
Location: loc,
|
||||||
|
Now: func() time.Time { return time.Now().In(loc) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run evaluates once at start-up (to seed state without alerting) and then on
|
||||||
|
// every tick until ctx is cancelled.
|
||||||
|
func (e *Engine) Run(ctx context.Context) {
|
||||||
|
interval := e.Interval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = DefaultInterval
|
||||||
|
}
|
||||||
|
e.Evaluate(ctx)
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
e.Evaluate(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate checks every device once and raises an alert for each condition that
|
||||||
|
// just became true.
|
||||||
|
func (e *Engine) Evaluate(ctx context.Context) {
|
||||||
|
now := e.now()
|
||||||
|
devs, err := e.Store.ListDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
e.Log.Error("alert evaluate: list devices", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
states, err := e.Store.AlertStates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
e.Log.Error("alert evaluate: alert states", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dev := range devs {
|
||||||
|
eff, err := e.Store.EffectiveState(ctx, dev, now)
|
||||||
|
if err != nil {
|
||||||
|
e.Log.Error("alert evaluate: effective state", "device", dev.Name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
locked := eff.Locked
|
||||||
|
offline := e.offlineTooLong(dev, now)
|
||||||
|
|
||||||
|
prev, known := states[dev.ID]
|
||||||
|
if !known {
|
||||||
|
// First observation: remember the condition, alert on nothing.
|
||||||
|
if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
|
||||||
|
e.Log.Error("alert seed state", "device", dev.Name, "err", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if locked && !prev.Locked {
|
||||||
|
e.fire(ctx, dev, store.AlertLimit, limitMessage(dev, eff, e.loc()))
|
||||||
|
}
|
||||||
|
if offline && !prev.Offline {
|
||||||
|
e.fire(ctx, dev, store.AlertOffline, offlineMessage(dev, now))
|
||||||
|
}
|
||||||
|
if locked != prev.Locked || offline != prev.Offline {
|
||||||
|
if err := e.Store.SetAlertState(ctx, dev.ID, locked, offline); err != nil {
|
||||||
|
e.Log.Error("alert save state", "device", dev.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire records an alert and delivers it. A delivery failure is recorded on the
|
||||||
|
// row and logged; it is never retried here, so a broken Apprise cannot spin.
|
||||||
|
func (e *Engine) fire(ctx context.Context, dev store.Device, kind, message string) {
|
||||||
|
deviceID := dev.ID
|
||||||
|
alert, err := e.Store.CreateAlert(ctx, &deviceID, kind, message)
|
||||||
|
if err != nil {
|
||||||
|
e.Log.Error("alert create", "device", dev.Name, "kind", kind, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Notifier == nil || !e.Notifier.Configured() {
|
||||||
|
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, "apprise is not configured"); err != nil {
|
||||||
|
e.Log.Error("alert record", "id", alert.ID, "err", err)
|
||||||
|
}
|
||||||
|
e.Log.Warn("alert not delivered: apprise is not configured",
|
||||||
|
"device", dev.Name, "kind", kind, "message", message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := "Internet Use Control — " + alertTitle(kind)
|
||||||
|
dctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := e.Notifier.SendType(dctx, title, message, apprise.TypeWarning); err != nil {
|
||||||
|
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, err.Error()); err != nil {
|
||||||
|
e.Log.Error("alert record", "id", alert.ID, "err", err)
|
||||||
|
}
|
||||||
|
e.Log.Error("alert delivery failed", "device", dev.Name, "kind", kind, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := e.Store.MarkAlertDelivered(ctx, alert.ID, ""); err != nil {
|
||||||
|
e.Log.Error("alert record", "id", alert.ID, "err", err)
|
||||||
|
}
|
||||||
|
e.Log.Info("alert delivered", "device", dev.Name, "kind", kind, "alert_id", alert.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineTooLong reports whether a device has been unreachable past the
|
||||||
|
// threshold. A device that has never reported is never "offline too long": a
|
||||||
|
// freshly registered device is not an alert.
|
||||||
|
func (e *Engine) offlineTooLong(dev store.Device, now time.Time) bool {
|
||||||
|
if dev.Online || dev.LastSeen == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return now.Sub(*dev.LastSeen) > e.OfflineAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) now() time.Time {
|
||||||
|
if e.Now == nil {
|
||||||
|
return time.Now().In(e.loc())
|
||||||
|
}
|
||||||
|
return e.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) loc() *time.Location {
|
||||||
|
if e.Location == nil {
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
return e.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitMessage is the one sentence Sam receives when a device is blocked.
|
||||||
|
func limitMessage(dev store.Device, eff store.EffectiveState, loc *time.Location) string {
|
||||||
|
reason := "its schedule"
|
||||||
|
if eff.Reason == "manual" {
|
||||||
|
reason = "a manual lock"
|
||||||
|
}
|
||||||
|
if eff.Until != nil {
|
||||||
|
return fmt.Sprintf("%s reached its limit and is blocked by %s (until %s).",
|
||||||
|
dev.Name, reason, eff.Until.In(loc).Format("15:04"))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s reached its limit and is blocked by %s.", dev.Name, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineMessage is the one sentence Sam receives when a device is unreachable.
|
||||||
|
func offlineMessage(dev store.Device, now time.Time) string {
|
||||||
|
if dev.LastSeen == nil {
|
||||||
|
return fmt.Sprintf("%s has been offline for a while.", dev.Name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s has been offline for %s.", dev.Name, humanDuration(now.Sub(*dev.LastSeen)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertTitle(kind string) string {
|
||||||
|
switch kind {
|
||||||
|
case store.AlertLimit:
|
||||||
|
return "limit hit"
|
||||||
|
case store.AlertOffline:
|
||||||
|
return "device offline"
|
||||||
|
default:
|
||||||
|
return "notification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanDuration renders a duration compactly: "2h 10m", "45m", "under a minute".
|
||||||
|
func humanDuration(d time.Duration) string {
|
||||||
|
seconds := int(d.Seconds())
|
||||||
|
if seconds <= 0 {
|
||||||
|
return "0m"
|
||||||
|
}
|
||||||
|
if seconds < 60 {
|
||||||
|
return "under a minute"
|
||||||
|
}
|
||||||
|
m := seconds / 60
|
||||||
|
h := m / 60
|
||||||
|
m %= 60
|
||||||
|
if h == 0 {
|
||||||
|
return fmt.Sprintf("%dm", m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh %02dm", h, m)
|
||||||
|
}
|
||||||
178
internal/alert/alert_test.go
Normal file
178
internal/alert/alert_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
devices []store.Device
|
||||||
|
eff map[int64]store.EffectiveState
|
||||||
|
states map[int64]store.AlertState
|
||||||
|
alerts []*store.Alert
|
||||||
|
nextID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore(devices ...store.Device) *fakeStore {
|
||||||
|
return &fakeStore{
|
||||||
|
devices: devices,
|
||||||
|
eff: map[int64]store.EffectiveState{},
|
||||||
|
states: map[int64]store.AlertState{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListDevices(context.Context) ([]store.Device, error) { return f.devices, nil }
|
||||||
|
|
||||||
|
func (f *fakeStore) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) {
|
||||||
|
return f.eff[dev.ID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) AlertStates(context.Context) (map[int64]store.AlertState, error) {
|
||||||
|
out := make(map[int64]store.AlertState, len(f.states))
|
||||||
|
for k, v := range f.states {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetAlertState(_ context.Context, deviceID int64, locked, offline bool) error {
|
||||||
|
f.states[deviceID] = store.AlertState{Locked: locked, Offline: offline}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAlert(_ context.Context, deviceID *int64, kind, message string) (*store.Alert, error) {
|
||||||
|
f.nextID++
|
||||||
|
a := &store.Alert{ID: f.nextID, DeviceID: deviceID, Kind: kind, Message: message}
|
||||||
|
f.alerts = append(f.alerts, a)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) MarkAlertDelivered(_ context.Context, id int64, errMsg string) error {
|
||||||
|
for _, a := range f.alerts {
|
||||||
|
if a.ID == id {
|
||||||
|
a.Delivered = errMsg == ""
|
||||||
|
a.Error = errMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotifier struct {
|
||||||
|
configured bool
|
||||||
|
sent []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) Configured() bool { return f.configured }
|
||||||
|
|
||||||
|
func (f *fakeNotifier) SendType(_ context.Context, _, body, _ string) error {
|
||||||
|
if f.err != nil {
|
||||||
|
return f.err
|
||||||
|
}
|
||||||
|
f.sent = append(f.sent, body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEngine(st *fakeStore, n *fakeNotifier, now time.Time) *Engine {
|
||||||
|
e := New(st, n, slog.New(slog.DiscardHandler), 30*time.Minute, time.UTC)
|
||||||
|
e.Now = func() time.Time { return now }
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedThenLimitTransition(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
|
||||||
|
until := now.Add(30 * time.Minute)
|
||||||
|
fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"})
|
||||||
|
fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: false, Reason: ""}
|
||||||
|
fn := &fakeNotifier{configured: true}
|
||||||
|
e := newEngine(fs, fn, now)
|
||||||
|
|
||||||
|
// First observation seeds the state and raises nothing.
|
||||||
|
e.Evaluate(context.Background())
|
||||||
|
if len(fs.alerts) != 0 {
|
||||||
|
t.Fatalf("seed raised %d alerts, want 0", len(fs.alerts))
|
||||||
|
}
|
||||||
|
if st := fs.states[1]; st.Locked {
|
||||||
|
t.Fatalf("seeded state = %#v, want unlocked", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A schedule turns on: exactly one limit alert.
|
||||||
|
fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: true, Reason: "schedule", Until: &until}
|
||||||
|
e.Evaluate(context.Background())
|
||||||
|
if len(fs.alerts) != 1 {
|
||||||
|
t.Fatalf("alerts = %d, want 1", len(fs.alerts))
|
||||||
|
}
|
||||||
|
if fs.alerts[0].Kind != store.AlertLimit {
|
||||||
|
t.Errorf("kind = %q, want limit", fs.alerts[0].Kind)
|
||||||
|
}
|
||||||
|
if !fs.alerts[0].Delivered {
|
||||||
|
t.Error("alert was not marked delivered")
|
||||||
|
}
|
||||||
|
if len(fn.sent) != 1 {
|
||||||
|
t.Fatalf("notifier sent %d, want 1", len(fn.sent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// No new alert while the condition stays true.
|
||||||
|
e.Evaluate(context.Background())
|
||||||
|
if len(fs.alerts) != 1 {
|
||||||
|
t.Fatalf("repeat evaluation raised %d alerts, want 1", len(fs.alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineTransition(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
old := now.Add(-2 * time.Hour)
|
||||||
|
fs := newFakeStore(store.Device{ID: 2, Name: "Finn's computer", Child: "Finn", Online: true})
|
||||||
|
fs.eff[2] = store.EffectiveState{DeviceID: 2}
|
||||||
|
fn := &fakeNotifier{configured: true}
|
||||||
|
e := newEngine(fs, fn, now)
|
||||||
|
e.Evaluate(context.Background()) // seed online
|
||||||
|
|
||||||
|
fs.devices[0].Online = false
|
||||||
|
fs.devices[0].LastSeen = &old
|
||||||
|
e.Evaluate(context.Background())
|
||||||
|
|
||||||
|
if len(fs.alerts) != 1 || fs.alerts[0].Kind != store.AlertOffline {
|
||||||
|
t.Fatalf("alerts = %#v, want one offline alert", fs.alerts)
|
||||||
|
}
|
||||||
|
if len(fn.sent) != 1 {
|
||||||
|
t.Fatalf("notifier sent %d, want 1", len(fn.sent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeverSeenDeviceIsNotOffline(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
fs := newFakeStore(store.Device{ID: 3, Name: "New device", Child: "Harry", Online: false})
|
||||||
|
fs.eff[3] = store.EffectiveState{DeviceID: 3}
|
||||||
|
e := newEngine(fs, &fakeNotifier{configured: true}, now)
|
||||||
|
|
||||||
|
e.Evaluate(context.Background()) // seed
|
||||||
|
e.Evaluate(context.Background()) // still no last_seen
|
||||||
|
if len(fs.alerts) != 0 {
|
||||||
|
t.Fatalf("alerts = %d, want 0 for a never-seen device", len(fs.alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUndeliveredWhenNotConfigured(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
|
||||||
|
until := now.Add(30 * time.Minute)
|
||||||
|
fs := newFakeStore(store.Device{ID: 1, Name: "Harry's phone", Child: "Harry"})
|
||||||
|
fs.eff[1] = store.EffectiveState{DeviceID: 1}
|
||||||
|
fn := &fakeNotifier{configured: false}
|
||||||
|
e := newEngine(fs, fn, now)
|
||||||
|
e.Evaluate(context.Background())
|
||||||
|
|
||||||
|
fs.eff[1] = store.EffectiveState{DeviceID: 1, Locked: true, Reason: "schedule", Until: &until}
|
||||||
|
e.Evaluate(context.Background())
|
||||||
|
|
||||||
|
if len(fs.alerts) != 1 {
|
||||||
|
t.Fatalf("alerts = %d, want 1", len(fs.alerts))
|
||||||
|
}
|
||||||
|
if fs.alerts[0].Delivered {
|
||||||
|
t.Error("alert marked delivered with an unconfigured notifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
123
internal/apprise/apprise.go
Normal file
123
internal/apprise/apprise.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Package apprise delivers notifications through the house Apprise API
|
||||||
|
// (AGENTS.md §8, §11 Phase 9).
|
||||||
|
//
|
||||||
|
// Apprise is the shared notification gateway that already reaches Sam by
|
||||||
|
// email. This client speaks the standard Apprise API server shape:
|
||||||
|
//
|
||||||
|
// POST {base}/notify/{key} JSON {"title","body","type"}
|
||||||
|
// POST {base}/notify JSON {"urls","title","body","type"} (stateless)
|
||||||
|
//
|
||||||
|
// A key names a stored Apprise configuration. When no key is set the client
|
||||||
|
// uses the stateless form with an explicit list of Apprise URLs, which is how
|
||||||
|
// the lab is configured (APPRISE_URLS). Secrets come from environment.d and are
|
||||||
|
// never committed.
|
||||||
|
package apprise
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notification classes understood by Apprise.
|
||||||
|
const (
|
||||||
|
TypeInfo = "info"
|
||||||
|
TypeSuccess = "success"
|
||||||
|
TypeWarning = "warning"
|
||||||
|
TypeFailure = "failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client posts a notification to one Apprise API server.
|
||||||
|
type Client struct {
|
||||||
|
// BaseURL is the Apprise API root, e.g. http://192.168.20.35:8210.
|
||||||
|
BaseURL string
|
||||||
|
// Key names a stored Apprise configuration. Empty means stateless mode.
|
||||||
|
Key string
|
||||||
|
// URLs is the stateless destination list, used when Key is empty.
|
||||||
|
URLs []string
|
||||||
|
// Token is sent as a bearer token when the Apprise API is protected.
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client. A zero timeout defaults to ten seconds.
|
||||||
|
func New(baseURL, key string, urls []string, token string, timeout time.Duration) *Client {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
|
||||||
|
Key: strings.TrimSpace(key),
|
||||||
|
URLs: urls,
|
||||||
|
Token: token,
|
||||||
|
HTTP: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether a delivery can be attempted. A nil client is not
|
||||||
|
// configured, so callers never need to branch on nil.
|
||||||
|
func (c *Client) Configured() bool {
|
||||||
|
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c.Key != "" || len(c.URLs) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyBody is the Apprise API request body. `type` selects the notification
|
||||||
|
// class; alerts are warnings.
|
||||||
|
type notifyBody struct {
|
||||||
|
URLs []string `json:"urls,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send delivers one informational notification.
|
||||||
|
func (c *Client) Send(ctx context.Context, title, body string) error {
|
||||||
|
return c.SendType(ctx, title, body, TypeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendType delivers one notification with an explicit Apprise class. A failure
|
||||||
|
// is returned; it is never retried here, so a caller can record the outcome.
|
||||||
|
func (c *Client) SendType(ctx context.Context, title, body, notifyType string) error {
|
||||||
|
if !c.Configured() {
|
||||||
|
return fmt.Errorf("apprise is not configured")
|
||||||
|
}
|
||||||
|
if notifyType == "" {
|
||||||
|
notifyType = TypeInfo
|
||||||
|
}
|
||||||
|
payload := notifyBody{Title: title, Body: body, Type: notifyType}
|
||||||
|
url := c.BaseURL + "/notify"
|
||||||
|
if c.Key != "" {
|
||||||
|
url += "/" + c.Key
|
||||||
|
} else {
|
||||||
|
payload.URLs = c.URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode apprise request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build apprise request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post apprise: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("post apprise: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
103
internal/apprise/apprise_test.go
Normal file
103
internal/apprise/apprise_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package apprise
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigured(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
c *Client
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil", nil, false},
|
||||||
|
{"no base", New("", "key", nil, "", 0), false},
|
||||||
|
{"base no destination", New("http://x", "", nil, "", 0), false},
|
||||||
|
{"key mode", New("http://x", "key", nil, "", 0), true},
|
||||||
|
{"stateless mode", New("http://x", "", []string{"mailto:a@b"}, "", 0), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.c.Configured(); got != tc.want {
|
||||||
|
t.Errorf("%s: Configured() = %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendKeyMode(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "sam_email", nil, "", 0)
|
||||||
|
if err := c.Send(context.Background(), "title", "body"); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/notify/sam_email" {
|
||||||
|
t.Errorf("path = %q, want /notify/sam_email", gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["title"] != "title" || gotBody["body"] != "body" {
|
||||||
|
t.Errorf("body = %#v", gotBody)
|
||||||
|
}
|
||||||
|
if _, ok := gotBody["urls"]; ok {
|
||||||
|
t.Errorf("key mode must not send urls: %#v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendStatelessModeWithAuthAndType(t *testing.T) {
|
||||||
|
var gotAuth, gotType string
|
||||||
|
var gotURLs []any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
var body map[string]any
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &body)
|
||||||
|
gotType, _ = body["type"].(string)
|
||||||
|
gotURLs, _ = body["urls"].([]any)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "", []string{"mailto:sam@example.com"}, "secret", 0)
|
||||||
|
if err := c.SendType(context.Background(), "t", "b", TypeWarning); err != nil {
|
||||||
|
t.Fatalf("SendType: %v", err)
|
||||||
|
}
|
||||||
|
if gotAuth != "Bearer secret" {
|
||||||
|
t.Errorf("auth = %q", gotAuth)
|
||||||
|
}
|
||||||
|
if gotType != TypeWarning {
|
||||||
|
t.Errorf("type = %q, want %q", gotType, TypeWarning)
|
||||||
|
}
|
||||||
|
if len(gotURLs) != 1 || gotURLs[0] != "mailto:sam@example.com" {
|
||||||
|
t.Errorf("urls = %#v", gotURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendErrorStatus(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, "key", nil, "", 0)
|
||||||
|
if err := c.Send(context.Background(), "t", "b"); err == nil {
|
||||||
|
t.Fatal("Send() error = nil, want a status error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendNotConfigured(t *testing.T) {
|
||||||
|
c := New("", "", nil, "", 0)
|
||||||
|
if err := c.Send(context.Background(), "t", "b"); err == nil {
|
||||||
|
t.Fatal("Send() error = nil, want not-configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,30 @@ type Config struct {
|
|||||||
// only until the shared Mosquitto gets the TLS listener; never set it in
|
// only until the shared Mosquitto gets the TLS listener; never set it in
|
||||||
// production.
|
// production.
|
||||||
MQTTInsecure bool
|
MQTTInsecure bool
|
||||||
|
|
||||||
|
// HubToken authenticates the read-only Hub API: GET /capabilities,
|
||||||
|
// POST /ask and POST /notify (Phase 9, FR-12). Secret; the Hub sends it.
|
||||||
|
// When it is empty those endpoints answer 503 and nothing else changes.
|
||||||
|
HubToken string
|
||||||
|
// AppriseURL is the Apprise API base, for example
|
||||||
|
// http://192.168.20.35:8210. Alerts are delivered through it (FR-13).
|
||||||
|
AppriseURL string
|
||||||
|
// AppriseKey names a stored Apprise configuration. Empty means stateless
|
||||||
|
// mode, which uses AppriseURLs.
|
||||||
|
AppriseKey string
|
||||||
|
// AppriseURLs are the stateless Apprise destinations (APPRISE_URLS).
|
||||||
|
AppriseURLs []string
|
||||||
|
// AppriseToken is an optional bearer token when the Apprise API is
|
||||||
|
// protected. It is empty in this lab.
|
||||||
|
AppriseToken string
|
||||||
|
// AlertsEnabled turns the limit/offline alert engine on. When false the
|
||||||
|
// service still answers /ask and /notify.
|
||||||
|
AlertsEnabled bool
|
||||||
|
// AlertOfflineAfter is how long a device may be unreachable before Sam is
|
||||||
|
// told. Only used when AlertsEnabled is true.
|
||||||
|
AlertOfflineAfter time.Duration
|
||||||
|
// AlertInterval is how often the alert engine re-evaluates.
|
||||||
|
AlertInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and validates the configuration.
|
// Load reads and validates the configuration.
|
||||||
@@ -94,6 +118,15 @@ func Load() (*Config, error) {
|
|||||||
MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"),
|
MQTTPassword: os.Getenv("CONTROL_MQTT_PASSWORD"),
|
||||||
MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"),
|
MQTTCAFile: env("CONTROL_MQTT_CA_FILE", "/etc/webrestrict/tls/ca.crt"),
|
||||||
MQTTInsecure: envBool("CONTROL_MQTT_INSECURE", false),
|
MQTTInsecure: envBool("CONTROL_MQTT_INSECURE", false),
|
||||||
|
|
||||||
|
HubToken: os.Getenv("CONTROL_HUB_TOKEN"),
|
||||||
|
AppriseURL: strings.TrimRight(env("CONTROL_APPRISE_URL", DefaultAppriseURL), "/"),
|
||||||
|
AppriseKey: strings.TrimSpace(os.Getenv("CONTROL_APPRISE_KEY")),
|
||||||
|
AppriseURLs: splitList(os.Getenv("CONTROL_APPRISE_URLS")),
|
||||||
|
AppriseToken: os.Getenv("CONTROL_APPRISE_TOKEN"),
|
||||||
|
AlertsEnabled: envBool("CONTROL_ALERTS_ENABLED", true),
|
||||||
|
AlertOfflineAfter: durationSetting("CONTROL_ALERT_OFFLINE_AFTER", DefaultAlertOfflineAfter),
|
||||||
|
AlertInterval: durationSetting("CONTROL_ALERT_INTERVAL", DefaultAlertInterval),
|
||||||
}
|
}
|
||||||
|
|
||||||
secret := os.Getenv("CONTROL_SESSION_SECRET")
|
secret := os.Getenv("CONTROL_SESSION_SECRET")
|
||||||
@@ -143,6 +176,24 @@ const DefaultMQTTBroker = "tcp://host.docker.internal:1883"
|
|||||||
// It matches the lab host's configured zone; set CONTROL_TIMEZONE to override.
|
// It matches the lab host's configured zone; set CONTROL_TIMEZONE to override.
|
||||||
const DefaultTimezone = "Australia/Sydney"
|
const DefaultTimezone = "Australia/Sydney"
|
||||||
|
|
||||||
|
// DefaultAppriseURL is the house Apprise API on `.35` (AGENTS.md §2a, §8).
|
||||||
|
const DefaultAppriseURL = "http://192.168.20.35:8210"
|
||||||
|
|
||||||
|
// DefaultAlertOfflineAfter is how long a device may be unreachable before Sam
|
||||||
|
// is told (FR-13).
|
||||||
|
const DefaultAlertOfflineAfter = 30 * time.Minute
|
||||||
|
|
||||||
|
// DefaultAlertInterval is how often the alert engine re-evaluates device state.
|
||||||
|
const DefaultAlertInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// HubConfigured reports whether the Hub API can serve authenticated requests.
|
||||||
|
func (c *Config) HubConfigured() bool { return strings.TrimSpace(c.HubToken) != "" }
|
||||||
|
|
||||||
|
// AppriseConfigured reports whether alerts can be delivered.
|
||||||
|
func (c *Config) AppriseConfigured() bool {
|
||||||
|
return strings.TrimSpace(c.AppriseURL) != "" && (c.AppriseKey != "" || len(c.AppriseURLs) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
// MQTTTLS reports whether the broker URL asks for TLS.
|
// MQTTTLS reports whether the broker URL asks for TLS.
|
||||||
func (c *Config) MQTTTLS() bool {
|
func (c *Config) MQTTTLS() bool {
|
||||||
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")
|
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")
|
||||||
@@ -189,6 +240,35 @@ func envBool(key string, fallback bool) bool {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// durationSetting reads a Go duration from the environment, falling back when
|
||||||
|
// it is empty or unparseable.
|
||||||
|
func durationSetting(key string, fallback time.Duration) time.Duration {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil || d <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitList parses a comma- or whitespace-separated list. It is used for the
|
||||||
|
// stateless Apprise destination list (APPRISE_URLS), which contains spaces.
|
||||||
|
func splitList(s string) []string {
|
||||||
|
fields := strings.FieldsFunc(s, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(fields))
|
||||||
|
for _, f := range fields {
|
||||||
|
if f = strings.TrimSpace(f); f != "" {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func parseLevel(s string) (slog.Level, error) {
|
func parseLevel(s string) (slog.Level, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|||||||
@@ -129,3 +129,60 @@ func TestMQTTDefaultsAndGuards(t *testing.T) {
|
|||||||
t.Error("MQTTEnabled = true, want false when CONTROL_MQTT_ENABLED=false")
|
t.Error("MQTTEnabled = true, want false when CONTROL_MQTT_ENABLED=false")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPhase9HubAndAlertDefaults(t *testing.T) {
|
||||||
|
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
|
||||||
|
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
|
||||||
|
t.Setenv("CONTROL_MQTT_INSECURE", "true")
|
||||||
|
for _, k := range []string{
|
||||||
|
"CONTROL_HUB_TOKEN", "CONTROL_APPRISE_KEY", "CONTROL_APPRISE_URLS",
|
||||||
|
"CONTROL_APPRISE_TOKEN", "CONTROL_ALERTS_ENABLED", "CONTROL_ALERT_OFFLINE_AFTER",
|
||||||
|
"CONTROL_APPRISE_URL",
|
||||||
|
} {
|
||||||
|
t.Setenv(k, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.HubConfigured() {
|
||||||
|
t.Error("HubConfigured = true with no CONTROL_HUB_TOKEN")
|
||||||
|
}
|
||||||
|
if cfg.AppriseURL != DefaultAppriseURL {
|
||||||
|
t.Errorf("AppriseURL = %q, want %q", cfg.AppriseURL, DefaultAppriseURL)
|
||||||
|
}
|
||||||
|
if cfg.AppriseConfigured() {
|
||||||
|
t.Error("AppriseConfigured = true with no destination")
|
||||||
|
}
|
||||||
|
if !cfg.AlertsEnabled {
|
||||||
|
t.Error("AlertsEnabled = false, want true by default")
|
||||||
|
}
|
||||||
|
if cfg.AlertOfflineAfter != DefaultAlertOfflineAfter {
|
||||||
|
t.Errorf("AlertOfflineAfter = %v, want %v", cfg.AlertOfflineAfter, DefaultAlertOfflineAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("CONTROL_HUB_TOKEN", "hub-secret")
|
||||||
|
t.Setenv("CONTROL_APPRISE_URLS", "mailto:sam@example.com pushover://x/y")
|
||||||
|
t.Setenv("CONTROL_ALERT_OFFLINE_AFTER", "10m")
|
||||||
|
t.Setenv("CONTROL_ALERTS_ENABLED", "false")
|
||||||
|
cfg, err = Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.HubConfigured() {
|
||||||
|
t.Error("HubConfigured = false with a token set")
|
||||||
|
}
|
||||||
|
if !cfg.AppriseConfigured() {
|
||||||
|
t.Error("AppriseConfigured = false with CONTROL_APPRISE_URLS set")
|
||||||
|
}
|
||||||
|
if len(cfg.AppriseURLs) != 2 {
|
||||||
|
t.Errorf("AppriseURLs = %#v, want 2 entries", cfg.AppriseURLs)
|
||||||
|
}
|
||||||
|
if cfg.AlertOfflineAfter != 10*time.Minute {
|
||||||
|
t.Errorf("AlertOfflineAfter = %v, want 10m", cfg.AlertOfflineAfter)
|
||||||
|
}
|
||||||
|
if cfg.AlertsEnabled {
|
||||||
|
t.Error("AlertsEnabled = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
345
internal/hub/ask.go
Normal file
345
internal/hub/ask.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
// Read-only question answering for the Hub (AGENTS.md §4 FR-12, §10 rule 9,
|
||||||
|
// §11 Phase 9).
|
||||||
|
//
|
||||||
|
// The answerer decides intent from the question text and answers from SQLite.
|
||||||
|
// It is not an LLM call: it is deterministic, answers in milliseconds, and
|
||||||
|
// never guesses. An unanswerable question returns answer=null plus a reason.
|
||||||
|
//
|
||||||
|
// Reader is the read-only data surface. *store.Store satisfies it. Keeping it
|
||||||
|
// an interface makes the read-only guarantee explicit: every method reads.
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reader is the read-only data surface the answerer uses.
|
||||||
|
type Reader interface {
|
||||||
|
ListDevices(ctx context.Context) ([]store.Device, error)
|
||||||
|
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
|
||||||
|
UsageByDeviceForDay(ctx context.Context, day string) ([]store.DeviceDayUsage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answerer answers one question at a time from a read-only Reader.
|
||||||
|
type Answerer struct {
|
||||||
|
Reader Reader
|
||||||
|
// Location is the schedule timezone, so "today" matches the day a device
|
||||||
|
// stamped its telemetry in. Defaults to the process local zone.
|
||||||
|
Location *time.Location
|
||||||
|
// Now is injectable so the answer is deterministic in tests.
|
||||||
|
Now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnswerer builds an Answerer over a read-only Reader.
|
||||||
|
func NewAnswerer(r Reader, loc *time.Location) *Answerer {
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
return &Answerer{Reader: r, Location: loc, Now: func() time.Time { return time.Now().In(loc) }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Answerer) now() time.Time {
|
||||||
|
if a.Now == nil {
|
||||||
|
return time.Now().In(a.loc())
|
||||||
|
}
|
||||||
|
return a.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Answerer) loc() *time.Location {
|
||||||
|
if a.Location == nil {
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
return a.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer returns a short, speakable answer for one question. It never writes
|
||||||
|
// state.
|
||||||
|
func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
|
||||||
|
now := a.now()
|
||||||
|
|
||||||
|
asker := strings.ToLower(strings.TrimSpace(req.Asker))
|
||||||
|
person := strings.ToLower(strings.TrimSpace(req.Person))
|
||||||
|
switch {
|
||||||
|
case asker == "":
|
||||||
|
return cannotAnswer("no asker was given")
|
||||||
|
case asker != "sam":
|
||||||
|
return cannotAnswer("I only answer for Sam")
|
||||||
|
}
|
||||||
|
|
||||||
|
q := normalizeQuestion(req.Question)
|
||||||
|
if q == "" {
|
||||||
|
return cannotAnswer("no question was given")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve who the question is about. The person field wins when it names a
|
||||||
|
// child; otherwise a child named in the question is used; otherwise every
|
||||||
|
// device is included.
|
||||||
|
child := ""
|
||||||
|
switch person {
|
||||||
|
case "", "sam", "kids", "children", "everyone", "both":
|
||||||
|
// aggregate (or derive from the question below)
|
||||||
|
case "harry", "finn":
|
||||||
|
child = person
|
||||||
|
default:
|
||||||
|
return cannotAnswer("no data for that person")
|
||||||
|
}
|
||||||
|
if child == "" {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(q, "harry"):
|
||||||
|
child = "harry"
|
||||||
|
case strings.Contains(q, "finn"):
|
||||||
|
child = "finn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devs, err := a.Reader.ListDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return cannotAnswer("the device list could not be read")
|
||||||
|
}
|
||||||
|
if child != "" && !knownChild(devs, child) {
|
||||||
|
return cannotAnswer("no data for that person")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case containsAny(q, "screen time", "screentime", "screen-time",
|
||||||
|
"on screens", "on screen", "screen", "how long", "spent", "usage", "used"):
|
||||||
|
return a.answerScreenTime(ctx, now, child, q)
|
||||||
|
case containsAny(q, "blocked", "block", "locked", "lock", "restricted", "restrict"):
|
||||||
|
return a.answerBlocked(ctx, now, child, devs)
|
||||||
|
case containsAny(q, "online", "offline", "connected", "reachable"):
|
||||||
|
return a.answerPresence(ctx, now, child, devs)
|
||||||
|
default:
|
||||||
|
return cannotAnswer("I can answer questions about screen time, what is blocked, and whether a device is online")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerScreenTime answers "how long have the kids been on screens today?" from
|
||||||
|
// the stored daily rollup. The day is "today" in the schedule timezone, or
|
||||||
|
// yesterday when the question says so.
|
||||||
|
func (a *Answerer) answerScreenTime(ctx context.Context, now time.Time, child, q string) AskResponse {
|
||||||
|
day := now
|
||||||
|
if containsAny(q, "yesterday", "last night") {
|
||||||
|
day = now.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
dayStr := day.Format("2006-01-02")
|
||||||
|
|
||||||
|
usage, err := a.Reader.UsageByDeviceForDay(ctx, dayStr)
|
||||||
|
if err != nil {
|
||||||
|
return cannotAnswer("usage could not be read")
|
||||||
|
}
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
byDevice := make([]map[string]any, 0, len(usage))
|
||||||
|
for _, u := range usage {
|
||||||
|
if child != "" && !strings.EqualFold(strings.TrimSpace(u.Child), child) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seconds := ScreenSeconds(u.AppSeconds, u.DomainSeconds)
|
||||||
|
total += seconds
|
||||||
|
byDevice = append(byDevice, map[string]any{
|
||||||
|
"device": u.Name,
|
||||||
|
"child": u.Child,
|
||||||
|
"seconds": seconds,
|
||||||
|
"app_seconds": u.AppSeconds,
|
||||||
|
"domain_seconds": u.DomainSeconds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
when := "today"
|
||||||
|
if strings.EqualFold(dayStr, now.AddDate(0, 0, -1).Format("2006-01-02")) {
|
||||||
|
when = "yesterday"
|
||||||
|
}
|
||||||
|
subject := "The kids"
|
||||||
|
verb := "have"
|
||||||
|
if child != "" {
|
||||||
|
subject = titleCase(child)
|
||||||
|
verb = "has"
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := fmt.Sprintf("%s %s had no recorded screen time %s.", subject, verb, when)
|
||||||
|
if total > 0 {
|
||||||
|
answer = fmt.Sprintf("%s %s had %s of screen time %s.", subject, verb, humanDuration(total), when)
|
||||||
|
}
|
||||||
|
return AskResponse{
|
||||||
|
Answer: answerText(answer),
|
||||||
|
Data: map[string]any{"day": dayStr, "total_seconds": total, "by_device": byDevice},
|
||||||
|
Expires: expiresIn(now, 15*time.Minute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerBlocked reports whether anything is blocked right now, and until when.
|
||||||
|
func (a *Answerer) answerBlocked(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse {
|
||||||
|
blocked := make([]map[string]any, 0, len(devs))
|
||||||
|
for _, dev := range devs {
|
||||||
|
if !matchesChild(dev, child) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eff, err := a.Reader.EffectiveState(ctx, dev, now)
|
||||||
|
if err != nil {
|
||||||
|
return cannotAnswer("device state could not be read")
|
||||||
|
}
|
||||||
|
if !eff.Locked {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := map[string]any{
|
||||||
|
"device": dev.Name,
|
||||||
|
"child": dev.Child,
|
||||||
|
"reason": eff.Reason,
|
||||||
|
}
|
||||||
|
if eff.Until != nil {
|
||||||
|
item["until"] = eff.Until.UTC().Format(time.RFC3339)
|
||||||
|
item["until_local"] = eff.Until.In(a.loc()).Format("15:04")
|
||||||
|
}
|
||||||
|
blocked = append(blocked, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := AskResponse{
|
||||||
|
Data: map[string]any{"blocked": blocked, "count": len(blocked)},
|
||||||
|
Expires: expiresIn(now, 5*time.Minute),
|
||||||
|
}
|
||||||
|
switch len(blocked) {
|
||||||
|
case 0:
|
||||||
|
resp.Answer = answerText("Nothing is blocked right now.")
|
||||||
|
case 1:
|
||||||
|
name, _ := blocked[0]["device"].(string)
|
||||||
|
reason, _ := blocked[0]["reason"].(string)
|
||||||
|
if until, ok := blocked[0]["until_local"].(string); ok && until != "" {
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("%s is blocked until %s (%s).", name, until, reason))
|
||||||
|
} else {
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("%s is blocked (%s).", name, reason))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("%d devices are blocked right now.", len(blocked)))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerPresence reports whether the devices are online. Presence comes from
|
||||||
|
// the broker's Last Will and Testament (FR-6).
|
||||||
|
func (a *Answerer) answerPresence(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse {
|
||||||
|
selected := make([]store.Device, 0, len(devs))
|
||||||
|
for _, dev := range devs {
|
||||||
|
if matchesChild(dev, child) {
|
||||||
|
selected = append(selected, dev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return cannotAnswer("no devices are registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
offline := make([]map[string]any, 0, len(selected))
|
||||||
|
online := 0
|
||||||
|
for _, dev := range selected {
|
||||||
|
if dev.Online {
|
||||||
|
online++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := map[string]any{"device": dev.Name, "child": dev.Child}
|
||||||
|
if dev.LastSeen != nil {
|
||||||
|
item["last_seen"] = dev.LastSeen.UTC().Format(time.RFC3339)
|
||||||
|
item["last_seen_local"] = dev.LastSeen.In(a.loc()).Format("15:04")
|
||||||
|
item["offline_for"] = humanDuration(int(now.Sub(*dev.LastSeen).Seconds()))
|
||||||
|
}
|
||||||
|
offline = append(offline, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := AskResponse{
|
||||||
|
Data: map[string]any{"online": online, "offline": offline, "total": len(selected)},
|
||||||
|
Expires: expiresIn(now, 5*time.Minute),
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(offline) == 0:
|
||||||
|
if len(selected) == 1 {
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("%s is online.", selected[0].Name))
|
||||||
|
} else {
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("All %d devices are online.", len(selected)))
|
||||||
|
}
|
||||||
|
case len(offline) == 1:
|
||||||
|
name, _ := offline[0]["device"].(string)
|
||||||
|
dur, _ := offline[0]["offline_for"].(string)
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("%s is offline (for %s).", name, dur))
|
||||||
|
default:
|
||||||
|
resp.Answer = answerText(fmt.Sprintf("%d devices are offline.", len(offline)))
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenSeconds is the screen-time measure for one device-day. The
|
||||||
|
// foreground-app total is the measure; domain seconds are a browsing
|
||||||
|
// sub-measure and are only used when a device reported no app rows at all (the
|
||||||
|
// phone's VPN filter, for example). A report that carries both is not
|
||||||
|
// double-counted.
|
||||||
|
func ScreenSeconds(app, domain int) int {
|
||||||
|
if app > 0 {
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownChild reports whether any registered device belongs to child.
|
||||||
|
func knownChild(devs []store.Device, child string) bool {
|
||||||
|
for _, dev := range devs {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(dev.Child), child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesChild reports whether a device is in scope for a question. An empty
|
||||||
|
// child means every device.
|
||||||
|
func matchesChild(dev store.Device, child string) bool {
|
||||||
|
return child == "" || strings.EqualFold(strings.TrimSpace(dev.Child), child)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeQuestion lowercases and collapses whitespace so intent matching is
|
||||||
|
// stable.
|
||||||
|
func normalizeQuestion(q string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.ToLower(q)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(q string, subs ...string) bool {
|
||||||
|
for _, s := range subs {
|
||||||
|
if strings.Contains(q, s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiresIn formats an RFC3339 expiry used by data-bearing answers.
|
||||||
|
func expiresIn(now time.Time, d time.Duration) string {
|
||||||
|
return now.UTC().Add(d).Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleCase upper-cases the first letter of a single-word name.
|
||||||
|
func titleCase(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
return strings.ToUpper(string(r[0])) + string(r[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanDuration renders seconds compactly: "3h 20m", "45m", "under a minute".
|
||||||
|
func humanDuration(seconds int) string {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return "0m"
|
||||||
|
}
|
||||||
|
if seconds < 60 {
|
||||||
|
return "under a minute"
|
||||||
|
}
|
||||||
|
m := seconds / 60
|
||||||
|
h := m / 60
|
||||||
|
m %= 60
|
||||||
|
if h == 0 {
|
||||||
|
return fmt.Sprintf("%dm", m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh %02dm", h, m)
|
||||||
|
}
|
||||||
211
internal/hub/ask_test.go
Normal file
211
internal/hub/ask_test.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeReader is a read-only Reader backed by fixed data.
|
||||||
|
type fakeReader struct {
|
||||||
|
devices []store.Device
|
||||||
|
eff map[int64]store.EffectiveState
|
||||||
|
usage map[string][]store.DeviceDayUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReader) ListDevices(context.Context) ([]store.Device, error) {
|
||||||
|
return f.devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReader) EffectiveState(_ context.Context, dev store.Device, _ time.Time) (store.EffectiveState, error) {
|
||||||
|
return f.eff[dev.ID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReader) UsageByDeviceForDay(_ context.Context, day string) ([]store.DeviceDayUsage, error) {
|
||||||
|
return f.usage[day], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnswerer(r Reader, now time.Time) *Answerer {
|
||||||
|
loc := time.UTC
|
||||||
|
a := NewAnswerer(r, loc)
|
||||||
|
a.Now = func() time.Time { return now.In(loc) }
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustAnswer(t *testing.T, a *Answerer, req AskRequest) AskResponse {
|
||||||
|
t.Helper()
|
||||||
|
return a.Answer(context.Background(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func answerTextOf(t *testing.T, resp AskResponse) string {
|
||||||
|
t.Helper()
|
||||||
|
if resp.Answer == nil {
|
||||||
|
t.Fatalf("answer = nil, reason = %q", resp.Reason)
|
||||||
|
}
|
||||||
|
return *resp.Answer
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenTimeToday(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{
|
||||||
|
{ID: 1, Name: "Harry's phone", Child: "Harry"},
|
||||||
|
{ID: 2, Name: "Finn's computer", Child: "Finn"},
|
||||||
|
},
|
||||||
|
usage: map[string][]store.DeviceDayUsage{
|
||||||
|
"2026-10-13": {
|
||||||
|
{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 3600, DomainSeconds: 900},
|
||||||
|
// app and domain for the same session must not be summed.
|
||||||
|
{DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 1800, DomainSeconds: 2000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
|
||||||
|
resp := mustAnswer(t, a, AskRequest{
|
||||||
|
Question: "how long have the kids been on screens today?",
|
||||||
|
Asker: "sam",
|
||||||
|
})
|
||||||
|
got := answerTextOf(t, resp)
|
||||||
|
if !strings.Contains(got, "1h 30m") {
|
||||||
|
t.Errorf("answer = %q, want 1h 30m", got)
|
||||||
|
}
|
||||||
|
if total, _ := resp.Data["total_seconds"].(int); total != 5400 {
|
||||||
|
t.Errorf("total_seconds = %v, want 5400 (app totals, no double count)", resp.Data["total_seconds"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenTimeOneChild(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{
|
||||||
|
{ID: 1, Name: "Harry's phone", Child: "Harry"},
|
||||||
|
{ID: 2, Name: "Finn's computer", Child: "Finn"},
|
||||||
|
},
|
||||||
|
usage: map[string][]store.DeviceDayUsage{
|
||||||
|
"2026-10-13": {
|
||||||
|
{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 2700},
|
||||||
|
{DeviceID: 2, Name: "Finn's computer", Child: "Finn", AppSeconds: 900},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
resp := mustAnswer(t, a, AskRequest{Question: "how long has Harry been on screens?", Asker: "sam"})
|
||||||
|
if got := answerTextOf(t, resp); !strings.Contains(got, "45m") || !strings.Contains(got, "Harry") {
|
||||||
|
t.Errorf("answer = %q, want Harry 45m", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenTimeYesterday(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
|
||||||
|
usage: map[string][]store.DeviceDayUsage{
|
||||||
|
"2026-10-12": {{DeviceID: 1, Name: "Harry's phone", Child: "Harry", AppSeconds: 120}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
resp := mustAnswer(t, a, AskRequest{Question: "how long were the kids on screens yesterday?", Asker: "sam"})
|
||||||
|
if got := answerTextOf(t, resp); !strings.Contains(got, "yesterday") {
|
||||||
|
t.Errorf("answer = %q, want yesterday", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenTimeNoneRecorded(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
|
||||||
|
usage: map[string][]store.DeviceDayUsage{},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
resp := mustAnswer(t, a, AskRequest{Question: "how long have the kids been on screens today?", Asker: "sam"})
|
||||||
|
if got := answerTextOf(t, resp); !strings.Contains(got, "no recorded screen time") {
|
||||||
|
t.Errorf("answer = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockedAnswer(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC)
|
||||||
|
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
|
||||||
|
eff: map[int64]store.EffectiveState{
|
||||||
|
1: {DeviceID: 1, Locked: true, Reason: "schedule", Until: &until},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
resp := mustAnswer(t, a, AskRequest{Question: "what is blocked right now?", Asker: "sam"})
|
||||||
|
if got := answerTextOf(t, resp); !strings.Contains(got, "Harry's phone") || !strings.Contains(got, "21:00") {
|
||||||
|
t.Errorf("answer = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresenceAnswer(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
lastSeen := now.Add(-2*time.Hour - 10*time.Minute)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{
|
||||||
|
{ID: 1, Name: "Harry's phone", Child: "Harry", Online: true},
|
||||||
|
{ID: 2, Name: "Finn's computer", Child: "Finn", Online: false, LastSeen: &lastSeen},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
resp := mustAnswer(t, a, AskRequest{Question: "are the devices online?", Asker: "sam"})
|
||||||
|
if got := answerTextOf(t, resp); !strings.Contains(got, "Finn's computer") || !strings.Contains(got, "2h 10m") {
|
||||||
|
t.Errorf("answer = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionsAndUnknowns(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||||
|
r := &fakeReader{
|
||||||
|
devices: []store.Device{{ID: 1, Name: "Harry's phone", Child: "Harry"}},
|
||||||
|
}
|
||||||
|
a := testAnswerer(r, now)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
req AskRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no asker", AskRequest{Question: "screen time?"}, "no asker"},
|
||||||
|
{"not sam", AskRequest{Question: "screen time?", Asker: "harry"}, "only answer for Sam"},
|
||||||
|
{"unknown person", AskRequest{Question: "screen time?", Asker: "sam", Person: "jo"}, "no data for that person"},
|
||||||
|
{"unknown intent", AskRequest{Question: "what is the weather?", Asker: "sam"}, "I can answer"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
resp := mustAnswer(t, a, tc.req)
|
||||||
|
if resp.Answer != nil {
|
||||||
|
t.Errorf("%s: answer = %q, want nil", tc.name, *resp.Answer)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Reason, tc.want) {
|
||||||
|
t.Errorf("%s: reason = %q, want to contain %q", tc.name, resp.Reason, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(mustAnswer(t, a, AskRequest{Question: "", Asker: "sam"}).Reason, "no question") {
|
||||||
|
t.Error("empty question should be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCapabilities(t *testing.T) {
|
||||||
|
c := DefaultCapabilities()
|
||||||
|
if c.Project != "internet_use_control" {
|
||||||
|
t.Errorf("project = %q", c.Project)
|
||||||
|
}
|
||||||
|
if len(c.Topics) == 0 || len(c.People) == 0 {
|
||||||
|
t.Errorf("capabilities = %#v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenSecondsPrefersApp(t *testing.T) {
|
||||||
|
if got := ScreenSeconds(600, 900); got != 600 {
|
||||||
|
t.Errorf("screenSeconds(600,900) = %d, want 600", got)
|
||||||
|
}
|
||||||
|
if got := ScreenSeconds(0, 900); got != 900 {
|
||||||
|
t.Errorf("screenSeconds(0,900) = %d, want 900", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
internal/hub/hub.go
Normal file
74
internal/hub/hub.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Package hub implements the project side of the Hub contract
|
||||||
|
// (central_ai_llm_master/docs/ask-contract.md) for the Internet Use Control
|
||||||
|
// Control Service.
|
||||||
|
//
|
||||||
|
// The contract is one-directional by design: the Hub asks a project about its
|
||||||
|
// own data and the project answers. A project never learns the Hub's data model
|
||||||
|
// and the Hub never learns the project's.
|
||||||
|
//
|
||||||
|
// GET /capabilities -> the topics this project can answer (Hub routing)
|
||||||
|
// POST /ask -> a short answer about this project's data
|
||||||
|
// POST /notify -> accept a notification and deliver it to Sam
|
||||||
|
//
|
||||||
|
// /ask and /capabilities are read-only. /ask answers from SQLite and never
|
||||||
|
// writes, so the "read only" promise (AGENTS.md §10 rule 9) is a testable
|
||||||
|
// property, not a comment (TestAskWritesNothing).
|
||||||
|
package hub
|
||||||
|
|
||||||
|
// ProjectName is the value the Hub matches in routing.md.
|
||||||
|
const ProjectName = "internet_use_control"
|
||||||
|
|
||||||
|
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
|
||||||
|
// question to this project. Keep the topic list short and plain; it holds no
|
||||||
|
// private data.
|
||||||
|
type Capabilities struct {
|
||||||
|
Project string `json:"project"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
People []string `json:"people"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCapabilities returns the topics this service can answer. They match
|
||||||
|
// the screen-time domain in AGENTS.md §1 and §4.
|
||||||
|
func DefaultCapabilities() Capabilities {
|
||||||
|
return Capabilities{
|
||||||
|
Project: ProjectName,
|
||||||
|
Topics: []string{"internet", "screen time", "screens", "usage", "devices", "blocking", "kids"},
|
||||||
|
People: []string{"harry", "finn"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskRequest is the POST /ask body (ask-contract.md).
|
||||||
|
type AskRequest struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Asker string `json:"asker"`
|
||||||
|
Person string `json:"person,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
|
||||||
|
// answer and Reason explains why. The shape is frozen by the contract.
|
||||||
|
type AskResponse struct {
|
||||||
|
Answer *string `json:"answer"`
|
||||||
|
Data map[string]any `json:"data,omitempty"`
|
||||||
|
Expires string `json:"expires,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyRequest is the POST /notify body (ask-contract.md, "Notify"). The
|
||||||
|
// contract defines it as project-to-Hub; this service accepts the same shape so
|
||||||
|
// a caller (Hub, admin app, or a future phone tamper report) can have Sam
|
||||||
|
// notified without inventing a second shape.
|
||||||
|
type NotifyRequest struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Person string `json:"person,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerText is a small helper so pointer handling stays in one place.
|
||||||
|
func answerText(s string) *string { return &s }
|
||||||
|
|
||||||
|
// cannotAnswer builds the contract's "cannot answer" reply.
|
||||||
|
func cannotAnswer(reason string) AskResponse {
|
||||||
|
return AskResponse{Answer: nil, Reason: reason}
|
||||||
|
}
|
||||||
224
internal/store/alerts.go
Normal file
224
internal/store/alerts.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// Alerts (AGENTS.md §4 FR-13, §7 retention, §11 Phase 9).
|
||||||
|
//
|
||||||
|
// An alert is a message the Control Service decided Sam should see: a device
|
||||||
|
// hit its limit (a blocking window turned on), or a device has been offline too
|
||||||
|
// long. The engine (internal/alert) raises them and delivers them through
|
||||||
|
// Apprise; this file only records what happened.
|
||||||
|
//
|
||||||
|
// `alerts` is append-only except for the delivery outcome, so an operator can
|
||||||
|
// always tell what fired and whether it reached Sam. `alert_state` is the small
|
||||||
|
// per-device dedup memory that turns a transition into exactly one alert.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert kinds. 'limit' is a device that became blocked, 'offline' is a device
|
||||||
|
// unreachable for too long, 'notify' is an accepted notification delivered on
|
||||||
|
// demand (for example the future phone tamper report, FR-17).
|
||||||
|
const (
|
||||||
|
AlertLimit = "limit"
|
||||||
|
AlertOffline = "offline"
|
||||||
|
AlertNotify = "notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert is one recorded alert.
|
||||||
|
type Alert struct {
|
||||||
|
ID int64
|
||||||
|
DeviceID *int64
|
||||||
|
DeviceName string
|
||||||
|
Kind string
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Delivered bool
|
||||||
|
DeliveredAt *time.Time
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertState is the last observed condition for one device. It exists only to
|
||||||
|
// suppress duplicate alerts while a condition stays true.
|
||||||
|
type AlertState struct {
|
||||||
|
Locked bool
|
||||||
|
Offline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAlert records one alert and appends it to the audit log in the same
|
||||||
|
// transaction (FR-15). deviceID may be nil for a notification with no device.
|
||||||
|
func (s *Store) CreateAlert(ctx context.Context, deviceID *int64, kind, message string) (*Alert, error) {
|
||||||
|
switch kind {
|
||||||
|
case AlertLimit, AlertOffline, AlertNotify:
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("create alert: unknown kind %q", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create alert: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var idArg any
|
||||||
|
if deviceID != nil {
|
||||||
|
idArg = *deviceID
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO alerts (device_id, kind, message) VALUES (?, ?, ?)`,
|
||||||
|
idArg, kind, message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create alert: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create alert id: %w", err)
|
||||||
|
}
|
||||||
|
if err := insertAudit(ctx, tx, "alert", "alert."+kind, deviceID, message); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit alert: %w", err)
|
||||||
|
}
|
||||||
|
return s.GetAlert(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAlertDelivered records the delivery outcome. An empty errMsg means Apprise
|
||||||
|
// accepted the notification.
|
||||||
|
func (s *Store) MarkAlertDelivered(ctx context.Context, id int64, errMsg string) error {
|
||||||
|
delivered := 0
|
||||||
|
if errMsg == "" {
|
||||||
|
delivered = 1
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE alerts SET delivered = ?, delivered_at = datetime('now'), error = ?
|
||||||
|
WHERE id = ?`, delivered, nullString(errMsg), id); err != nil {
|
||||||
|
return fmt.Errorf("mark alert delivered: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlert returns one alert, or ErrNotFound.
|
||||||
|
func (s *Store) GetAlert(ctx context.Context, id int64) (*Alert, error) {
|
||||||
|
row := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT a.id, a.device_id, COALESCE(d.name, ''), a.kind, a.message,
|
||||||
|
a.created_at, a.delivered, a.delivered_at, COALESCE(a.error, '')
|
||||||
|
FROM alerts a
|
||||||
|
LEFT JOIN devices d ON d.id = a.device_id
|
||||||
|
WHERE a.id = ?`, id)
|
||||||
|
a, err := scanAlert(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get alert: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAlerts returns the newest alerts first, for the Alerts screen and for
|
||||||
|
// verification. A non-positive or oversized limit is clamped.
|
||||||
|
func (s *Store) ListAlerts(ctx context.Context, limit int) ([]Alert, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT a.id, a.device_id, COALESCE(d.name, ''), a.kind, a.message,
|
||||||
|
a.created_at, a.delivered, a.delivered_at, COALESCE(a.error, '')
|
||||||
|
FROM alerts a
|
||||||
|
LEFT JOIN devices d ON d.id = a.device_id
|
||||||
|
ORDER BY a.id DESC
|
||||||
|
LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list alerts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]Alert, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAlert(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan alert: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("list alerts: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertStates returns the dedup state for every device that has one. A device
|
||||||
|
// with no row is a device the engine has not seen yet.
|
||||||
|
func (s *Store) AlertStates(ctx context.Context) (map[int64]AlertState, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT device_id, locked, offline FROM alert_state`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("alert states: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make(map[int64]AlertState)
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
locked, offln int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &locked, &offln); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan alert state: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = AlertState{Locked: locked != 0, Offline: offln != 0}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("alert states: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAlertState records the last observed condition for one device.
|
||||||
|
func (s *Store) SetAlertState(ctx context.Context, deviceID int64, locked, offline bool) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO alert_state (device_id, locked, offline, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(device_id) DO UPDATE SET
|
||||||
|
locked = excluded.locked,
|
||||||
|
offline = excluded.offline,
|
||||||
|
updated_at = datetime('now')`,
|
||||||
|
deviceID, boolToInt(locked), boolToInt(offline)); err != nil {
|
||||||
|
return fmt.Errorf("set alert state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAlert(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (*Alert, error) {
|
||||||
|
var (
|
||||||
|
a Alert
|
||||||
|
deviceID sql.NullInt64
|
||||||
|
created sql.NullString
|
||||||
|
delivered int
|
||||||
|
deliveredAt sql.NullString
|
||||||
|
)
|
||||||
|
if err := row.Scan(&a.ID, &deviceID, &a.DeviceName, &a.Kind, &a.Message,
|
||||||
|
&created, &delivered, &deliveredAt, &a.Error); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if deviceID.Valid {
|
||||||
|
id := deviceID.Int64
|
||||||
|
a.DeviceID = &id
|
||||||
|
}
|
||||||
|
a.CreatedAt = parseTime(created.String)
|
||||||
|
a.Delivered = delivered != 0
|
||||||
|
a.DeliveredAt = parseTimePtr(deliveredAt)
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullString maps an empty message to SQL NULL, so an undelivered-without-error
|
||||||
|
// row is distinguishable from a delivered one.
|
||||||
|
func nullString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
134
internal/store/alerts_test.go
Normal file
134
internal/store/alerts_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertLifecycle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := testStore(t)
|
||||||
|
dev := mustCreateDevice(t, st, "Harry's phone", "Harry", "android")
|
||||||
|
|
||||||
|
a, err := st.CreateAlert(ctx, &dev.ID, AlertLimit, "Harry's phone reached its limit.")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAlert: %v", err)
|
||||||
|
}
|
||||||
|
if a.Delivered {
|
||||||
|
t.Error("a fresh alert must not be delivered")
|
||||||
|
}
|
||||||
|
if a.DeviceName != "Harry's phone" {
|
||||||
|
t.Errorf("device name = %q", a.DeviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alert is audited.
|
||||||
|
audit, err := st.ListAudit(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAudit: %v", err)
|
||||||
|
}
|
||||||
|
if len(audit) == 0 || audit[0].Action != "alert.limit" {
|
||||||
|
t.Fatalf("audit = %#v, want an alert.limit row", audit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.MarkAlertDelivered(ctx, a.ID, ""); err != nil {
|
||||||
|
t.Fatalf("MarkAlertDelivered: %v", err)
|
||||||
|
}
|
||||||
|
got, err := st.GetAlert(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlert: %v", err)
|
||||||
|
}
|
||||||
|
if !got.Delivered || got.DeliveredAt == nil || got.Error != "" {
|
||||||
|
t.Errorf("delivered alert = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delivery failure is recorded, not hidden.
|
||||||
|
b, err := st.CreateAlert(ctx, nil, AlertNotify, "message")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAlert notify: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.MarkAlertDelivered(ctx, b.ID, "post apprise: status 500"); err != nil {
|
||||||
|
t.Fatalf("MarkAlertDelivered: %v", err)
|
||||||
|
}
|
||||||
|
got, err = st.GetAlert(ctx, b.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlert: %v", err)
|
||||||
|
}
|
||||||
|
if got.Delivered || got.Error != "post apprise: status 500" {
|
||||||
|
t.Errorf("failed alert = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := st.CreateAlert(ctx, nil, "bogus", "x"); err == nil {
|
||||||
|
t.Error("CreateAlert accepted an unknown kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertStatesRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := testStore(t)
|
||||||
|
dev := mustCreateDevice(t, st, "Finn's computer", "Finn", "windows")
|
||||||
|
|
||||||
|
states, err := st.AlertStates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AlertStates: %v", err)
|
||||||
|
}
|
||||||
|
if len(states) != 0 {
|
||||||
|
t.Fatalf("states = %#v, want empty", states)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.SetAlertState(ctx, dev.ID, true, false); err != nil {
|
||||||
|
t.Fatalf("SetAlertState: %v", err)
|
||||||
|
}
|
||||||
|
states, err = st.AlertStates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AlertStates: %v", err)
|
||||||
|
}
|
||||||
|
if st := states[dev.ID]; !st.Locked || st.Offline {
|
||||||
|
t.Fatalf("state = %#v, want locked only", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.SetAlertState(ctx, dev.ID, false, true); err != nil {
|
||||||
|
t.Fatalf("SetAlertState: %v", err)
|
||||||
|
}
|
||||||
|
states, _ = st.AlertStates(ctx)
|
||||||
|
if st := states[dev.ID]; st.Locked || !st.Offline {
|
||||||
|
t.Fatalf("state = %#v, want offline only", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageByDeviceForDay(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := testStore(t)
|
||||||
|
harry := mustCreateDevice(t, st, "Harry's phone", "Harry", "android")
|
||||||
|
finn := mustCreateDevice(t, st, "Finn's computer", "Finn", "windows")
|
||||||
|
day := time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
|
||||||
|
|
||||||
|
if _, err := st.InsertTelemetry(ctx, harry.ID, day, []TelemetryEntry{
|
||||||
|
{App: "com.spotify.music", Seconds: 3600},
|
||||||
|
{Domain: "youtube.com", Seconds: 900},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertTelemetry: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.InsertTelemetry(ctx, finn.ID, day, []TelemetryEntry{{App: "chrome", Seconds: 1800}}); err != nil {
|
||||||
|
t.Fatalf("InsertTelemetry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := st.UsageByDeviceForDay(ctx, day)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageByDeviceForDay: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("rows = %d, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Name != "Harry's phone" || rows[0].AppSeconds != 3600 || rows[0].DomainSeconds != 900 {
|
||||||
|
t.Errorf("harry row = %#v", rows[0])
|
||||||
|
}
|
||||||
|
if rows[1].Child != "Finn" || rows[1].AppSeconds != 1800 {
|
||||||
|
t.Errorf("finn row = %#v", rows[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day with no reports returns nothing.
|
||||||
|
if rows, err := st.UsageByDeviceForDay(ctx, "2000-01-01"); err != nil || len(rows) != 0 {
|
||||||
|
t.Fatalf("empty day: rows=%#v err=%v", rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/store/migrations/005_alerts.sql
Normal file
33
internal/store/migrations/005_alerts.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Migration 005 — Phase 9. Alerts and their dedup state (AGENTS.md §4 FR-13,
|
||||||
|
-- §5 screen 6, §11 Phase 9).
|
||||||
|
--
|
||||||
|
-- `alerts` is the append-only record of every alert the Control Service raised:
|
||||||
|
-- a schedule/limit hit, a device offline too long, or an accepted notify. It is
|
||||||
|
-- what the Alerts screen reads and what the delivery evidence is checked
|
||||||
|
-- against. Nothing is ever edited except the delivery outcome columns.
|
||||||
|
--
|
||||||
|
-- `alert_state` remembers the last observed condition per device, so an alert
|
||||||
|
-- fires once on a transition (unlocked -> locked, reachable -> offline-too-long)
|
||||||
|
-- instead of on every poll. It is derived state, safe to delete; the service
|
||||||
|
-- re-seeds it without alerting.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
device_id INTEGER REFERENCES devices(id),
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('limit', 'offline', 'notify')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
delivered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
delivered_at TEXT,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_created ON alerts(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_device ON alerts(device_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_state (
|
||||||
|
device_id INTEGER PRIMARY KEY REFERENCES devices(id),
|
||||||
|
locked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
offline INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
@@ -260,6 +260,79 @@ func (s *Store) TopUsage(ctx context.Context, id int64, from, to, kind string, l
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeviceDayUsage is one device's rolled-up usage for one day. AppSeconds and
|
||||||
|
// DomainSeconds are kept apart for the same reason as the rollup: a report may
|
||||||
|
// carry both for one session, so adding them together would double-count time.
|
||||||
|
// The screen-time answer decides how to combine them (internal/hub).
|
||||||
|
type DeviceDayUsage struct {
|
||||||
|
DeviceID int64
|
||||||
|
Name string
|
||||||
|
Child string
|
||||||
|
AppSeconds int
|
||||||
|
DomainSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageByDeviceForDay returns the daily rollup for every device that reported
|
||||||
|
// anything on day (YYYY-MM-DD). It is the read the screen-time answer is built
|
||||||
|
// from. Devices with no row are absent; the caller treats them as zero.
|
||||||
|
func (s *Store) UsageByDeviceForDay(ctx context.Context, day string) ([]DeviceDayUsage, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT d.id, d.name, d.child, td.app_seconds, td.domain_seconds
|
||||||
|
FROM telemetry_daily td
|
||||||
|
JOIN devices d ON d.id = td.device_id
|
||||||
|
WHERE td.day = ?
|
||||||
|
ORDER BY d.id`, day)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("usage by device for day: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]DeviceDayUsage, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
var u DeviceDayUsage
|
||||||
|
if err := rows.Scan(&u.DeviceID, &u.Name, &u.Child, &u.AppSeconds, &u.DomainSeconds); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan device day usage: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("usage by device for day: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailUsageByDeviceForDay is the same measure computed straight from the
|
||||||
|
// telemetry detail table instead of the derived rollup. It exists so an
|
||||||
|
// operator can independently check the rollup the daily answer reads.
|
||||||
|
func (s *Store) DetailUsageByDeviceForDay(ctx context.Context, day string) ([]DeviceDayUsage, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT d.id, d.name, d.child,
|
||||||
|
COALESCE(SUM(CASE WHEN t.app <> '' THEN t.seconds END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN t.domain <> '' THEN t.seconds END), 0)
|
||||||
|
FROM telemetry t
|
||||||
|
JOIN devices d ON d.id = t.device_id
|
||||||
|
WHERE t.day = ?
|
||||||
|
GROUP BY d.id
|
||||||
|
ORDER BY d.id`, day)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("detail usage by device for day: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]DeviceDayUsage, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
var u DeviceDayUsage
|
||||||
|
if err := rows.Scan(&u.DeviceID, &u.Name, &u.Child, &u.AppSeconds, &u.DomainSeconds); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan detail device day usage: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("detail usage by device for day: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CapAudit keeps the newest max audit rows and deletes the oldest beyond that,
|
// CapAudit keeps the newest max audit rows and deletes the oldest beyond that,
|
||||||
// so the append-only log cannot grow without bound (AGENTS.md §7 retention). It
|
// so the append-only log cannot grow without bound (AGENTS.md §7 retention). It
|
||||||
// returns how many rows it removed. Nothing in the audit log is ever edited.
|
// returns how many rows it removed. Nothing in the audit log is ever edited.
|
||||||
|
|||||||
192
internal/web/hub_api.go
Normal file
192
internal/web/hub_api.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// The Hub contract surface (AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9).
|
||||||
|
//
|
||||||
|
// The project side of central_ai_llm_master/docs/ask-contract.md:
|
||||||
|
//
|
||||||
|
// GET /capabilities -> the topics this service answers (Hub routing)
|
||||||
|
// POST /ask -> a short answer about screen time and devices
|
||||||
|
// POST /notify -> accept a notification and deliver it through Apprise
|
||||||
|
//
|
||||||
|
// All three require the shared Hub bearer token. A service-to-service call must
|
||||||
|
// never be trusted unsigned. /ask and /capabilities are read-only: they call
|
||||||
|
// only store read methods (AGENTS.md §10 rule 9). /notify is the one write on
|
||||||
|
// this surface, and it only records and delivers a message; it never changes
|
||||||
|
// device state.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hubMaxBody bounds a Hub request body. Questions and notifications are small.
|
||||||
|
const hubMaxBody = 64 << 10 // 64 KiB
|
||||||
|
|
||||||
|
// notifyResponse is this service's own reply to POST /notify. The contract does
|
||||||
|
// not freeze a response shape for the receiver, so this is kept minimal.
|
||||||
|
type notifyResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubAuthRequired writes the contract-shaped refusal when the Hub API has no
|
||||||
|
// token. With no token the endpoints refuse to serve rather than trust an
|
||||||
|
// unsigned request.
|
||||||
|
func (s *Server) hubAuthRequired(w http.ResponseWriter) bool {
|
||||||
|
if !s.cfg.HubConfigured() {
|
||||||
|
writeHubJSON(w, http.StatusServiceUnavailable, hub.AskResponse{
|
||||||
|
Answer: nil,
|
||||||
|
Reason: "the Hub API is not configured",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireHub enforces the shared bearer token on the Hub endpoints.
|
||||||
|
func (s *Server) requireHub(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.hubAuthRequired(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, ok := bearerToken(r)
|
||||||
|
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.HubToken)) != 1 {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
_ = s.store.Audit(ctx, "anonymous", "hub.token_rejected", nil, r.URL.Path+" from "+clientIP(r))
|
||||||
|
cancel()
|
||||||
|
s.log.Warn("hub api rejected", "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||||
|
w.Header().Set("WWW-Authenticate", `Bearer realm="internet-use-control"`)
|
||||||
|
writeHubJSON(w, http.StatusUnauthorized, hub.AskResponse{
|
||||||
|
Answer: nil,
|
||||||
|
Reason: "unauthorized",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCapabilities returns the routing topics for the Hub. It holds no
|
||||||
|
// private data.
|
||||||
|
func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeHubJSON(w, http.StatusOK, hub.DefaultCapabilities())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAsk answers one question from this project's read-only data. The reply
|
||||||
|
// is always HTTP 200 with either an answer or a reason, so the Hub can relay a
|
||||||
|
// "cannot answer" without treating the project as down.
|
||||||
|
func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var req hub.AskRequest
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody))
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
writeHubJSON(w, http.StatusOK, hub.AskResponse{
|
||||||
|
Answer: nil,
|
||||||
|
Reason: "the request body is not valid JSON",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
resp := s.answerer.Answer(ctx, req)
|
||||||
|
s.log.Info("hub ask",
|
||||||
|
"asker", req.Asker,
|
||||||
|
"person", req.Person,
|
||||||
|
"source", req.Source,
|
||||||
|
"answered", resp.Answer != nil,
|
||||||
|
"duration_ms", time.Since(started).Milliseconds(),
|
||||||
|
)
|
||||||
|
writeHubJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNotify accepts a notification in the frozen contract's shape and
|
||||||
|
// delivers it to Sam through Apprise. It is how a tamper report from a phone
|
||||||
|
// client (FR-17) will reach Sam without inventing a second shape. The message
|
||||||
|
// is recorded before delivery so a delivery failure is visible.
|
||||||
|
func (s *Server) handleNotify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var req hub.NotifyRequest
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, hubMaxBody))
|
||||||
|
if err := dec.Decode(&req); err != nil {
|
||||||
|
writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "invalid JSON body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
from := strings.TrimSpace(req.From)
|
||||||
|
if from == "" {
|
||||||
|
from = hub.ProjectName
|
||||||
|
}
|
||||||
|
level := strings.ToLower(strings.TrimSpace(req.Level))
|
||||||
|
switch level {
|
||||||
|
case "":
|
||||||
|
level = "info"
|
||||||
|
case "info", "important", "urgent":
|
||||||
|
default:
|
||||||
|
writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "level must be info, important or urgent"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message := strings.TrimSpace(req.Message)
|
||||||
|
if message == "" {
|
||||||
|
writeHubJSON(w, http.StatusBadRequest, notifyResponse{Error: "message is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(message) > 500 {
|
||||||
|
message = message[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := fmt.Sprintf("[%s] %s — from %s", level, message, from)
|
||||||
|
record, err := s.store.CreateAlert(ctx, nil, store.AlertNotify, detail)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("notify record", "from", from, "err", err)
|
||||||
|
writeHubJSON(w, http.StatusInternalServerError, notifyResponse{Error: "could not record the notification"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.apprise.Configured() {
|
||||||
|
_ = s.store.MarkAlertDelivered(ctx, record.ID, "apprise is not configured")
|
||||||
|
writeHubJSON(w, http.StatusServiceUnavailable, notifyResponse{Error: "apprise is not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.apprise.SendType(ctx, "Internet Use Control — "+from, message, notifyType(level)); err != nil {
|
||||||
|
_ = s.store.MarkAlertDelivered(ctx, record.ID, err.Error())
|
||||||
|
s.log.Error("notify delivery", "from", from, "err", err)
|
||||||
|
writeHubJSON(w, http.StatusBadGateway, notifyResponse{Error: "delivery failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.MarkAlertDelivered(ctx, record.ID, ""); err != nil {
|
||||||
|
s.log.Error("notify record", "id", record.ID, "err", err)
|
||||||
|
}
|
||||||
|
s.log.Info("notify delivered", "from", from, "level", level, "person", req.Person, "alert_id", record.ID)
|
||||||
|
writeHubJSON(w, http.StatusOK, notifyResponse{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyType maps the contract's level to an Apprise notification class.
|
||||||
|
func notifyType(level string) string {
|
||||||
|
switch level {
|
||||||
|
case "important":
|
||||||
|
return apprise.TypeWarning
|
||||||
|
case "urgent":
|
||||||
|
return apprise.TypeFailure
|
||||||
|
default:
|
||||||
|
return apprise.TypeInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHubJSON writes one JSON value with the given status.
|
||||||
|
func writeHubJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
250
internal/web/hub_api_test.go
Normal file
250
internal/web/hub_api_test.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testHubToken = "test-hub-token"
|
||||||
|
|
||||||
|
// newHubTestApp builds a server with a real store and explicit Hub/Apprise
|
||||||
|
// configuration.
|
||||||
|
func newHubTestApp(t *testing.T, hubToken, appriseBase string) (*Server, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.Open(filepath.Join(t.TempDir(), "control.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
if err := st.Migrate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auth.New: %v", err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
AdminUsername: "sam",
|
||||||
|
Version: "test",
|
||||||
|
AppEnv: "test",
|
||||||
|
DBPath: ":memory:",
|
||||||
|
ScheduleLocation: time.UTC,
|
||||||
|
HubToken: hubToken,
|
||||||
|
AppriseURL: appriseBase,
|
||||||
|
AppriseURLs: []string{"mailto:sam@example.com"},
|
||||||
|
}
|
||||||
|
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web.New: %v", err)
|
||||||
|
}
|
||||||
|
return srv, st
|
||||||
|
}
|
||||||
|
|
||||||
|
func hubRequest(t *testing.T, h http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var reader io.Reader
|
||||||
|
if body != "" {
|
||||||
|
reader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, reader)
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapabilitiesRequiresToken(t *testing.T) {
|
||||||
|
srv, _ := newHubTestApp(t, testHubToken, "")
|
||||||
|
|
||||||
|
rec := hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", "")
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unauthenticated status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", testHubToken)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Project string `json:"project"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
People []string `json:"people"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if body.Project != "internet_use_control" {
|
||||||
|
t.Errorf("project = %q", body.Project)
|
||||||
|
}
|
||||||
|
if len(body.Topics) == 0 {
|
||||||
|
t.Errorf("topics empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskRequiresTokenAndAnswers(t *testing.T) {
|
||||||
|
srv, st := newHubTestApp(t, testHubToken, "")
|
||||||
|
ctx := context.Background()
|
||||||
|
dev, err := st.CreateDevice(ctx, "Harry's phone", "Harry", "android", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDevice: %v", err)
|
||||||
|
}
|
||||||
|
today := time.Now().In(time.UTC).Format("2006-01-02")
|
||||||
|
if _, err := st.InsertTelemetry(ctx, dev.ID, today, []store.TelemetryEntry{
|
||||||
|
{App: "com.spotify.music", Seconds: 3600},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertTelemetry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No token: refused.
|
||||||
|
rec := hubRequest(t, srv.Handler(), http.MethodPost, "/ask",
|
||||||
|
`{"question":"how long have the kids been on screens today?","asker":"sam"}`, "")
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unauthenticated status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := `{"question":"how long have the kids been on screens today?","asker":"sam"}`
|
||||||
|
rec = hubRequest(t, srv.Handler(), http.MethodPost, "/ask", body, testHubToken)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
Answer *string `json:"answer"`
|
||||||
|
Data struct {
|
||||||
|
TotalSeconds int `json:"total_seconds"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Answer == nil || !strings.Contains(*resp.Answer, "1h 00m") {
|
||||||
|
t.Fatalf("answer = %v, want 1h 00m", resp.Answer)
|
||||||
|
}
|
||||||
|
if resp.Data.TotalSeconds != 3600 {
|
||||||
|
t.Errorf("total_seconds = %d, want 3600", resp.Data.TotalSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskWritesNothing(t *testing.T) {
|
||||||
|
srv, st := newHubTestApp(t, testHubToken, "")
|
||||||
|
ctx := context.Background()
|
||||||
|
dev, err := st.CreateDevice(ctx, "Harry's phone", "Harry", "android", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDevice: %v", err)
|
||||||
|
}
|
||||||
|
today := time.Now().In(time.UTC).Format("2006-01-02")
|
||||||
|
if _, err := st.InsertTelemetry(ctx, dev.ID, today, []store.TelemetryEntry{{App: "youtube", Seconds: 600}}); err != nil {
|
||||||
|
t.Fatalf("InsertTelemetry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
countRows := func() int {
|
||||||
|
var n int
|
||||||
|
if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM telemetry`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count telemetry: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
var auditBefore int
|
||||||
|
if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&auditBefore); err != nil {
|
||||||
|
t.Fatalf("count audit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
questions := []string{
|
||||||
|
`{"question":"how long have the kids been on screens today?","asker":"sam"}`,
|
||||||
|
`{"question":"what is blocked?","asker":"sam"}`,
|
||||||
|
`{"question":"are the devices online?","asker":"sam"}`,
|
||||||
|
`{"question":"what is the weather?","asker":"sam"}`,
|
||||||
|
}
|
||||||
|
for _, q := range questions {
|
||||||
|
rec := hubRequest(t, srv.Handler(), http.MethodPost, "/ask", q, testHubToken)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ask status = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := countRows(); got != 1 {
|
||||||
|
t.Errorf("telemetry rows = %d, want 1 (ask must not write)", got)
|
||||||
|
}
|
||||||
|
var auditAfter int
|
||||||
|
if err := st.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit`).Scan(&auditAfter); err != nil {
|
||||||
|
t.Fatalf("count audit: %v", err)
|
||||||
|
}
|
||||||
|
if auditAfter != auditBefore {
|
||||||
|
t.Errorf("audit rows changed from %d to %d; ask must not write", auditBefore, auditAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyDeliversThroughApprise(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
var gotBody map[string]any
|
||||||
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &gotBody)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer stub.Close()
|
||||||
|
|
||||||
|
srv, st := newHubTestApp(t, testHubToken, stub.URL)
|
||||||
|
rec := hubRequest(t, srv.Handler(), http.MethodPost, "/notify",
|
||||||
|
`{"from":"internet_use_control","level":"important","message":"3 emails need a decision.","person":"sam"}`,
|
||||||
|
testHubToken)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if gotPath != "/notify" {
|
||||||
|
t.Errorf("apprise path = %q, want /notify", gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["body"] != "3 emails need a decision." {
|
||||||
|
t.Errorf("apprise body = %#v", gotBody)
|
||||||
|
}
|
||||||
|
if gotBody["type"] != "warning" {
|
||||||
|
t.Errorf("apprise type = %v, want warning", gotBody["type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err := st.ListAlerts(context.Background(), 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAlerts: %v", err)
|
||||||
|
}
|
||||||
|
if len(alerts) != 1 || alerts[0].Kind != store.AlertNotify || !alerts[0].Delivered {
|
||||||
|
t.Fatalf("alerts = %#v, want one delivered notify", alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyValidatesInput(t *testing.T) {
|
||||||
|
srv, _ := newHubTestApp(t, testHubToken, "")
|
||||||
|
cases := []string{
|
||||||
|
`{"from":"x","level":"loud","message":"hi"}`,
|
||||||
|
`{"from":"x","level":"info","message":""}`,
|
||||||
|
`not json`,
|
||||||
|
}
|
||||||
|
for _, body := range cases {
|
||||||
|
rec := hubRequest(t, srv.Handler(), http.MethodPost, "/notify", body, testHubToken)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("body %q: status = %d, want 400", body, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHubAPINotConfigured(t *testing.T) {
|
||||||
|
srv, _ := newHubTestApp(t, "", "")
|
||||||
|
rec := hubRequest(t, srv.Handler(), http.MethodGet, "/capabilities", "", "")
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503 when no Hub token is set", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/apprise"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
|
||||||
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/hub"
|
||||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +42,11 @@ type Server struct {
|
|||||||
// code cannot be tried without limit.
|
// code cannot be tried without limit.
|
||||||
keycodeMu sync.Mutex
|
keycodeMu sync.Mutex
|
||||||
keycode map[string]keycodeAttempts
|
keycode map[string]keycodeAttempts
|
||||||
|
|
||||||
|
// The Hub contract surface (Phase 9): the read-only answerer and the
|
||||||
|
// Apprise client that delivers alerts and accepted notifications.
|
||||||
|
answerer *hub.Answerer
|
||||||
|
apprise *apprise.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publisher pushes device config to the broker and reports the link's health.
|
// Publisher pushes device config to the broker and reports the link's health.
|
||||||
@@ -68,16 +75,22 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
|
|||||||
return nil, fmt.Errorf("parse templates: %w", err)
|
return nil, fmt.Errorf("parse templates: %w", err)
|
||||||
}
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: st,
|
store: st,
|
||||||
auth: am,
|
auth: am,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
log: log,
|
log: log,
|
||||||
pub: pub,
|
pub: pub,
|
||||||
keycode: make(map[string]keycodeAttempts),
|
keycode: make(map[string]keycodeAttempts),
|
||||||
|
answerer: hub.NewAnswerer(st, cfg.ScheduleLocation),
|
||||||
|
apprise: apprise.New(cfg.AppriseURL, cfg.AppriseKey, cfg.AppriseURLs, cfg.AppriseToken, 0),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apprise exposes the delivery client so the alert engine (cmd/control) can
|
||||||
|
// share the one configured destination with the /notify handler.
|
||||||
|
func (s *Server) Apprise() *apprise.Client { return s.apprise }
|
||||||
|
|
||||||
// publishConfig pushes one device's desired state to the broker, retained. A
|
// publishConfig pushes one device's desired state to the broker, retained. A
|
||||||
// publish failure never fails the HTTP request: SQLite already holds the new
|
// publish failure never fails the HTTP request: SQLite already holds the new
|
||||||
// state, and the link republishes everything when it reconnects, so the broker
|
// state, and the link republishes everything when it reconnects, so the broker
|
||||||
@@ -131,6 +144,13 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// not hold that secret. It is throttled and audited instead (admin_api.go).
|
// not hold that secret. It is throttled and audited instead (admin_api.go).
|
||||||
mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride)
|
mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride)
|
||||||
|
|
||||||
|
// The Hub contract (Phase 9, FR-12/FR-13). Bearer-token auth, separate
|
||||||
|
// from the dashboard session and the admin API token. /ask is read-only;
|
||||||
|
// /notify records and delivers a message through Apprise.
|
||||||
|
mux.HandleFunc("GET /capabilities", s.requireHub(s.handleCapabilities))
|
||||||
|
mux.HandleFunc("POST /ask", s.requireHub(s.handleAsk))
|
||||||
|
mux.HandleFunc("POST /notify", s.requireHub(s.handleNotify))
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("web: embedded static dir missing: " + err.Error())
|
panic("web: embedded static dir missing: " + err.Error())
|
||||||
|
|||||||
Reference in New Issue
Block a user