Manages one Caddyfile entry per subdomain (URL -> IP:Port, plus optional
HTTP Basic Auth) and applies changes with a hot-reload through the Caddy
admin API, so an edit is live in ~0.3s with no restart and no certificate
churn.
Safety properties:
- every change is validated by the real Caddy binary before it is written
- a rejected reload restores the previous Caddyfile
- site blocks containing directives cadprox does not model are preserved
byte-for-byte, shown read-only, and never rewritten
- the Caddyfile is the only source of state, so a hand-edited file cannot
silently diverge from what the UI shows
- password hashes never leave the server; the session cookie is HttpOnly
and SameSite=Strict, and mutating requests need a custom header
app.py is a single standard-library file. The image is built on caddy:2.11.4
so validation, adaptation and hashing use the same binary that serves
traffic, which is why basic-auth hashes are bcrypt: Caddy's argon2id hashes
load fine but its basic-auth provider only verifies bcrypt.
Tests: 28 unit tests (Caddy-backed ones skip without the binary) and a 32
assertion end-to-end script that runs against a throwaway Caddy.
176 lines
9.0 KiB
Bash
Executable File
176 lines
9.0 KiB
Bash
Executable File
#!/bin/bash
|
|
# End-to-end test for cadprox against a throwaway Caddy. Production is never touched.
|
|
set -u
|
|
B=http://127.0.0.1:8091
|
|
PROXY_PORT=8099
|
|
J=/tmp/opencode/e2e/jar
|
|
PASS=0; FAIL=0
|
|
ok() { echo " PASS $1"; PASS=$((PASS+1)); }
|
|
bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); }
|
|
check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
|
|
|
|
ups(){ curl -s -m 20 -b $J -X POST $B/api/entries -H 'Content-Type: application/json' -H 'X-Cadprox: 1' -d "$1"; }
|
|
del(){ curl -s -m 20 -b $J -X POST $B/api/delete -H 'Content-Type: application/json' -H 'X-Cadprox: 1' -d "$1"; }
|
|
# Issue a request through the throwaway Caddy, using SNI so the right site block
|
|
# is selected. -k accepts Caddy's internal-CA certificate. A brand new hostname
|
|
# needs Caddy a moment to provision its certificate, so retry briefly.
|
|
req(){ local h="$1"; shift
|
|
for _ in $(seq 1 20); do
|
|
c=$(curl -sk -o /dev/null -w '%{http_code}' -m 20 \
|
|
--resolve "$h:$PROXY_PORT:127.0.0.1" "$@" "https://$h:$PROXY_PORT/")
|
|
# 000 means the TLS handshake failed, which is what an unprovisioned
|
|
# certificate looks like. Any real HTTP status is a real answer.
|
|
[ "$c" != "000" ] && { echo "$c"; return; }
|
|
sleep 1
|
|
done
|
|
echo "000"; }
|
|
err(){ ups "$1" | python3 -c "import json,sys;print(json.load(sys.stdin).get('error',''))"; }
|
|
login(){ rm -f $J
|
|
curl -s -c $J -X POST $B/api/login -H 'Content-Type: application/json' \
|
|
-d '{"username":"admin","password":"e2etestpassword"}' >/dev/null; }
|
|
# Block until the throwaway Caddy admin API answers, so a restart is never
|
|
# raced by a reload request.
|
|
wait_admin(){ for _ in $(seq 1 30); do
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 http://127.0.0.1:2020/config/)" = "200" ] && return
|
|
sleep 1
|
|
done
|
|
echo " WARNING: caddy admin API never became ready" >&2; }
|
|
|
|
echo "=== reset ==="
|
|
docker rm -f caddy-e2e backend-e2e cadprox-e2e >/dev/null 2>&1
|
|
# cadprox runs as root and owns the files it writes, so the directory has to be
|
|
# cleared from a container too. A plain rm leaves root-owned files behind and the
|
|
# next run silently inherits stale state.
|
|
docker run --rm -v /tmp/opencode/e2e:/x caddy:2.11.4 sh -c 'rm -rf /x/*' >/dev/null 2>&1
|
|
rm -rf /tmp/opencode/e2e; mkdir -p /tmp/opencode/e2e/datadocker network rm cadprox-e2e-net >/dev/null 2>&1
|
|
docker network create cadprox-e2e-net >/dev/null
|
|
docker run -d --name backend-e2e --network cadprox-e2e-net \
|
|
python:3.12-alpine python3 -m http.server 9001 --directory /tmp >/dev/null
|
|
# local_certs keeps auto-HTTPS on but issues from Caddy's internal CA, so the
|
|
# site blocks behave exactly as they do in production but need no public DNS.
|
|
cat > /tmp/opencode/e2e/Caddyfile <<'EOF'
|
|
{
|
|
admin 0.0.0.0:2019
|
|
local_certs
|
|
}
|
|
|
|
one.test {
|
|
encode gzip
|
|
reverse_proxy backend-e2e:9001
|
|
}
|
|
EOF
|
|
docker run -d --name caddy-e2e --network cadprox-e2e-net \
|
|
-p 127.0.0.1:2020:2019 -p 127.0.0.1:$PROXY_PORT:443 \
|
|
-v /tmp/opencode/e2e/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2.11.4 >/dev/null
|
|
docker run -d --name cadprox-e2e --network host \
|
|
-v /tmp/opencode/e2e:/caddy -v /tmp/opencode/e2e/data:/data \
|
|
-e CADPROX_CADDY_ADMIN=http://127.0.0.1:2020 -e CADPROX_PORT=8091 \
|
|
-e CADPROX_ADMIN_PASSWORD=e2etestpassword cadprox:1.0 >/dev/null
|
|
wait_admin
|
|
sleep 1
|
|
login
|
|
[ -s $J ] && ok "login issues a session cookie" || bad "no session cookie"
|
|
|
|
echo "=== 1. baseline traffic through the proxy (no auth) ==="
|
|
check "open proxy serves the backend" "$(req one.test)" "200"
|
|
|
|
echo "=== 2. add a proxy, traffic follows immediately ==="
|
|
ups '{"url":"two.test","target":"backend-e2e:9001"}' >/dev/null
|
|
check "new proxy serves traffic" "$(req two.test)" "200"
|
|
|
|
echo "=== 3. edit the target of an existing proxy ==="
|
|
ups '{"url":"two.test","target":"127.0.0.1:9"}' >/dev/null
|
|
check "bad target now returns 502" "$(req two.test)" "502"
|
|
ups '{"url":"two.test","target":"backend-e2e:9001"}' >/dev/null
|
|
check "restored target serves again" "$(req two.test)" "200"
|
|
|
|
echo "=== 4. basic auth is enforced end to end ==="
|
|
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":"firstpassword"}}' >/dev/null
|
|
check "no credentials -> 401" "$(req secure.test)" "401"
|
|
check "wrong password -> 401" "$(req secure.test -u alice:wrong)" "401"
|
|
check "correct credentials -> 200" "$(req secure.test -u alice:firstpassword)" "200"
|
|
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":"secondpassword"}}' >/dev/null
|
|
check "old password now -> 401" "$(req secure.test -u alice:firstpassword)" "401"
|
|
check "new password -> 200" "$(req secure.test -u alice:secondpassword)" "200"
|
|
|
|
echo "=== 5. editing the target keeps the credential ==="
|
|
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":null}}' >/dev/null
|
|
check "auth still required after target edit" "$(req secure.test)" "401"
|
|
check "credential still valid after target edit" "$(req secure.test -u alice:secondpassword)" "200"
|
|
|
|
echo "=== 6. remove auth from a proxy ==="
|
|
ups '{"url":"secure.test","target":"backend-e2e:9001","auth":null}' >/dev/null
|
|
check "auth removed -> 200 without credentials" "$(req secure.test)" "200"
|
|
|
|
echo "=== 7. delete a proxy ==="
|
|
del '{"url":"two.test"}' >/dev/null
|
|
# A deleted hostname has no route and no certificate, so Caddy answers the
|
|
# handshake with a non-200 outcome rather than proxying to the backend.
|
|
two_now=$(req two.test)
|
|
if [ "$two_now" != "200" ]; then ok "deleted proxy no longer serves the backend (got $two_now)"
|
|
else bad "deleted proxy still serving traffic"; fi
|
|
check "untouched proxy still routes" "$(req one.test)" "200"
|
|
|
|
echo "=== 8. a hand-written block survives a save ==="
|
|
# -i is required: without it docker does not forward the heredoc on stdin.
|
|
docker exec -i cadprox-e2e sh -c 'cat >> /caddy/Caddyfile' <<'EOF'
|
|
|
|
handwritten.test {
|
|
header X-Special yes
|
|
reverse_proxy backend-e2e:9001
|
|
}
|
|
EOF
|
|
docker restart caddy-e2e >/dev/null; wait_admin
|
|
docker restart cadprox-e2e >/dev/null; sleep 3
|
|
login
|
|
managed=$(curl -s -b $J $B/api/entries | python3 -c "
|
|
import json,sys
|
|
e=[x for x in json.load(sys.stdin)['entries'] if x['url']=='handwritten.test']
|
|
print(e[0]['managed'] if e else 'absent')")
|
|
check "hand-written block is read-only in the UI" "$managed" "False"
|
|
refusal=$(err '{"url":"handwritten.test","target":"127.0.0.1:9"}')
|
|
case "$refusal" in
|
|
*"preserved verbatim"*) ok "editing a preserved block is refused";;
|
|
*) bad "expected a refusal, got: '$refusal'";;
|
|
esac
|
|
check "preserved block serves traffic before the save" "$(req handwritten.test)" "200"
|
|
ups '{"url":"one.test","target":"backend-e2e:9001"}' >/dev/null
|
|
check "preserved block still in the file after a save" \
|
|
"$(docker exec cadprox-e2e sh -c 'grep -c "header X-Special yes" /caddy/Caddyfile')" "1"
|
|
check "preserved block still serves traffic after a save" "$(req handwritten.test)" "200"
|
|
check "preserved block's custom directive survived the save" \
|
|
"$(docker exec cadprox-e2e sh -c 'grep -c "X-Special" /caddy/Caddyfile')" "1"
|
|
check "the cadprox header did not accumulate across saves" \
|
|
"$(docker exec cadprox-e2e sh -c 'grep -c "Managed by cadprox" /caddy/Caddyfile')" "1"
|
|
|
|
echo "=== 9. security ==="
|
|
check "unauthenticated API is refused" "$(curl -s -o /dev/null -w '%{http_code}' -m 10 $B/api/entries)" "401"
|
|
check "POST without the CSRF header is refused" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -m 10 -b $J -X POST $B/api/entries -H 'Content-Type: application/json' -d '{"url":"x.test","target":"1.2.3.4:80"}')" "403"
|
|
check "wrong password is refused" \
|
|
"$(curl -s -o /dev/null -w '%{http_code}' -m 10 -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"nope"}')" "401"
|
|
check "no password hash is exposed by the API" \
|
|
"$(curl -s -b $J $B/api/entries | grep -c '\$2[aby]\$')" "0"
|
|
check "invalid target is rejected" \
|
|
"$(err '{"url":"bad.test","target":"1.2.3.4:99999"}' | grep -c 'not a valid port')" "1"
|
|
check "URL with a scheme is rejected" \
|
|
"$(err '{"url":"http://bad.test","target":"1.2.3.4:80"}' | grep -c 'no scheme')" "1"
|
|
# curl's cookie jar records HttpOnly but silently drops SameSite, so the raw
|
|
# Set-Cookie header is inspected instead.
|
|
sc=$(curl -s -i -X POST $B/api/login -H 'Content-Type: application/json' \
|
|
-d '{"username":"admin","password":"e2etestpassword"}' | grep -i '^set-cookie:')
|
|
case "$sc" in *HttpOnly*) ok "session cookie is HttpOnly";; *) bad "cookie not HttpOnly: $sc";; esac
|
|
case "$sc" in *SameSite=Strict*) ok "session cookie is SameSite=Strict";; *) bad "cookie lacks SameSite=Strict: $sc";; esac
|
|
case "$sc" in *Secure*) bad "cookie marked Secure but cadprox serves plain HTTP";;
|
|
*) ok "cookie not falsely marked Secure over HTTP";; esac
|
|
|
|
echo "=== 10. the Caddyfile on disk still validates ==="
|
|
docker exec cadprox-e2e caddy validate --adapter caddyfile --config /caddy/Caddyfile >/dev/null 2>&1 \
|
|
&& ok "caddy validate accepts the final file" || bad "caddy validate rejected the final file"
|
|
|
|
echo
|
|
echo "================================"
|
|
echo " passed: $PASS failed: $FAIL"
|
|
echo "================================"
|
|
[ "$FAIL" -eq 0 ]
|