#!/usr/bin/env bash # scripts/setup_caddy.sh — publish bot.elghaly.dev through Caddy, with a # path allowlist, without touching any other site block. # # sudo ./scripts/setup_caddy.sh # # Caddy already fronts this box (arb.elghaly.dev -> the Rust bot on :8080, # bot.elghaly.dev -> this Python bot on :8081). The problem this fixes is # that the bot.elghaly.dev block is a bare `reverse_proxy 127.0.0.1:8081`, # which publishes EVERY route the Python server has — including POST # /command, which can move funds, and GET /logs, which leaks wallet # addresses and transaction detail. # # Both are Bearer-gated in the application and both are now rate-limited # and lockout-protected (modules/http_guard.py), so this is defence in # depth rather than an emergency. It is still the right shape: a # fund-moving endpoint should be unreachable for two independent reasons, # so no single mistake — a leaked token, a bad commit, a regression in one # auth check — is enough on its own. # # ───────────────────────────────────────────────────────────────────────── # WHAT THIS SCRIPT WILL AND WILL NOT TOUCH # ───────────────────────────────────────────────────────────────────────── # It rewrites ONLY the `bot.elghaly.dev { … }` block. Every other block — # including arb.elghaly.dev and the bcrypt hash inside it — is copied # through byte for byte. That constraint is why this parses braces instead # of writing a whole new file from a template: a template would silently # destroy the Rust bot's basicauth credential, and "the other bot stopped # working" is a bad way to find that out. # # It takes a timestamped backup, validates before reloading, and restores # the backup automatically if validation fails. A config that does not # parse is never handed to a running Caddy. set -euo pipefail cd "$(dirname "$0")/.." CADDYFILE="${CADDYFILE:-/etc/caddy/Caddyfile}" SITE="${SITE:-bot.elghaly.dev}" UPSTREAM="${UPSTREAM:-127.0.0.1:8081}" # ── PRIVATE by default (2026-07-29) ────────────────────────────────────── # # Operator: "make secure just me don't want anyone talk with the bot." # # The site was built public on the earlier brief ("a public receipts page — # real tx signatures, proof not a claim"). That brief has changed, so the # default has changed with it: the whole site now sits behind HTTP basic # auth, exactly like arb.elghaly.dev already does. # # The path allowlist stays regardless. Auth and the allowlist answer # different questions — "who are you" and "what may be reached at all" — # and /command should remain unroutable even for someone who knows the # password, because the password and the Bearer token are two credentials # an operator may reasonably keep in two different places. # # sudo ./scripts/setup_caddy.sh → private (asks for a password) # sudo PUBLIC=1 ./scripts/setup_caddy.sh → public read-only, as before # sudo BOT_WEB_USER=me BOT_WEB_PASS=… ./scripts/setup_caddy.sh # → private, non-interactive PUBLIC="${PUBLIC:-0}" WEB_USER="${BOT_WEB_USER:-admin}" step() { printf '\n\033[1m── %s\033[0m\n' "$*"; } ok() { printf ' \033[32mok\033[0m — %s\n' "$*"; } bad() { printf ' \033[31mFAILED\033[0m — %s\n' "$*"; } command -v caddy >/dev/null 2>&1 || { bad "caddy is not installed — this box appears to use a different proxy" exit 1 } [ -f "$CADDYFILE" ] || { bad "no $CADDYFILE"; exit 1; } [ "$(id -u)" -eq 0 ] || { bad "run with sudo"; exit 1; } step "1/5 is the bot actually listening on ${UPSTREAM#127.0.0.1:}?" if ss -lnt 2>/dev/null | grep -q "${UPSTREAM}"; then ok "something is listening on $UPSTREAM" else bad "nothing on $UPSTREAM — start the bot first, or the site will 502" fi step "2/5 backing up $CADDYFILE" BACKUP="${CADDYFILE}.bak.$(date +%Y%m%d-%H%M%S)" cp "$CADDYFILE" "$BACKUP" ok "$BACKUP" step "3/5 building the $SITE block" AUTHBLOCK="" if [ "$PUBLIC" = "1" ]; then ok "PUBLIC=1 — read-only pages will be reachable without a password" else if [ -z "${BOT_WEB_PASS:-}" ]; then printf ' password for web user "%s" (typing is hidden): ' "$WEB_USER" stty -echo 2>/dev/null || true read -r BOT_WEB_PASS stty echo 2>/dev/null || true echo fi [ -n "${BOT_WEB_PASS:-}" ] || { bad "empty password — refusing to install an open site"; exit 1; } # caddy hash-password reads the password on stdin, so it never appears in # the process list or in shell history — which `caddy hash-password # --plaintext "$PASS"` would not manage. HASH="$(printf '%s' "$BOT_WEB_PASS" | caddy hash-password 2>/dev/null)" || { bad "caddy hash-password failed"; exit 1; } unset BOT_WEB_PASS # The directive was renamed `basicauth` -> `basic_auth` in Caddy 2.7. # The existing Caddyfile on this box uses the OLD name, so hardcoding # either one is a coin flip that fails on some installs. Probe instead: # validate a throwaway config and keep whichever name parses. # # Deliberately probed rather than parsed out of the version string — # `caddy version` formats have changed too, and "does this directive # actually work here" is the question, not "what release is this". AUTHDIR=basic_auth PROBE="$(mktemp)" printf 'http://probe.invalid {\n\tbasic_auth {\n\t\tu %s\n\t}\n\trespond 200\n}\n' \ "$HASH" > "$PROBE" if ! caddy validate --config "$PROBE" --adapter caddyfile >/dev/null 2>&1; then AUTHDIR=basicauth fi rm -f "$PROBE" AUTHBLOCK=$'\n\t'"${AUTHDIR}"$' {\n\t\t'"${WEB_USER} ${HASH}"$'\n\t}\n' ok "site will require a password for user \"$WEB_USER\" (using \`$AUTHDIR\`)" fi NEWBLOCK="$(cat < "${CADDYFILE}.new" mv "${CADDYFILE}.new" "$CADDYFILE" caddy fmt --overwrite "$CADDYFILE" >/dev/null 2>&1 || true ok "$SITE block replaced; all other blocks copied through unchanged" step "4/5 validating and reloading" if ! caddy validate --config "$CADDYFILE" >/dev/null 2>&1; then bad "config does not validate — restoring $BACKUP and changing nothing" cp "$BACKUP" "$CADDYFILE" caddy validate --config "$CADDYFILE" >/dev/null 2>&1 && ok "backup restored and valid" exit 1 fi ok "config is valid" systemctl reload caddy || systemctl restart caddy ok "caddy reloaded" step "5/5 verifying from outside" sleep 2 FAIL=0 check() { # path expected_code description code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 12 "https://${SITE}$1" || echo 000)" if [ "$code" = "$2" ]; then ok "$1 -> $code ($3)" else bad "$1 -> $code, expected $2 ($3)" FAIL=1 fi } # When the site is private, an ANONYMOUS request to a page route must come # back 401 — a 200 would mean the basic_auth directive did not take effect, # which is the single most important thing to catch here. if [ "$PUBLIC" = "1" ]; then EXPECT=200; else EXPECT=401; fi check "/" "$EXPECT" "landing page" check "/api/pnl" "$EXPECT" "ledger JSON" check "/api/receipts" "$EXPECT" "on-chain receipts" check "/api/routes" "$EXPECT" "route pruning transparency" check "/api/nearmiss" "$EXPECT" "failure-mode log" check "/api/engine" "$EXPECT" "decay, tips, guards" # 404 either way: not routed at all, password or no password. check "/logs" 404 "MUST be unreachable — leaks wallet/tx detail" check "/command" 404 "MUST be unreachable — can move funds" echo if [ "$FAIL" -eq 0 ]; then if [ "$PUBLIC" = "1" ]; then printf '\033[32m✅ https://%s is public read-only, and /command and /logs are not routed.\033[0m\n' "$SITE" else printf '\033[32m✅ https://%s is PRIVATE — anonymous requests get 401, and\n /command and /logs are not routed at all.\033[0m\n' "$SITE" printf ' Log in as "%s" with the password you just set.\n' "$WEB_USER" fi else printf '\033[31m⚠️ Something is off above. Restore with:\033[0m\n' printf ' sudo cp %s %s && sudo systemctl reload caddy\n' "$BACKUP" "$CADDYFILE" exit 1 fi printf ' backup kept at %s\n\n' "$BACKUP"