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"