AUD-56: deploy prep - token script, container user fix, runbook

- Add scripts/provision-hub-tokens.sh. It adds the five HUB_TOKEN_* keys to
  ~/.config/environment.d/10-secrets.conf. It is idempotent, keeps mode 600,
  leaves a mode-600 backup, and never prints a value. It uses openssl when
  present and /dev/urandom otherwise (this host has no openssl).
- docker-compose.yml: run the container as the host user. The image's
  distroless nonroot user cannot read the 0600 secrets file. With nonroot the
  Hub read no tokens and every /ask returned 401.
- Document the deploy steps, the finding, and the dry-run evidence in
  docs/outline/04-agent-runbook.md, docs/outline/02-config-and-env.md, README.md.

Dry run on port 18788 with a scratch secrets file: /health ok with
omniroute ok, control action refused, bad token 401. The host secrets file and
the running hub container were not touched.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-10 17:48:22 +11:00
parent bf80efa25d
commit 4d87124840
5 changed files with 198 additions and 0 deletions

View File

@@ -35,6 +35,9 @@ curl -s -X POST http://192.168.20.13:8788/ask \
The Hub reads `HUB_TOKEN_*`, `OMNIROUTE_API_KEY`, and the project tokens from The Hub reads `HUB_TOKEN_*`, `OMNIROUTE_API_KEY`, and the project tokens from
`~/.config/environment.d/10-secrets.conf` (mounted read-only, never committed). `~/.config/environment.d/10-secrets.conf` (mounted read-only, never committed).
Add the five Hub client tokens with the idempotent script
`scripts/provision-hub-tokens.sh`; it keeps mode 600 and never prints a value.
A new token needs Sam's OK (`AGENTS.md` "ask first: credentials").
### Test without a network ### Test without a network

View File

@@ -6,6 +6,10 @@ services:
image: central-ai-hub:local image: central-ai-hub:local
container_name: hub container_name: hub
restart: unless-stopped restart: unless-stopped
# The secrets file is bind-mounted read-only with mode 0600, owned by the
# host user. The image's distroless nonroot user (65532) cannot read it, so
# the container runs as the host user instead. No host file change needed.
user: "${HUB_UID:-1000}:${HUB_GID:-100}"
ports: ports:
- "8788:8788" - "8788:8788"
environment: environment:

View File

@@ -23,6 +23,14 @@ Relevant keys:
| `HUB_TOKEN_CONSOLE` | Hub token for the console portal. | | `HUB_TOKEN_CONSOLE` | Hub token for the console portal. |
| `HUB_TOKEN_ADMIN` | Hub token for the admin app. The only trusted door. | | `HUB_TOKEN_ADMIN` | Hub token for the admin app. The only trusted door. |
Add the five Hub tokens with `scripts/provision-hub-tokens.sh`. It is
idempotent, keeps mode 600, and never prints a value. A new token needs Sam's OK
(`AGENTS.md` "ask first: credentials").
The container runs as the host user (`HUB_UID`/`HUB_GID`, default `1000:100`),
not the image's `nonroot` user, because the secrets file has mode 0600 and the
`nonroot` user cannot read it. See `docs/outline/04-agent-runbook.md`.
## Hub Environment Keys ## Hub Environment Keys
The Hub reads these keys from the environment, then from the secrets file. The The Hub reads these keys from the environment, then from the secrets file. The

View File

@@ -161,6 +161,87 @@ The `docker-compose.yml` mounts `routing.md` and the secrets file read-only, and
uses a `/tmp` tmpfs for the log. To deploy: build the image, then restart the uses a `/tmp` tmpfs for the log. To deploy: build the image, then restart the
container. A `HUB_TOKEN_*` key per client must exist in the secrets file first. container. A `HUB_TOKEN_*` key per client must exist in the secrets file first.
## Phase 2 — Deploy the Hub (AUD-56)
The AUD-49 image is built as `central-ai-hub:local`. The `hub` container on port
8788 must serve that image, and the secrets file must hold one token per door.
### Step 1 — Provision the Hub client tokens
Run the script. It adds only the missing `HUB_TOKEN_*` keys. It never prints a
token value and never overwrites a token.
```bash
scripts/provision-hub-tokens.sh --check # report present or missing, no change
scripts/provision-hub-tokens.sh # add the missing keys
```
The script writes the keys to `~/.config/environment.d/10-secrets.conf`, keeps
mode 600, and leaves a mode-600 timestamped backup. The keys are:
`HUB_TOKEN_PHONE`, `HUB_TOKEN_ESP32`, `HUB_TOKEN_DSH`, `HUB_TOKEN_CONSOLE`,
`HUB_TOKEN_ADMIN`.
This step changes host credentials. It needs Sam's OK first
(`AGENTS.md` "ask first: credentials").
> This host has no `openssl`. The script uses `openssl rand -hex 32` when it is
> present, and `/dev/urandom` (32 bytes, hex) otherwise. Both give 64 hex
> characters.
### Step 2 — Deploy the image
```bash
cd /home/sam/paseo/projects/central_ai_llm_master
docker compose up -d --build
```
### Step 3 — Verify
```bash
curl -s http://192.168.20.13:8788/health
docker inspect hub --format '{{.State.Health.Status}}'
```
Expected: `ok: true`, `omniroute: ok`, and `healthy`. The control action must be
refused:
```bash
curl -s -X POST http://192.168.20.13:8788/ask \
-H "Authorization: Bearer $HUB_TOKEN_ESP32" \
-H "Content-Type: application/json" \
-d '{"text":"cut the kids internet","asker":"sam","source":"esp32"}'
```
Expected: `status: "refused"`, `route.kind: "refused"`.
`HUB_PROJECTS` is empty in this phase. A project question therefore returns
`status: "unavailable"` with `route.reason: "no_client"`. That is correct.
### Finding — the container user
The secrets file has mode 0600 and is owned by the host user. The image's
distroless `nonroot` user (65532) cannot read that file. With `nonroot`, the Hub
starts but reads no tokens: every `/ask` returns 401 and `/health` reports
`omniroute: unknown`.
Fix: `docker-compose.yml` runs the container as the host user
(`user: "${HUB_UID:-1000}:${HUB_GID:-100}"`). No host file change is needed.
### Dry-run evidence — 2026-10-10
The AUD-49 image (`sha256:eb062c638da5`) ran on port 18788 against a scratch
secrets file with freshly generated tokens. It did not touch the host secrets
file or the running `hub` container.
| Check | Result |
|---|---|
| `GET /health` | `ok: true`, `checks.omniroute: ok`, `checks.log: ok` |
| `POST /ask` `"cut the kids internet"` | `status: "refused"`, `route.kind: "refused"` |
| `POST /ask` with a bad token | HTTP 401 `unauthorized` |
| `POST /ask` a project question (no project) | `status: "unavailable"`, `route.reason: "no_client"` |
| `docker exec <container> /app/hub -healthcheck` | exit 0 (the compose healthcheck) |
| Container with the image's default `nonroot` user | 401 on `/ask`, `omniroute: unknown` (the finding above) |
## After Phase 1 ## After Phase 1
Follow the build order in `AGENTS.md`. Do not start a destination before the Follow the build order in `AGENTS.md`. Do not start a destination before the

