diff --git a/contract-registry.yaml b/contract-registry.yaml index fb50e4a..49731af 100644 --- a/contract-registry.yaml +++ b/contract-registry.yaml @@ -628,7 +628,7 @@ contracts: sensitivity: high status: active owner: abiba - version: 3.1.0 + version: 3.2.0 trigger: type: scheduled cadence: '*/15 * * * *' diff --git a/scripts/capture-dsh-token.sh b/scripts/capture-dsh-token.sh new file mode 100755 index 0000000..4d7fe59 --- /dev/null +++ b/scripts/capture-dsh-token.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# capture-dsh-token.sh — refresh the dsh-web login token WITHOUT restarting dsh-web. +# +# Context (CT 112 / tankodhs.sysloggh.net) +# ---------------------------------------- +# The dsh-web UI (systemd unit `dsh-web.service`, 127.0.0.1:3080) prints a random +# launch token to the journal on every start: +# +# dsh web: http://127.0.0.1:3080/?token= +# +# That token is the only way to bootstrap the authority-bound 30-day browser +# cookie. It rotates on every dsh-web start, so the Authentik-gated +# `location = /dsh-web-login` in /etc/nginx/sites-available/dsh must always +# reference the token of the RUNNING process. +# +# This script: +# 1. selects the launch token the RUNNING service actually accepts from the +# current systemd invocation — it NEVER stops or starts dsh-web, +# 2. records it in /etc/dsh-web/launch-token, +# 3. regenerates the nginx include /etc/dsh-web/nginx-login.conf (the +# `proxy_pass ...?token=` line consumed by /dsh-web-login), +# 4. reloads nginx ONLY when the on-disk include differs from the generated +# one or the applied-state stamp does not match the token (the stamp is +# written only after a successful reload), rolling the include back on +# failure so the next run retries, +# 5. removes the legacy unauthenticated :8081 endpoint if it ever reappears. +# +# Idempotent and safe to run at any time (systemd ExecStartPost or timer). +set -euo pipefail +umask 077 +PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +JOURNAL_UNIT="dsh-web.service" +TOKEN_FILE="/etc/dsh-web/launch-token" +INCLUDE_FILE="/etc/dsh-web/nginx-login.conf" +STAMP_FILE="/etc/dsh-web/nginx-login.conf.applied" +PENDING_FILE="/etc/dsh-web/nginx-reload.pending" +SITE_ENABLED="/etc/nginx/sites-enabled/dsh" +LEGACY_8081="/etc/nginx/sites-enabled/dsh.token" +STASH_DIR="/etc/nginx/sites-available" +LOCK_FILE="/run/capture-dsh-token.lock" +LOGIN_HOST="tankodhs.sysloggh.net" +LOGIN_UPSTREAM="http://127.0.0.1:3080" +TOKEN_WAIT=120 + +log() { printf 'capture-dsh-token: %s\n' "$*" >&2; } +die() { printf 'capture-dsh-token: ERROR: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "must run as root" + +# ── 0. Serialize runs so timer/ExecStartPost/manual runs cannot interleave ── +exec 9>"$LOCK_FILE" +flock -n 9 || { log "another capture-dsh-token run holds $LOCK_FILE; exiting"; exit 0; } +mkdir -p "$(dirname "$PENDING_FILE")" + +# ── 0b. Guarantee the generated include exists before any `nginx -t` ────── +# The :80 site includes /etc/dsh-web/nginx-login.conf by literal path, so a +# missing include makes every `nginx -t` fail and can wedge recovery. Seed it +# from the last known token (or a placeholder); step 4 replaces it. +if [ ! -f "$INCLUDE_FILE" ]; then + SEED="placeholder" + if [ -f "$TOKEN_FILE" ]; then + SEED="$(cat "$TOKEN_FILE" 2>/dev/null || true)" + [ -n "$SEED" ] || SEED="placeholder" + fi + printf '%s' "$SEED" | grep -qE '^[A-Za-z0-9._~+/=:@-]+$' || SEED="placeholder" + printf 'proxy_pass %s/?token=%s;\n' "$LOGIN_UPSTREAM" "$SEED" > "$INCLUDE_FILE" + chmod 600 "$INCLUDE_FILE" + log "created missing $INCLUDE_FILE" +fi + +# ── 1. Remove the legacy unauthenticated :8081 endpoint, if present ───────── +# It bypassed Authentik entirely (listened on 0.0.0.0:8081 with no auth_request) +# and must never come back. Stash it rather than delete so it is auditable. +if [ -e "$LEGACY_8081" ] || [ -L "$LEGACY_8081" ]; then + TS="$(date -u +%Y%m%dT%H%M%SZ)" + STASHED="$STASH_DIR/dsh.token.disabled-$TS" + mv "$LEGACY_8081" "$STASHED" + chmod 600 "$STASHED" 2>/dev/null || true + touch "$PENDING_FILE" + if ! NGINX_TEST_OUT="$(nginx -t 2>&1)"; then + die "nginx config test failed after disabling $LEGACY_8081 (kept disabled at $STASHED): $NGINX_TEST_OUT; a pending reload is recorded so running nginx is reloaded once the config is fixed. The legacy :8081 endpoint will NOT be restored." + fi + if ! nginx -s reload; then + die "nginx reload failed after disabling $LEGACY_8081 (kept disabled at $STASHED); a pending reload is recorded so running nginx is reloaded on the next run. The legacy :8081 endpoint will NOT be restored." + fi + rm -f "$PENDING_FILE" + log "removed legacy :8081 endpoint -> $STASHED" +fi + +# ── 1b. Honor a recorded pending reload regardless of token selection ─────── +# A failed reload leaves PENDING_FILE set so a stashed legacy :8081 file can +# never remain loaded in the running nginx while dsh-web is down or not yet +# answering. Reconcile it before the token wait. +if [ -e "$PENDING_FILE" ]; then + if ! NGINX_TEST_OUT="$(nginx -t 2>&1)"; then + log "WARNING: pending nginx reload recorded but 'nginx -t' fails: $NGINX_TEST_OUT; continuing so the include can be regenerated; will retry next run" + elif ! nginx -s reload; then + log "WARNING: pending nginx reload recorded but 'nginx -s reload' failed; will retry next run" + else + rm -f "$PENDING_FILE" + log "completed pending nginx reload" + fi +fi + +# ── 2. Select the token the RUNNING service actually accepts ──────────────── +# Re-sample the service's CURRENT systemd invocation on every pass and read +# candidates only from it, so a restart that lands during the wait immediately +# switches to the new invocation; there is no whole-journal or cross-invocation +# fallback, and an empty/unknown invocation just waits. Each candidate is then +# functionally verified against the local dsh-web using the public authority, +# exactly as the /dsh-web-login proxy does, and the first that answers 303 is +# the live token. Candidates are re-probed newest-first on each pass (connection +# failures stay eligible) until one is accepted or the wait elapses. +journal_tokens() { + journalctl -u "$JOURNAL_UNIT" "_SYSTEMD_INVOCATION_ID=$1" --no-pager -o cat 2>/dev/null \ + | grep -oE 'dsh web: https?://[^[:space:]]+[?&]token=[^[:space:]]+' \ + | sed -E 's/.*[?&]token=//' \ + | grep -E '^[A-Za-z0-9._~+/=:@-]+$' \ + | tac | awk '!seen[$0]++' || true +} + +TOKEN="" +DEADLINE=$((SECONDS + TOKEN_WAIT)) +NO_INVOCATION_WARNED=0 +while [ -z "$TOKEN" ] && [ "$SECONDS" -lt "$DEADLINE" ]; do + INVOCATION="$(systemctl show -p InvocationID --value "$JOURNAL_UNIT" 2>/dev/null || true)" + if [ -z "$INVOCATION" ] || [ "$INVOCATION" = "n/a" ]; then + if [ "$NO_INVOCATION_WARNED" -eq 0 ]; then + log "WARNING: no invocation id for $JOURNAL_UNIT; waiting for a live invocation" + NO_INVOCATION_WARNED=1 + fi + sleep 2 + continue + fi + for cand in $(journal_tokens "$INVOCATION"); do + code="$(curl -s -o /dev/null --max-time 5 -w '%{http_code}' \ + -H "Host: $LOGIN_HOST" "$LOGIN_UPSTREAM/?token=$cand" || true)" + if [ "$code" = "303" ]; then + TOKEN="$cand" + break + fi + done + [ -n "$TOKEN" ] && break + sleep 2 +done + +if [ -z "$TOKEN" ]; then + log "no accepted launch token in the current invocation within ${TOKEN_WAIT}s; leaving the include untouched for the next run" + [ -e "$PENDING_FILE" ] && die "pending nginx reload could not be completed; will retry next run" + exit 0 +fi + +# ── 3. Record the token (atomic, private) ────────────────────────────────── +mkdir -p "$(dirname "$TOKEN_FILE")" +if ! printf '%s\n' "$TOKEN" | cmp -s - "$TOKEN_FILE" 2>/dev/null; then + printf '%s\n' "$TOKEN" > "$TOKEN_FILE.tmp" + chmod 600 "$TOKEN_FILE.tmp" + mv "$TOKEN_FILE.tmp" "$TOKEN_FILE" + log "recorded live launch token in $TOKEN_FILE" +fi +chmod 600 "$TOKEN_FILE" + +# ── 4. Regenerate the nginx login include (reload only when it changes) ──── +NEW_INCLUDE="$(mktemp "$INCLUDE_FILE.XXXXXX")" +printf 'proxy_pass %s/?token=%s;\n' "$LOGIN_UPSTREAM" "$TOKEN" > "$NEW_INCLUDE" +chmod 600 "$NEW_INCLUDE" + +# The stamp records the token nginx actually loaded. It is written only after a +# successful reload, so the early exit is safe only when both the stamp and the +# on-disk include agree with the live token; anything else falls through to the +# reload path so the include can never silently diverge from what nginx serves. +APPLIED="" +[ -f "$STAMP_FILE" ] && APPLIED="$(cat "$STAMP_FILE" 2>/dev/null || true)" +[ -f "$INCLUDE_FILE" ] && chmod 600 "$INCLUDE_FILE" +[ -f "$STAMP_FILE" ] && chmod 600 "$STAMP_FILE" + +if [ "$APPLIED" = "$TOKEN" ] && [ -f "$INCLUDE_FILE" ] && cmp -s "$NEW_INCLUDE" "$INCLUDE_FILE" \ + && [ ! -e "$PENDING_FILE" ]; then + rm -f "$NEW_INCLUDE" + log "token unchanged; nginx not reloaded" + exit 0 +fi + +[ -e "$SITE_ENABLED" ] || { rm -f "$NEW_INCLUDE"; die "$SITE_ENABLED missing; refusing to reload"; } + +RESTORE="" +if [ -f "$INCLUDE_FILE" ]; then + RESTORE="$(mktemp "$INCLUDE_FILE.bak.XXXXXX")" + cp -p "$INCLUDE_FILE" "$RESTORE" + chmod 600 "$RESTORE" +fi + +mv "$NEW_INCLUDE" "$INCLUDE_FILE" +chmod 600 "$INCLUDE_FILE" + +if ! NGINX_TEST_OUT="$(nginx -t 2>&1)"; then + if [ -n "$RESTORE" ]; then + mv "$RESTORE" "$INCLUDE_FILE" + else + rm -f "$INCLUDE_FILE" + fi + die "nginx config test failed: $NGINX_TEST_OUT; previous include restored" +fi + +if ! nginx -s reload; then + if [ -n "$RESTORE" ]; then + mv "$RESTORE" "$INCLUDE_FILE" + else + rm -f "$INCLUDE_FILE" + fi + touch "$PENDING_FILE" + die "nginx reload failed; previous include restored; a pending reload is recorded so the next run retries" +fi + +if [ -n "$RESTORE" ]; then + rm -f "$RESTORE" +fi + +rm -f "$PENDING_FILE" + +printf '%s\n' "$TOKEN" > "$STAMP_FILE.tmp" +chmod 600 "$STAMP_FILE.tmp" +mv "$STAMP_FILE.tmp" "$STAMP_FILE" + +log "token changed; nginx reloaded" +log "login endpoint: https://$LOGIN_HOST/dsh-web-login (Authentik-gated)" diff --git a/zulip-health.prose.md b/zulip-health.prose.md index 6ebafd4..4e9cfb6 100644 --- a/zulip-health.prose.md +++ b/zulip-health.prose.md @@ -3,7 +3,7 @@ kind: responsibility name: zulip-health description: Multi-platform health monitor for the Zulip messaging mesh spanning Platform A (pi/Abiba Zulip bridge), Platform B (Tanko on DSH), and Platform C (Agent Zero Docker). Verifies bot registration, DM delivery, and cross-platform connectivity. Mumuni is no longer monitored from this host — she runs on her own container (kagentz CT 105 on minipve, .14) and is monitored on her side. title: Zulip Mesh Health Monitor — Multi-Platform -version: 3.1.0 +version: 3.2.0 runtime_contract: 2 agent: abiba report_only_agents: @@ -261,6 +261,178 @@ logged/reported as a warning — reported, never healed on. | HTTP `:3080` connection refused/timeout (`000`) | Same as above | | HTTP status outside the expected set | Log/report as a warning — reported, never healed on | +**B4: dsh-web Authentication (Tanko — restart-persistent login)** + +The dsh-web UI is token-gated. On every start the process prints a random +launch token to the journal: + +``` +dsh web: http://127.0.0.1:3080/?token= +``` + +The token only bootstraps an authority-bound, HMAC-signed browser cookie with a +30-day lifetime. The signing secret is durable in +`/root/.dsh/.credentials.yaml` (key `client-connection/browser-session`), so a +cookie minted once keeps working across `dsh-web` restarts; the launch token +itself rotates on every restart. + +**Login endpoint (public, Authentik-gated):** +`https://tankodhs.sysloggh.net/dsh-web-login` + +It lives inside the Authentik-gated `:80` server block +(`/etc/nginx/sites-available/dsh`, symlinked from +`/etc/nginx/sites-enabled/dsh`) as `location = /dsh-web-login`, guarded by +`auth_request /outpost.goauthentik.io/auth/nginx`. It proxies to dsh-web with +`Host: tankodhs.sysloggh.net`, so the minted cookie is bound to the public +authority — never to `127.0.0.1:3080`. The token-dependent line is isolated in +the generated include `/etc/dsh-web/nginx-login.conf`: + +``` +proxy_pass http://127.0.0.1:3080/?token=; +``` + +**Token refresh (non-disruptive):** +`/opt/deepseek-harness/capture-dsh-token.sh` (source: +`scripts/capture-dsh-token.sh`) reads candidate launch tokens from the journal +**scoped to the service's current systemd invocation** +(`systemctl show -p InvocationID` + `_SYSTEMD_INVOCATION_ID=`), re-sampling the +invocation on every pass so a restart that lands during the wait switches to the +new invocation; a restarted process's stale token is never considered while its +new startup banner is still pending and there is no whole-journal or +cross-invocation fallback. Each candidate +is then functionally verified against dsh-web with `Host: tankodhs.sysloggh.net`, +using the first the running process accepts with `303`. It waits up to 120s for +a restarted process to accept a token and re-probes every current-invocation +candidate on each pass, so a token that briefly returns `000` while the service +is still starting is not disqualified. If none is accepted it leaves the include +untouched and exits so the timer retries (exiting non-zero when a pending reload +is still outstanding). It writes +`/etc/dsh-web/launch-token` and regenerates `/etc/dsh-web/nginx-login.conf`, +reloading nginx only when the on-disk include differs from the generated one or +the applied-state stamp does not match the token (`nginx -t` guards the reload, +and the stamp is written only after a successful `nginx -s reload`, so a failed +or interrupted reload is retried on the next run). Any failed reload records a +pending-reload marker under `/etc/dsh-web/`; the next run attempts the reload +before the token wait, independent of token state, and clears the marker only +once the reload succeeds, so a disabled legacy `:8081` file can never leave the +running nginx unreloaded. The generated include is recreated before any +`nginx -t` if it is missing, so a failed run cannot wedge recovery. +Runs are serialized with `flock` on `/run/capture-dsh-token.lock`. It **never +stops or starts `dsh-web`**. +It is triggered by the `dsh-web.service` drop-in +`/etc/systemd/system/dsh-web.service.d/20-token-refresh.conf` +(`ExecStartPost=/bin/systemctl --no-block start dsh-web-token.service`) and by +`dsh-web-token.timer` every 2 minutes for reconciliation. + +
Installed systemd wiring (CT 112) + +```ini +# /etc/systemd/system/dsh-web-token.service +[Unit] +Description=Refresh the dsh-web launch token for the nginx login endpoint +After=dsh-web.service +[Service] +Type=oneshot +TimeoutStartSec=180 +ExecStart=/opt/deepseek-harness/capture-dsh-token.sh + +# /etc/systemd/system/dsh-web-token.timer +[Unit] +Description=Periodically refresh the dsh-web login token +[Timer] +OnBootSec=90s +OnUnitActiveSec=120s +AccuracySec=10s +Persistent=true +[Install] +WantedBy=timers.target + +# /etc/systemd/system/dsh-web.service.d/20-token-refresh.conf +[Service] +ExecStartPost=/bin/systemctl --no-block start dsh-web-token.service +``` + +
+ +> **Do NOT reintroduce the `:8081` endpoint.** It listened on `0.0.0.0:8081` +> with no `auth_request` and was a full Authentik bypass for anyone on the LAN. +> The script now removes `/etc/nginx/sites-enabled/dsh.token` automatically if +> it ever reappears. + +**Authentication flow:** +1. `GET https://tankodhs.sysloggh.net/dsh-web-login` +2. Unauthenticated → Authentik sign-in; once authenticated the request reaches + dsh-web with `Host: tankodhs.sysloggh.net`. +3. dsh-web accepts the launch token on `GET /`, writes the + `dsh-auth-` cookie (30 days, `HttpOnly`, `SameSite=Strict`) + and returns `303` to `/`. +4. Every later request through `/` presents that cookie; the token is not needed + again until the cookie expires or a new browser is used. + +**Verification** (amdpve vantage): +```bash +# 1. Login endpoint is Authentik-gated: unauthenticated -> 302 (not 200/303). +ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}\n' \ + -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1/dsh-web-login" +# Expected: 302 + +# 2. Legacy :8081 endpoint is gone (connection refused -> 000). +ssh root@192.168.68.15 "pct exec 112 -- curl -s --max-time 3 -o /dev/null \ + -w '%{http_code}\n' http://192.168.68.122:8081/" +# Expected: 000 + +# 3. Backend cookie mint + reuse (exactly what /dsh-web-login proxies to). +TOKEN=$(ssh root@192.168.68.15 "pct exec 112 -- cat /etc/dsh-web/launch-token") +ssh root@192.168.68.15 "pct exec 112 -- curl -s -c /tmp/dsh.jar -o /dev/null \ + -H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'" +ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null \ + -w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/" +# Expected: 200 — the minted dsh-auth-... cookie (authority +# tankodhs.sysloggh.net) is replayed on the next request and accepted. + +# 4. Token refresh is non-disruptive and idempotent. +ssh root@192.168.68.15 "pct exec 112 -- /opt/deepseek-harness/capture-dsh-token.sh" +# Expected: "token unchanged; nginx not reloaded" when nothing changed +``` + +**Restart durability (acceptance):** after `systemctl restart dsh-web`, (a) the +cookie minted before the restart still returns `200` on `/`, and (b) the +refreshed `/etc/dsh-web/nginx-login.conf` carries the new token and mints a +fresh cookie. Both verified live 2026-09-11. + +```bash +# 5. Cookie survives a dsh-web restart, and the new token mints a new cookie. +ssh root@192.168.68.15 "pct exec 112 -- systemctl restart dsh-web" +# dsh-web is Type=simple: restart returns before :3080 is listening. Bounded-poll +# until the socket answers (any status but 000) before asserting the cookie. +for i in $(seq 1 60); do + UP=$(ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' \ + -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/") + [ "$UP" != "000" ] && break + sleep 2 +done +ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null \ + -w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/" +# Expected: 200 — the pre-restart cookie is still accepted. +# The restart's ExecStartPost (or the 2-minute timer) refreshes the include. A +# manual run may no-op on the flock, so poll until the include carries a token +# the running process accepts (bounded wait) before the mint+reuse check. +for i in $(seq 1 60); do + TOKEN=$(ssh root@192.168.68.15 "pct exec 112 -- sed -n 's/.*token=//p' /etc/dsh-web/nginx-login.conf | tr -d ';\n'") + CODE=$(ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' \ + -H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'") + [ "$CODE" = "303" ] && break + sleep 2 +done +# Expected: 303 — the include now holds the token the running process accepts. +ssh root@192.168.68.15 "pct exec 112 -- curl -s -c /tmp/dsh-new.jar -o /dev/null \ + -H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'" +ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh-new.jar -o /dev/null \ + -w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/" +# Expected: 200 — the refreshed token minted a fresh cookie. +``` + + ### Step 4: Platform C — Agent Zero (kagentz, CT 105 via Docker host .14) **C1: A2A Server Health**