File size: 10,378 Bytes
de83fbe bf3ef55 de83fbe bf3ef55 de83fbe bf3ef55 de83fbe bf3ef55 de83fbe bf3ef55 de83fbe bf3ef55 de83fbe | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | #!/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"
|