diff --git a/Makefile b/Makefile index c74155d..0f5da71 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)" -e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \ -v "$$PWD":/src -w /src $(GO_IMAGE) -.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 verify-phase8 parse scan calendar notify morning-brief +.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 verify-phase8 parse scan calendar notify morning-brief n8n-key n8n-import help: @echo "make test run go test ./... in golang:1.25-alpine" @@ -38,6 +38,8 @@ help: @echo "make notify build and send the morning briefs via Apprise (Phase 7; NOTIFY_ARGS=...)" @echo "make preview print the briefs without sending (Phase 7)" @echo "make morning-brief scan both children, then send the briefs (the n8n job)" + @echo "make n8n-key print the restricted SSH trigger key plan (Phase 7a)" + @echo "make n8n-import print the n8n import/publish plan (Phase 7a; APPLY=1 applies)" @echo "make verify-phase1 prove /health, the login and the Caddy route" @echo "make verify-phase8 prove the Hub API: /capabilities, /ask, /notify (--send delivers)" @@ -102,6 +104,14 @@ preview: morning-brief: IMAGE=$(IMAGE) scripts/morning-brief.sh +# Phase 7a: the 06:00 n8n schedule. Both default to a dry run; pass APPLY=1 to +# change the shared n8n service on .13 (after approval). +n8n-key: + deploy/setup-n8n-scan-key.sh $(if $(APPLY),--apply,) + +n8n-import: + deploy/import-n8n-morning-brief.sh $(if $(APPLY),--apply,) + # Phase 1 definition of done: /health 200, login page + session login, and the # Caddy route on .35. verify-phase1: diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index 361026a..a72f9d5 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -96,8 +96,16 @@ the API directly on the internal address. docker exec compass-teams-nhs /app/school notify status ``` -5. Import `deploy/n8n-morning-brief.json` in n8n, set `SCHOOL_NOTIFY_TOKEN` in - the n8n environment, and activate the workflow (06:00 daily, Australia/Melbourne). +5. Schedule the 06:00 daily run in n8n (Phase 7a, AUD-115): + + ```bash + deploy/setup-n8n-scan-key.sh --apply # restricted SSH key for the trigger + deploy/import-n8n-morning-brief.sh --apply # import + publish + restart n8n + ``` + + The workflow has one Execute Command node: `ssh … 'scripts/morning-brief.sh'`. + It needs no `SCHOOL_NOTIFY_TOKEN` in n8n — the host script reads the project env + file. See `docs/phase-7a-n8n-schedule.md` for the design and the failure drill. Notes: @@ -109,3 +117,7 @@ Notes: service image. `scripts/morning-brief.sh` runs the scan and the send in order. - A run that cannot deliver exits non-zero, and the endpoint answers `207`, so a broken morning is visible in n8n instead of silent (FR-12). +- The 06:00 schedule is the n8n workflow `Compass / NHS — 6:00am morning brief` + (`deploy/n8n-morning-brief.json`), triggered over a restricted SSH key. It runs + on the shared n8n service on `.13`, so its import, publish, and the one-off n8n + restart need approval first. diff --git a/deploy/import-n8n-morning-brief.sh b/deploy/import-n8n-morning-brief.sh new file mode 100755 index 0000000..81ef1f9 --- /dev/null +++ b/deploy/import-n8n-morning-brief.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Phase 7a (AUD-115): import + publish the 6:00am workflow in n8n. +# +# n8n on .13 is a shared family service. Run this only after the change is +# approved. It imports one workflow, publishes it, restarts n8n once so the +# 06:00 schedule registers, and verifies the published state. +# +# It does NOT run the workflow. The first real run is the next 06:00 trigger +# (or `docker exec n8n n8n execute --id=` by hand). +# +# Usage: +# deploy/import-n8n-morning-brief.sh # dry run, print the plan +# deploy/import-n8n-morning-brief.sh --apply +# deploy/import-n8n-morning-brief.sh --apply --no-restart +# +# Environment overrides: +# CONTAINER n8n container name (default n8n) +# WORKFLOW workflow json path (default /deploy/n8n-morning-brief.json) +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONTAINER="${CONTAINER:-n8n}" +WORKFLOW="${WORKFLOW:-$REPO_ROOT/deploy/n8n-morning-brief.json}" +IN_CONTAINER="/tmp/n8n-morning-brief.json" + +APPLY=0 +RESTART=1 +for arg in "$@"; do + case "$arg" in + --apply) APPLY=1 ;; + --no-restart) RESTART=0 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "unknown argument: $arg" >&2; exit 2 ;; + esac +done + +[[ -f "$WORKFLOW" ]] || { echo "workflow not found: $WORKFLOW" >&2; exit 1; } + +# The workflow name is stable; match on a substring to survive em-dash changes. +WF_MATCH="${WF_MATCH:-6:00am morning brief}" + +n8n_cli() { docker exec "$CONTAINER" n8n "$@"; } + +wf_id() { + n8n_cli list:workflow 2>/dev/null \ + | awk -F'|' -v m="$WF_MATCH" 'index($2, m) { print $1; exit }' +} + +echo "workflow file : $WORKFLOW" +echo "n8n container : $CONTAINER" +echo "in-container : $IN_CONTAINER" +echo "restart n8n : $([[ "$RESTART" -eq 1 ]] && echo yes || echo no)" + +if [[ "$APPLY" -eq 0 ]]; then + echo + echo "DRY RUN — no change to n8n. Re-run with --apply." + echo "steps:" + echo " 1. copy the JSON into the container" + echo " 2. n8n import:workflow" + echo " 3. n8n publish:workflow --id=" + echo " 4. $([[ "$RESTART" -eq 1 ]] && echo 'docker restart n8n (≈10s)' || echo 'skip restart')" + echo " 5. n8n list:workflow --active=true shows the workflow" + exit 0 +fi + +echo +echo "== 1. copy the workflow into the container ==" +docker exec -i "$CONTAINER" sh -c "cat > $IN_CONTAINER" < "$WORKFLOW" + +echo "== 2. import ==" +n8n_cli import:workflow --input="$IN_CONTAINER" + +ID="$(wf_id)" +if [[ -z "$ID" ]]; then + echo "could not find the imported workflow by name '$WF_MATCH'" >&2 + n8n_cli list:workflow >&2 || true + exit 1 +fi +echo "workflow id: $ID" + +echo "== 3. publish ==" +n8n_cli publish:workflow --id="$ID" + +echo "== 4. restart ==" +if [[ "$RESTART" -eq 1 ]]; then + docker restart "$CONTAINER" >/dev/null + echo "n8n restarted; waiting for the API to answer…" + for _ in $(seq 1 30); do + if curl -s -o /dev/null --max-time 3 "http://127.0.0.1:5678/healthz"; then + echo "n8n is up" + break + fi + sleep 2 + done +fi + +echo "== 5. verify ==" +n8n_cli list:workflow --active=true +echo +echo "AUTHORIZED_KEY_HINT: the Execute Command node calls the host with" +echo " ~/.ssh/id_ed25519_school_scan — create it with deploy/setup-n8n-scan-key.sh --apply" diff --git a/deploy/n8n-morning-brief.json b/deploy/n8n-morning-brief.json index f71fdb9..489ff26 100644 --- a/deploy/n8n-morning-brief.json +++ b/deploy/n8n-morning-brief.json @@ -15,53 +15,20 @@ "id": "c1a0a0a0-0000-4000-8000-000000000001", "name": "6:00am every day", "type": "n8n-nodes-base.scheduleTrigger", - "typeVersion": 1.2, + "typeVersion": 1.3, "position": [200, 300] }, { "parameters": { - "command": "ssh remote 'cd /home/sam/paseo/projects/compass_teams_nhs && scripts/scan-compass.sh'" + "executeOnce": true, + "command": "ssh -i /home/node/.ssh/id_ed25519_school_scan -o IdentitiesOnly=yes -o BatchMode=yes -o UserKnownHostsFile=/home/node/.ssh/known_hosts -o StrictHostKeyChecking=yes sam@192.168.20.13 'cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh'" }, "id": "c1a0a0a0-0000-4000-8000-000000000002", - "name": "Scan Compass (both children, read-only)", + "name": "Scan both children and send the briefs", "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [480, 300], - "notes": "The scan needs node + patchright on the host, so it runs outside the service container. A failed scan still lets the briefs go out: the brief carries the failure notice (FR-12)." - }, - { - "parameters": { - "method": "POST", - "url": "http://192.168.20.13:8792/notify/morning", - "sendHeaders": true, - "headerParameters": { - "parameters": [ - { - "name": "Authorization", - "value": "=Bearer {{$env.SCHOOL_NOTIFY_TOKEN}}" - }, - { - "name": "Content-Type", - "value": "application/json" - } - ] - }, - "sendBody": false, - "options": { - "timeout": 240000, - "response": { - "response": { - "neverError": true - } - } - } - }, - "id": "c1a0a0a0-0000-4000-8000-000000000003", - "name": "Send the morning briefs", - "type": "n8n-nodes-base.httpRequest", - "typeVersion": 4.2, - "position": [760, 300], - "notes": "POST /notify/morning builds one brief per boy plus the combined brief to Jo and Sam and delivers them through Apprise. 200 = every channel delivered; 207 = a channel failed or a scan failed; 503 = SCHOOL_NOTIFY_TOKEN is not set on the service." + "notes": "One bounded host command. The scan needs node + patchright on .13, so it runs outside this container. scripts/morning-brief.sh scans both children, builds the per-boy and combined briefs and sends them through Apprise, then exits non-zero if the scan or a send fails, so this execution goes red (FR-12). The key is restricted in authorized_keys to exactly this command." } ], "connections": { @@ -69,18 +36,7 @@ "main": [ [ { - "node": "Scan Compass (both children, read-only)", - "type": "main", - "index": 0 - } - ] - ] - }, - "Scan Compass (both children, read-only)": { - "main": [ - [ - { - "node": "Send the morning briefs", + "node": "Scan both children and send the briefs", "type": "main", "index": 0 } diff --git a/deploy/setup-n8n-scan-key.sh b/deploy/setup-n8n-scan-key.sh new file mode 100755 index 0000000..4b76fe1 --- /dev/null +++ b/deploy/setup-n8n-scan-key.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Phase 7a (AUD-115): the SSH trigger key for the 6:00am n8n workflow. +# +# The n8n container mounts ~/.ssh read-only at /home/node/.ssh (see the n8n +# compose file). This script creates a dedicated key there and prints the exact +# `authorized_keys` line that restricts it to one command. Nothing else can use +# the key: no shell, no port forwarding, no agent. +# +# This writes under $HOME/.ssh, which is OUTSIDE this repository. Run it only +# after the change is approved. Default run is a dry run and prints the plan. +# +# Usage: +# deploy/setup-n8n-scan-key.sh # dry run, print the plan +# deploy/setup-n8n-scan-key.sh --apply # create the key + authorize it +# +# Environment overrides: +# KEY private key path (default ~/.ssh/id_ed25519_school_scan) +# SCAN_CMD forced command (default the morning brief on the host) +set -euo pipefail + +KEY="${KEY:-$HOME/.ssh/id_ed25519_school_scan}" +SCAN_CMD="${SCAN_CMD:-cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh}" +AUTH="${AUTH:-$HOME/.ssh/authorized_keys}" +COMMENT="n8n-school-scan" + +APPLY=0 +case "${1:-}" in + --apply) APPLY=1 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + '') ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; +esac + +if [[ "$APPLY" -eq 0 ]]; then + echo "DRY RUN — no files are written. Re-run with --apply." +fi + +if [[ ! -f "$KEY" ]]; then + if [[ "$APPLY" -eq 1 ]]; then + echo "creating $KEY" + ssh-keygen -t ed25519 -N '' -C "$COMMENT" -f "$KEY" >/dev/null + else + echo "would create key : $KEY ($COMMENT)" + fi +fi + +PUB="$(cat "$KEY.pub" 2>/dev/null || echo '')" +LINE="restrict,command=\"$SCAN_CMD\" $PUB" + +echo "key : $KEY" +echo "forced command : $SCAN_CMD" +echo "authorized_keys : $AUTH" +echo +echo "line to add:" +echo " $LINE" + +if [[ "$APPLY" -eq 1 ]]; then + if grep -qF "$PUB" "$AUTH"; then + echo + echo "public key already present in $AUTH — nothing to add" + else + printf '%s\n' "$LINE" >> "$AUTH" + chmod 600 "$AUTH" + echo + echo "added the restricted key to $AUTH" + fi + echo + echo "the n8n container now sees it at: /home/node/.ssh/$(basename "$KEY")" +fi diff --git a/docs/phase-7a-n8n-schedule.md b/docs/phase-7a-n8n-schedule.md new file mode 100644 index 0000000..f0dabfa --- /dev/null +++ b/docs/phase-7a-n8n-schedule.md @@ -0,0 +1,115 @@ +# Phase 7a — the 6:00am n8n schedule (AUD-115) + +Phase 7 (AUD-111) proved the delivery path: `POST /notify/morning`, the per-boy and +combined briefs, the Apprise channels, and eight real deliveries. This phase makes +the schedule real: one n8n workflow that runs every day at 06:00 and sends the +briefs. + +## What changed + +`deploy/n8n-morning-brief.json` is now a two-node workflow: + +``` +Schedule Trigger "0 6 * * *" (Australia/Melbourne) + | + v +Execute Command ssh … sam@192.168.20.13 'scripts/morning-brief.sh' +``` + +The earlier draft had three nodes (`scan` via `ssh remote` → `POST /notify/morning`). +Two problems made that shape wrong here: + +1. **`ssh remote` does not work.** The n8n container mounts `~/.ssh` read-only, and + `~/.ssh/config` is a Nix symlink into `/nix/store`, which does not exist inside + the container. The hostname `remote` does not resolve, and the container key is + not in `~/.ssh/authorized_keys` (the host alias fails too). +2. **A split scan/send node hides scan failures.** If the scan node fails, n8n stops + before the HTTP node, so `POST /notify/morning` never runs and the family gets no + brief at all — the opposite of FR-12. + +`scripts/morning-brief.sh` is already the bounded command that does both steps and +exits non-zero when the scan or a send fails. One Execute Command node is therefore +both simpler and more correct: a failed scan turns the n8n execution red **and** still +sends a brief that opens with the failure (`SCAN FAILED`), never "nothing due". + +Because the single command runs on the host, n8n needs **no** `SCHOOL_NOTIFY_TOKEN` +and no HTTP node. The token stays in `environment.d/school-service.env` only. + +## The SSH trigger key + +The n8n container mounts `/home/sam/.ssh` read-only at `/home/node/.ssh`. The trigger +uses a dedicated key with a forced command, so it can run exactly one thing: + +``` +restrict,command="cd /home/sam/paseo/projects/compass_teams_nhs && ./scripts/morning-brief.sh" ssh-ed25519 AAAA… +``` + +- `restrict` disables pty, agent forwarding, port forwarding, X11, and shell access. +- `command=` makes the server ignore whatever command the client sends. +- The private key lives at `/home/sam/.ssh/id_ed25519_school_scan` (mode 0600) and is + readable only by `sam` and the n8n container (which already mounts `~/.ssh`). +- The key is not sam's personal key and is not used anywhere else. + +Create it with: + +```bash +deploy/setup-n8n-scan-key.sh # dry run: print the plan +deploy/setup-n8n-scan-key.sh --apply # create the key + the authorized_keys line +``` + +## Import and activate + +```bash +deploy/import-n8n-morning-brief.sh # dry run: print the plan +deploy/import-n8n-morning-brief.sh --apply # import, publish, restart n8n +``` + +The script copies the JSON into the container with `docker exec -i … sh -c 'cat > …'` +(`docker cp` fails against the read-only `~/.ssh` mount), runs +`n8n import:workflow`, then `n8n publish:workflow --id=` (n8n 2.x replaced +`update:workflow --active`), restarts n8n once so the 06:00 schedule registers, and +prints `n8n list:workflow --active=true`. + +## Verification + +```bash +docker exec n8n n8n list:workflow --active=true # the workflow is published +docker exec compass-teams-nhs /app/school notify status +``` + +A healthy morning looks like this in `notify status`: + +``` +1 Harry Harry email ok 06:0x +2 Harry Harry whatsapp ok 06:0x +3 Finn Finn email ok 06:0x +4 Finn Finn whatsapp ok 06:0x +5 combined Jo email ok 06:0x +6 combined Jo whatsapp ok 06:0x +7 combined Sam email ok 06:0x +8 combined Sam whatsapp ok 06:0x +``` + +Failure drill (never leave an empty day): point the key's forced command at a command +that exits non-zero, trigger the workflow, and confirm the n8n execution is red and +the brief body starts with the scan failure. Restore the command afterwards. + +## Proving a run + +The Schedule Trigger only fires at 06:00, so the schedule itself is proved by +`n8n list:workflow --active=true` plus the next morning's `notify status` rows. To +prove the full send path without waiting, run the published workflow once by hand: + +```bash +docker exec n8n n8n execute --id= +``` + +This sends the real briefs, so run it only when a real delivery is wanted. `n8n +list:workflow` prints the id. Both checks are recorded in this issue's final comment. + +## Not touched + +- Compass / Teams: read-only. Nothing in this phase writes to the school. +- Other n8n workflows: untouched. +- Secrets: no address, phone number, Apprise key, session secret, or notify token is + written into the workflow JSON, this document, or git.