Garden-Angel-Ai-35Bot / scripts /setup_caddy.sh
Claude
Lock the bot to its operator, fix aws-secrets, and explain the no-trade
bf3ef55 unverified
Raw
History Blame Contribute Delete
10.4 kB
#!/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 <<EOF
${SITE} {
encode gzip
${AUTHBLOCK}
# Read-only surface. Nothing here can act and nothing here returns a
# secret. An ALLOWLIST, so a route added to the Python server tomorrow
# stays unreachable until someone publishes it on purpose.
#
# Kept even when the site is password-protected: auth answers "who are
# you", the allowlist answers "what may be reached at all", and
# /command should stay unroutable even for someone holding the
# password.
@public {
path /
path /status
path /api/pnl
path /api/receipts
path /api/routes
path /api/nearmiss
path /api/engine
path /receipts.csv
}
handle @public {
reverse_proxy ${UPSTREAM}
}
# /command (fund-moving) and /logs (operational detail) land here.
# 404, not 403: a 403 confirms the path exists and is worth attacking.
handle {
respond "not found" 404
}
header {
Content-Security-Policy "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
X-Frame-Options "DENY"
-Server
}
}
EOF
)"
# Brace-depth scan. Everything outside the target block is echoed verbatim,
# so arb.elghaly.dev and its bcrypt hash survive untouched. If the block is
# absent it is appended instead.
SITE="$SITE" NEWBLOCK="$NEWBLOCK" awk '
BEGIN { site = ENVIRON["SITE"]; blk = ENVIRON["NEWBLOCK"]; depth = 0; skip = 0; done = 0 }
{
line = $0
if (!skip && depth == 0 && index(line, site) == 1) {
# Start of the target block. Emit the replacement, then swallow the
# original through to its matching close brace.
print blk
done = 1
skip = 1
depth = 0
}
if (skip) {
n = gsub(/{/, "{", line); m = gsub(/}/, "}", line)
depth += n - m
if (depth <= 0) { skip = 0; depth = 0 }
next
}
print line
}
END { if (!done) { print ""; print blk } }
' "$CADDYFILE" > "${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"