From 266fa1f835900ff1fb9c91efa6596a8ece5c038b Mon Sep 17 00:00:00 2001 From: abiba-bot Date: Fri, 11 Sep 2026 16:52:45 +0000 Subject: [PATCH] fix(dsh-web-auth): Authentik-gated :80 login + non-disruptive token capture Correct the dsh-web authentication fix after parent correction: - Remove the unauthenticated :8081 endpoint (0.0.0.0 bind with no auth_request = full Authentik bypass for the LAN). The script now removes /etc/nginx/sites-enabled/dsh.token automatically if it reappears. - Put the login path inside the Authentik-gated :80 server block as location = /dsh-web-login; proxy to dsh-web with Host = tankodhs.sysloggh.net so the 30-day cookie is bound to the public authority, never to 127.0.0.1:3080. - Isolate the rotating token in a generated include /etc/dsh-web/nginx-login.conf; reload nginx only when it changes. - Replace the disruptive capture (systemctl stop/start dsh-web) with a non-disruptive read of the running service's journal, scoped to the current systemd invocation so a restarted process's stale token is never reused while the new banner is still pending. - Keep x-dsh-task-board-proxy-token and Host $ak_origin_host intact in '/'. - Document the corrected design (B4) in zulip-health.prose.md, v3.2.0. Live-verified 2026-09-11: no auth bypass (302), :8081 refused (000), a cookie minted before two dsh-web restarts still returns 200, the refreshed token mints a fresh cookie, and the systemd ExecStartPost/timer refreshes the token automatically without touching dsh-web. --- scripts/capture-dsh-token.sh | 207 ++++++++++++++++++++--------------- zulip-health.prose.md | 147 ++++++++++++++++++------- 2 files changed, 227 insertions(+), 127 deletions(-) diff --git a/scripts/capture-dsh-token.sh b/scripts/capture-dsh-token.sh index d0a87e9..7b49bc8 100755 --- a/scripts/capture-dsh-token.sh +++ b/scripts/capture-dsh-token.sh @@ -1,112 +1,141 @@ -#!/bin/bash -# capture-dsh-token.sh — start dsh-web, capture its token, update nginx -# Run on CT112 (tankodhs.sysloggh.net) +#!/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. Restarts the dsh-web service -# 2. Captures the token URL from the journal -# 3. Extracts the token value -# 4. Writes the token to /etc/dsh-web/launch-token -# 5. Creates an nginx config that exposes a /dsh-web-login endpoint -# 6. Reloads nginx - +# 1. reads the LATEST launch token from the running service's journal — 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. validates with `nginx -t` and reloads ONLY when the token changed, +# rolling the include back if validation fails, +# 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" -# Kill any existing dsh-web instance first -systemctl stop dsh-web 2>/dev/null || true -sleep 2 +JOURNAL_UNIT="dsh-web.service" +TOKEN_FILE="/etc/dsh-web/launch-token" +INCLUDE_FILE="/etc/dsh-web/nginx-login.conf" +SITE_ENABLED="/etc/nginx/sites-enabled/dsh" +LEGACY_8081="/etc/nginx/sites-enabled/dsh.token" +STASH_DIR="/etc/nginx/sites-available" -# Record the time we started the service (for --since filter) -START_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +log() { printf 'capture-dsh-token: %s\n' "$*" >&2; } +die() { printf 'capture-dsh-token: ERROR: %s\n' "$*" >&2; exit 1; } -# Start dsh-web -systemctl start dsh-web +[ "$(id -u)" -eq 0 ] || die "must run as root" -# Wait for the token to appear in the journal (up to 30 seconds) -TOKEN="" -for i in {1..30}; do - TOKEN=$(journalctl -u dsh-web.service --since "$START_TIME" --output=cat 2>/dev/null | grep -m1 "dsh web: http://" | grep -oP "(?<=dsh web: )(https?://[^ ]+)" | head -1 || true) - if [ -n "$TOKEN" ]; then - break +# ── 0. 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 + STAMP="$(date -u +%Y%m%dT%H%M%SZ)" + STASHED="$STASH_DIR/dsh.token.disabled-$STAMP" + mv "$LEGACY_8081" "$STASHED" + if nginx -t >/dev/null 2>&1; then + nginx -s reload + log "removed legacy :8081 endpoint -> $STASHED" + else + mv "$STASHED" "$LEGACY_8081" + die "nginx config test failed after removing $LEGACY_8081; restored it" fi +fi + +# ── 1. Read the latest launch token from the RUNNING service ──────────────── +# Scope the journal to the service's CURRENT invocation. While a restarted +# process is still booting (~25s before it prints the banner), the newest token +# in the journal still belongs to the PREVIOUS process; without this filter an +# ExecStartPost run would silently keep the stale token. Never stop/start dsh-web. +INVOCATION="$(systemctl show -p InvocationID --value "$JOURNAL_UNIT" 2>/dev/null || true)" +JOURNAL_ARGS=(-u "$JOURNAL_UNIT") +if [ -n "$INVOCATION" ] && [ "$INVOCATION" != "n/a" ]; then + JOURNAL_ARGS+=("_SYSTEMD_INVOCATION_ID=$INVOCATION") +else + log "WARNING: no invocation id for $JOURNAL_UNIT; using latest journal token" +fi + +extract_token() { + grep -oE 'dsh web: https?://[^[:space:]]+[?&]token=[^[:space:]]+' \ + | tail -n1 | sed -E 's/.*[?&]token=//' || true +} + +TOKEN="" +for _ in $(seq 1 60); do + TOKEN="$(journalctl "${JOURNAL_ARGS[@]}" --no-pager -o cat 2>/dev/null | extract_token)" + [ -n "$TOKEN" ] && break sleep 1 done +# Fallback: the current invocation's start banner may have been rotated out of +# the journal; the newest matching line overall is then the best available. if [ -z "$TOKEN" ]; then - echo "ERROR: token not captured within 30s" >&2 - exit 1 + log "WARNING: no token for the current invocation; falling back to newest journal token" + TOKEN="$(journalctl -u "$JOURNAL_UNIT" --no-pager -o cat 2>/dev/null | extract_token)" +fi +[ -n "$TOKEN" ] || die "no launch token found in the $JOURNAL_UNIT journal" + +# The token must be safe to embed in a URI and in the nginx config. +printf '%s' "$TOKEN" | grep -qE '^[A-Za-z0-9._~+/=:@-]+$' \ + || die "captured token contains unsupported characters" + +# ── 2. 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 new launch token in $TOKEN_FILE" fi -# Extract the token value (everything after "?token=") -TOKEN_VALUE=$(echo "$TOKEN" | grep -oP "(?<=token=)[^ ]+") +# ── 3. Regenerate the nginx login include (reload only when it changes) ──── +NEW_INCLUDE="$(mktemp "$INCLUDE_FILE.XXXXXX")" +printf 'proxy_pass http://127.0.0.1:3080/?token=%s;\n' "$TOKEN" > "$NEW_INCLUDE" +chmod 600 "$NEW_INCLUDE" -# Reject tokens that could break nginx config or the request URI -if ! printf '%s' "$TOKEN_VALUE" | grep -qE '^[A-Za-z0-9._~+/=%:@-]+$'; then - echo "ERROR: token contains unsupported characters" >&2 - exit 1 +if [ -f "$INCLUDE_FILE" ] && cmp -s "$NEW_INCLUDE" "$INCLUDE_FILE"; then + rm -f "$NEW_INCLUDE" + log "token unchanged; nginx not reloaded" + exit 0 fi -# Write the token to a restricted file -mkdir -p /etc/dsh-web -printf '%s\n' "$TOKEN_VALUE" > /etc/dsh-web/launch-token -chmod 600 /etc/dsh-web/launch-token -echo "Captured dsh-web launch token" +[ -e "$SITE_ENABLED" ] || { rm -f "$NEW_INCLUDE"; die "$SITE_ENABLED missing; refusing to reload"; } -# Create the nginx config with the token (using printf to control expansion) -NGINX_ENABLED="/etc/nginx/sites-enabled/dsh.token" -NGINX_STAGE_DIR="/etc/nginx/sites-available" -mkdir -p "$NGINX_STAGE_DIR" - -TMP_CONFIG="$(mktemp "$NGINX_STAGE_DIR/dsh.token.XXXXXX")" -BACKUP="" -if [ -f "$NGINX_ENABLED" ]; then - BACKUP="$(mktemp "$NGINX_STAGE_DIR/dsh.token.bak.XXXXXX")" - cp -p "$NGINX_ENABLED" "$BACKUP" +RESTORE="" +if [ -f "$INCLUDE_FILE" ]; then + RESTORE="$(mktemp "$INCLUDE_FILE.bak.XXXXXX")" + cp -p "$INCLUDE_FILE" "$RESTORE" fi -{ - printf "server {\n" - printf " listen 127.0.0.1:8081;\n" - printf " server_name _;\n" - printf " \n" - printf " location = /dsh-web-login {\n" - printf " proxy_pass http://127.0.0.1:3080/?token=%s;\n" "$TOKEN_VALUE" - printf " proxy_http_version 1.1;\n" - printf " proxy_set_header Host 127.0.0.1:3080;\n" - printf " proxy_set_header X-Real-IP \$remote_addr;\n" - printf " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n" - printf " }\n" - printf " \n" - printf " location / {\n" - printf " proxy_pass http://127.0.0.1:3080;\n" - printf " proxy_http_version 1.1;\n" - printf " proxy_set_header Host 127.0.0.1:3080;\n" - printf " proxy_set_header X-Real-IP \$remote_addr;\n" - printf " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n" - printf " }\n" - printf "}\n" -} > "$TMP_CONFIG" -chmod 600 "$TMP_CONFIG" +mv "$NEW_INCLUDE" "$INCLUDE_FILE" +chmod 600 "$INCLUDE_FILE" -mv "$TMP_CONFIG" "$NGINX_ENABLED" -CONFIG_APPLIED=1 -restore_on_exit() { - if [ "$CONFIG_APPLIED" -eq 1 ]; then - if [ -n "$BACKUP" ]; then - if cp -p "$BACKUP" "$NGINX_ENABLED" 2>/dev/null; then rm -f "$BACKUP"; fi - else - rm -f "$NGINX_ENABLED" - fi +if ! nginx -t >/dev/null 2>&1; then + if [ -n "$RESTORE" ]; then + mv "$RESTORE" "$INCLUDE_FILE" + else + rm -f "$INCLUDE_FILE" fi -} -trap restore_on_exit EXIT - -if nginx -t; then - /usr/sbin/nginx -s reload - CONFIG_APPLIED=0 - if [ -n "$BACKUP" ]; then rm -f "$BACKUP"; fi - echo "Token captured and nginx reloaded" -else - echo "ERROR: nginx config test failed; rolling back" >&2 - exit 1 + die "nginx config test failed; previous include restored" fi +if [ -n "$RESTORE" ]; then + rm -f "$RESTORE" +fi + +nginx -s reload +log "token changed; nginx reloaded" +log "login endpoint: https://tankodhs.sysloggh.net/dsh-web-login (Authentik-gated)" diff --git a/zulip-health.prose.md b/zulip-health.prose.md index c6dff2a..e3392b6 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: @@ -262,45 +262,116 @@ logged/reported as a warning — reported, never healed on. | 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. Each dsh-web process generates a unique -launch token printed to the journal at startup. The token is used to mint -a 30-day authentication cookie. After the first authenticated login, -subsequent requests use the cookie — no token required. +The dsh-web UI is token-gated. On every start the process prints a random +launch token to the journal: -**Login endpoint**: `http://127.0.0.1:8081/dsh-web-login` (inside CT 112) - -**Token capture script**: `/opt/deepseek-harness/capture-dsh-token.sh` (CT 112) - -The script: -1. Restarts the dsh-web service -2. Captures the token URL from the journal -3. Extracts the token value -4. Writes the token to `/etc/dsh-web/launch-token` -5. Creates an nginx config that exposes the `/dsh-web-login` endpoint on port 8081 -6. Reloads nginx - -**Authentication flow**: -1. Access `http://127.0.0.1:8081/dsh-web-login` → 303 redirect -2. The redirect includes a `Set-Cookie` header with the `dsh-auth-*` cookie -3. The cookie has a 30-day expiry and is authority-bound to `127.0.0.1:3080` -4. Subsequent requests to `http://127.0.0.1:3080/` use the cookie for authentication -5. After 30 days, the cookie expires and a new token exchange is required - -**Verification**: -```bash -# Check if the login endpoint is working: -ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/dsh-web-login" -# Expected: 303 - -# Mint the 30-day cookie from the login endpoint: -ssh root@192.168.68.15 "pct exec 112 -- rm -f /tmp/dsh.jar" -ssh root@192.168.68.15 "pct exec 112 -- curl -s -c /tmp/dsh.jar -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/dsh-web-login" -# Expected: 303 - -# Check if the stored cookie authenticates against dsh-web: -ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/" -# Expected: 200 ``` +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 the latest launch token from the journal +**of the service's current invocation**, writes `/etc/dsh-web/launch-token` and +regenerates `/etc/dsh-web/nginx-login.conf`, reloading nginx only when the token +changed (`nginx -t` guards the reload, with rollback). 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 +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 -D - -o /dev/null \ + -H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'" +# Expected: HTTP/1.1 303 + set-cookie: dsh-auth-... (authority tankodhs.sysloggh.net) + +# 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) a +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. ### Step 4: Platform C — Agent Zero (kagentz, CT 105 via Docker host .14)