102
scripts/provision-hub-tokens.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
#
# provision-hub-tokens.sh - add one Hub client token per door to the secrets
# file.
#
# The Hub accepts one bearer token per client, not per person
# (docs/hub-api.md section 3). This script adds the five HUB_TOKEN_* keys to
# ~/.config/environment.d/10-secrets.conf.
#
# It is idempotent. It adds only the keys that are missing. It never prints a
# token value and never overwrites an existing token.
#
# This changes host credentials. It needs Sam's OK before it runs
# (AGENTS.md "ask first: credentials").
#
# Usage:
# scripts/provision-hub-tokens.sh --check # report present/missing, change nothing
# scripts/provision-hub-tokens.sh --dry-run # same as --check, no change
# scripts/provision-hub-tokens.sh # add the missing keys
#
# Exit codes:
# 0 done (or all keys already present)
# 1 the secrets file is missing or is not usable
# 2 bad usage
#
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
readonly SECRETS_FILE="${HUB_SECRETS_FILE:-$HOME/.config/environment.d/10-secrets.conf}"
# The five doors from docs/hub-api.md section 3.
readonly TOKEN_KEYS=(HUB_TOKEN_PHONE HUB_TOKEN_ESP32 HUB_TOKEN_DSH HUB_TOKEN_CONSOLE HUB_TOKEN_ADMIN)
mode="apply"
case "${1:-}" in
--check|--dry-run) mode="check" ;;
"") ;;
*) echo "usage: $(basename "$0") [--check|--dry-run]" >&2; exit 2 ;;
esac
# gen_hex32 prints 64 hex characters (32 random bytes). It uses openssl when
# it is installed, and /dev/urandom otherwise. This host has no openssl.
gen_hex32() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
return
fi
od -An -N32 -tx1 /dev/urandom | tr -d ' \n'
}
if [[ ! -f "$SECRETS_FILE" ]]; then
echo "error: secrets file not found: $SECRETS_FILE" >&2
exit 1
fi
# has_key reports whether KEY is already set in the file.
has_key() {
local key="$1"
grep -qE "^[[:space:]]*${key}=" "$SECRETS_FILE"
}
missing=()
for key in "${TOKEN_KEYS[@]}"; do
if has_key "$key"; then
echo "present: $key"
else
echo "missing: $key"
missing+=("$key")
fi
done
if [[ ${#missing[@]} -eq 0 ]]; then
echo "done: all ${#TOKEN_KEYS[@]} Hub tokens are present. No change."
exit 0
fi
if [[ "$mode" == "check" ]]; then
echo "check only: ${#missing[@]} token(s) would be added. No change."
exit 0
fi
# Back up first. The backup keeps the same restrictive mode.
backup="${SECRETS_FILE}.bak.$(date -u +%Y%m%dT%H%M%SZ)"
cp -p "$SECRETS_FILE" "$backup"
chmod 600 "$backup"
echo "backup: $backup"
# Add the missing keys. openssl writes the value; nothing prints it.
{
echo ""
echo "# Hub client tokens (one per door, docs/hub-api.md section 3)."
echo "# Added by scripts/provision-hub-tokens.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)."
for key in "${missing[@]}"; do
value="$(gen_hex32)"
printf '%s="%s"\n' "$key" "$value"
done
} >> "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
echo "done: added ${#missing[@]} key(s) to $SECRETS_FILE (values not shown)."