Add cadprox: web UI for the Caddy reverse proxy config
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.
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Runtime state, the generated admin credential and the audit log.
|
||||
data/
|
||||
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# cadprox reuses the official Caddy image so that validation, config adaptation
|
||||
# and password hashing are performed by the exact same binary that will serve
|
||||
# the traffic. A different Caddy build could accept a config the live instance
|
||||
# then rejects.
|
||||
FROM caddy:2.11.4
|
||||
|
||||
# The caddy image ships no Python, and app.py is standard library only.
|
||||
# The base is Alpine, so it is apk rather than apt.
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
WORKDIR /app
|
||||
COPY app.py /app/app.py
|
||||
|
||||
ENV CADPROX_CADDYFILE=/caddy/Caddyfile \
|
||||
CADPROX_CADDY_ADMIN=http://127.0.0.1:2019 \
|
||||
CADPROX_DATA=/data \
|
||||
CADPROX_PORT=8090 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 8090
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["python3", "-u", "/app/app.py"]
|
||||
141
README.md
Normal file
141
README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# cadprox
|
||||
|
||||
A small web UI for the Caddy reverse proxy configuration on this host.
|
||||
|
||||
You give it a URL and a target `IP:Port`. It writes the Caddyfile and hot-reloads
|
||||
the running Caddy through the admin API, so a change is live in well under a
|
||||
second without restarting Caddy and without disturbing the Let's Encrypt
|
||||
certificates or any in-flight connections.
|
||||
|
||||
## What it manages
|
||||
|
||||
Each entry is one site block:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `url` | Bare hostname, e.g. `umbraforge.bdmurphy.me`. No scheme, port or path. |
|
||||
| `target` | `IP:Port` or `hostname:Port` of the backend. |
|
||||
| auth user / password | Optional HTTP Basic Auth for that subdomain. |
|
||||
|
||||
Saving an existing URL updates its target in place. Leaving the password blank
|
||||
on an edit keeps the credential that is already stored.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cd /home/bdm/cadprox
|
||||
docker compose up -d --build
|
||||
docker compose logs -f cadprox # first boot prints the generated password
|
||||
```
|
||||
|
||||
The UI is then on `http://<host>:8090`. Sign in as `admin` with the password
|
||||
from the first boot log, or set `CADPROX_ADMIN_PASSWORD` in
|
||||
`docker-compose.yml` to choose your own.
|
||||
|
||||
To pin the password permanently, generate one and drop it in a file that only
|
||||
you can read, then reference it from the compose file:
|
||||
|
||||
```bash
|
||||
printf 'change-me-at-least-8-chars' > ~/.cadprox-password && chmod 600 ~/.cadprox-password
|
||||
# then in docker-compose.yml:
|
||||
# CADPROX_ADMIN_PASSWORD_FILE: /run/secrets/cadprox-password
|
||||
```
|
||||
|
||||
## How a change is applied
|
||||
|
||||
1. The current Caddyfile is parsed into structured entries.
|
||||
2. The new entry set is rendered back to a Caddyfile.
|
||||
3. The result is validated with the real Caddy binary, via
|
||||
`caddy validate --adapter caddyfile`. An invalid config never reaches disk.
|
||||
4. The file is written atomically (temp file plus `os.replace`).
|
||||
5. It is pushed to the live instance with `POST /load` on `127.0.0.1:2019`.
|
||||
6. If Caddy rejects the load, **the previous file is restored** and the error is
|
||||
shown in the UI and written to the audit log.
|
||||
|
||||
## Design notes worth knowing
|
||||
|
||||
**The Caddyfile is the only source of state.** cadprox deliberately keeps no
|
||||
parallel copy. Targets, `encode gzip` and the Basic Auth hashes all live in the
|
||||
Caddyfile, so re-reading it recovers everything, credentials included. A cached
|
||||
copy would let a hand-edited Caddyfile silently diverge from what the UI shows,
|
||||
and the next save would overwrite the operator's change.
|
||||
|
||||
**Anything cadprox does not model is preserved byte-for-byte.** If a site block
|
||||
contains a directive cadprox does not understand — a `header` block, multiple
|
||||
`reverse_proxy` upstreams, a `redir`, a load balancer policy — the block is kept
|
||||
verbatim, shown in the UI marked *preserved*, and cannot be edited there. It is
|
||||
never silently dropped or rewritten. Blocks cadprox does not recognise are
|
||||
listed as read-only rather than being lost.
|
||||
|
||||
**Changes are validated, then rolled back.** Nothing is written until Caddy has
|
||||
accepted the rendered text, and a failed reload restores the previous file.
|
||||
|
||||
**Credentials.** The admin password is compared with `hmac.compare_digest` and
|
||||
stored as a scrypt hash. A session is a random token in an `HttpOnly`,
|
||||
`SameSite=Strict` cookie. Mutating requests also require an `X-Cadprox` header,
|
||||
which blocks cross-origin form posts. The Basic Auth password for a subdomain is
|
||||
hashed by Caddy itself and the hash is never returned to the browser.
|
||||
|
||||
**Hashes are bcrypt, on purpose.** `caddy hash-password` can emit argon2id, and
|
||||
that hash loads without complaint — but Caddy 2.11's HTTP Basic Auth provider
|
||||
only verifies bcrypt, so an argon2id hash silently rejects *every* login
|
||||
including the correct password. bcrypt at cost 14 is Caddy's own default for
|
||||
`basic_auth`.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
- It does not terminate TLS itself. Caddy keeps doing that, including automatic
|
||||
certificate issuance for new hostnames.
|
||||
- It does not restart Caddy, and it does not edit your Docker Compose file.
|
||||
- It is plain HTTP. It is meant for your LAN. Do not expose port 8090 to the
|
||||
internet as-is; put it behind Caddy with Basic Auth or an SSH tunnel if you
|
||||
need remote access.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
cadprox/
|
||||
app.py the whole application, standard library only
|
||||
Dockerfile caddy:2.11.4 + python3
|
||||
docker-compose.yml host networking so the admin API on 2019 is reachable
|
||||
data/ audit log and the generated admin credential (git-ignored)
|
||||
tests/test_app.py unit tests, including Caddy-backed parse/render tests
|
||||
tests/e2e.sh end-to-end test against a throwaway Caddy
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# unit tests. Caddy-backed tests skip automatically if the binary is missing.
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
# point the Caddy-backed tests at a real Caddy
|
||||
CADPROX_CADDY_BIN=/path/to/caddy python3 -m unittest discover -s tests
|
||||
|
||||
# end-to-end: spins up a throwaway Caddy, a backend and cadprox on spare
|
||||
# ports, and never touches the live configuration.
|
||||
./tests/e2e.sh
|
||||
```
|
||||
|
||||
The end-to-end script asserts real behaviour, including that a correct Basic
|
||||
Auth password returns 200 and a wrong one returns 401, that a new proxy serves
|
||||
traffic immediately, and that a hand-written block survives a save intact.
|
||||
|
||||
## Backups and recovery
|
||||
|
||||
`/home/bdm/caddy` is a git repository. Every meaningful change is a single-line
|
||||
diff, so history is a reliable record of who changed which target when:
|
||||
|
||||
```bash
|
||||
git -C /home/bdm/caddy diff # see the current change
|
||||
git -C /home/bdm/caddy log -p # full history
|
||||
```
|
||||
|
||||
If the Caddyfile is ever left in a bad state, restore it and reload:
|
||||
|
||||
```bash
|
||||
cd /home/bdm/caddy
|
||||
git checkout Caddyfile
|
||||
curl -X POST http://127.0.0.1:2019/load --data-binary @Caddyfile \
|
||||
-H 'Content-Type: text/caddyfile'
|
||||
```
|
||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
# cadprox - web UI for the Caddy reverse proxy configuration
|
||||
#
|
||||
# network_mode: host is required, not stylistic. The Caddy admin API listens on
|
||||
# 127.0.0.1:2019 on the host loopback and Caddy itself is in host mode, so this
|
||||
# is the only way cadprox can reach it to hot-reload without restarting Caddy.
|
||||
#
|
||||
# The Caddyfile is mounted read-write because cadprox is the writer. Caddy keeps
|
||||
# its own read-only mount, so a cadprox edit does not disturb the running
|
||||
# container until the admin API accepts the new config.
|
||||
|
||||
services:
|
||||
cadprox:
|
||||
build: .
|
||||
image: cadprox:1.0
|
||||
container_name: cadprox
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
|
||||
volumes:
|
||||
# The managed Caddyfile. Read-write: cadprox writes it, Caddy reads it.
|
||||
- /home/bdm/caddy:/caddy
|
||||
# State, audit log and the generated admin credential.
|
||||
- ./data:/data
|
||||
|
||||
environment:
|
||||
CADPROX_CADDYFILE: /caddy/Caddyfile
|
||||
CADPROX_CADDY_ADMIN: http://127.0.0.1:2019
|
||||
CADPROX_CADDY_BIN: caddy
|
||||
CADPROX_DATA: /data
|
||||
CADPROX_PORT: "8090"
|
||||
CADPROX_ADMIN_USER: admin
|
||||
# Leave CADPROX_ADMIN_PASSWORD unset to auto-generate one on first boot.
|
||||
# The generated password is written to the container log exactly once.
|
||||
# CADPROX_ADMIN_PASSWORD: "change-me-at-least-8-chars"
|
||||
#
|
||||
# Or keep the password in a root-only file instead of the compose file, so
|
||||
# it never ends up in `docker inspect` output or a committed config.
|
||||
# CADPROX_ADMIN_PASSWORD_FILE: /run/secrets/cadprox-password
|
||||
175
tests/e2e.sh
Executable file
175
tests/e2e.sh
Executable file
@@ -0,0 +1,175 @@
|
||||
#!/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 ]
|
||||
415
tests/test_app.py
Normal file
415
tests/test_app.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Tests for cadprox's Caddyfile parsing and rendering.
|
||||
|
||||
These cover the parts that can silently destroy the user's configuration:
|
||||
round-tripping a hand-written Caddyfile, preserving blocks cadprox does not
|
||||
model, and making sure password hashes never leave the server.
|
||||
|
||||
Run with: python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import app # noqa: E402
|
||||
|
||||
REAL_CADDYFILE = """\
|
||||
bdmurphy.me {
|
||||
encode gzip
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
vault.bdmurphy.me {
|
||||
encode gzip
|
||||
reverse_proxy 127.0.0.1:8082
|
||||
}
|
||||
|
||||
umbraudio.bdmurphy.me {
|
||||
encode gzip
|
||||
reverse_proxy 192.168.123.12:8420
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def targets(state):
|
||||
return {", ".join(e["hosts"]): e["target"] for e in state["entries"]}
|
||||
|
||||
|
||||
def caddy_available() -> bool:
|
||||
try:
|
||||
return subprocess.run(
|
||||
[app.CADDY_BIN, "version"], capture_output=True, timeout=30
|
||||
).returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
class CaddyBackedTest(unittest.TestCase):
|
||||
"""Base class for tests that need the real Caddy binary to adjudicate."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not caddy_available():
|
||||
raise unittest.SkipTest(
|
||||
"caddy binary not reachable; set CADPROX_CADDY_BIN to test against a real Caddy"
|
||||
)
|
||||
|
||||
def validate_with_real_caddy(self, config: str) -> tuple[bool, str]:
|
||||
return app.validate_caddyfile(config)
|
||||
|
||||
|
||||
class TestParse(CaddyBackedTest):
|
||||
def test_parses_real_caddyfile(self):
|
||||
state = app.parse_caddyfile(REAL_CADDYFILE)
|
||||
self.assertEqual(
|
||||
targets(state),
|
||||
{
|
||||
"bdmurphy.me": "127.0.0.1:8080",
|
||||
"vault.bdmurphy.me": "127.0.0.1:8082",
|
||||
"umbraudio.bdmurphy.me": "192.168.123.12:8420",
|
||||
},
|
||||
)
|
||||
self.assertTrue(all(e["managed"] for e in state["entries"]))
|
||||
|
||||
def test_header_does_not_accumulate_across_saves(self):
|
||||
# Regression: the generated header used to be absorbed as preserved
|
||||
# leading text, so every save added another copy and the file grew.
|
||||
state = app.parse_caddyfile(REAL_CADDYFILE)
|
||||
text = app.render_caddyfile(state)
|
||||
for _ in range(5):
|
||||
text = app.render_caddyfile(app.parse_caddyfile(text))
|
||||
self.assertEqual(
|
||||
text.count(app.MANAGED_MARKER), 1,
|
||||
"the cadprox header was duplicated across repeated saves",
|
||||
)
|
||||
self.assertEqual(
|
||||
len([ln for ln in text.splitlines() if ln.startswith("#") and not ln.startswith("# ")]),
|
||||
1,
|
||||
)
|
||||
ok, msg = self.validate_with_real_caddy(text)
|
||||
self.assertTrue(ok, f"repeated saves produced an invalid config: {msg}")
|
||||
|
||||
def test_operator_comments_at_the_top_are_preserved(self):
|
||||
text = """\
|
||||
# My own note about this file
|
||||
# second line
|
||||
|
||||
a.example.com {
|
||||
\treverse_proxy 10.0.0.1:80
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
rendered = app.render_caddyfile(state)
|
||||
self.assertIn("# My own note about this file", rendered)
|
||||
self.assertIn("# second line", rendered)
|
||||
self.assertEqual(rendered.count(app.MANAGED_MARKER), 1)
|
||||
|
||||
def test_header_does_not_hide_global_block(self):
|
||||
# Regression: cadprox writes its own comment header at the top of the
|
||||
# file, and those comments used to stop the global block being found,
|
||||
# which turned it into a phantom entry with a blank URL.
|
||||
text = """\
|
||||
# Caddy reverse proxy configuration
|
||||
#
|
||||
# Site blocks below are managed by cadprox.
|
||||
|
||||
{
|
||||
\tadmin 127.0.0.1:2019
|
||||
\tauto_https off
|
||||
}
|
||||
|
||||
a.example.com {
|
||||
\tencode gzip
|
||||
\treverse_proxy 10.0.0.1:80
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertIn("auto_https off", state["globals"])
|
||||
self.assertEqual([e["hosts"] for e in state["entries"]], [["a.example.com"]])
|
||||
for entry in state["entries"]:
|
||||
self.assertTrue(entry["hosts"], "entry with no site address leaked into the UI")
|
||||
rendered = app.render_caddyfile(state)
|
||||
self.assertLess(rendered.index("auto_https off"), rendered.index("a.example.com"))
|
||||
ok, msg = self.validate_with_real_caddy(rendered)
|
||||
self.assertTrue(ok, f"rendered config rejected by caddy: {msg}")
|
||||
|
||||
def test_keyless_block_never_becomes_an_entry(self):
|
||||
state = app.parse_caddyfile("{\n\tadmin 0.0.0.0:2019\n}\n")
|
||||
self.assertEqual(state["entries"], [])
|
||||
self.assertIn("admin 0.0.0.0:2019", state["globals"])
|
||||
|
||||
def test_mid_file_keyless_block_preserved_not_listed(self):
|
||||
text = """\
|
||||
a.example.com {
|
||||
\treverse_proxy 10.0.0.1:80
|
||||
}
|
||||
|
||||
{
|
||||
\tservers {
|
||||
\t\ttrusted_proxies static 10.0.0.0/8
|
||||
\t}
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertEqual([e["hosts"] for e in state["entries"]], [["a.example.com"]])
|
||||
self.assertIn("trusted_proxies", state["extras"])
|
||||
self.assertIn("trusted_proxies", app.render_caddyfile(state))
|
||||
|
||||
def test_global_options_block_preserved(self):
|
||||
text = """\
|
||||
{
|
||||
\tadmin 127.0.0.1:2019
|
||||
\temail me@example.com
|
||||
}
|
||||
|
||||
a.example.com {
|
||||
\treverse_proxy 10.0.0.1:80
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertEqual(len(state["entries"]), 1)
|
||||
self.assertIn("email me@example.com", state["globals"])
|
||||
self.assertIn("admin 127.0.0.1:2019", state["globals"])
|
||||
self.assertEqual(state["extras"], "")
|
||||
rendered = app.render_caddyfile(state)
|
||||
self.assertIn("email me@example.com", rendered)
|
||||
# Caddy rejects a global block that is not first, so it must precede
|
||||
# every site block in the rendered output.
|
||||
self.assertLess(
|
||||
rendered.index("email me@example.com"),
|
||||
rendered.index("a.example.com"),
|
||||
)
|
||||
|
||||
def test_global_block_alone_round_trips(self):
|
||||
text = "{\n\tadmin 0.0.0.0:2019\n}\n"
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertEqual(state["entries"], [])
|
||||
self.assertIn("admin 0.0.0.0:2019", app.render_caddyfile(state))
|
||||
|
||||
def test_basic_auth_round_trips_hashes(self):
|
||||
text = """\
|
||||
secure.example.com {
|
||||
\tencode gzip
|
||||
\tbasic_auth {
|
||||
\t\talice $2a$14$abcdefghijklmnopqrstuv
|
||||
\t\tbob $2a$14$zyxwvutsrqponmlkjihg
|
||||
\t}
|
||||
\treverse_proxy 10.0.0.2:8080
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
entry = state["entries"][0]
|
||||
self.assertTrue(entry["managed"])
|
||||
self.assertEqual(entry["auth"]["users"], ["alice", "bob"])
|
||||
self.assertEqual(
|
||||
entry["auth"]["hashes"],
|
||||
["$2a$14$abcdefghijklmnopqrstuv", "$2a$14$zyxwvutsrqponmlkjihg"],
|
||||
)
|
||||
# The exact hashes must survive a render.
|
||||
rendered = app.render_caddyfile(state)
|
||||
self.assertIn("alice $2a$14$abcdefghijklmnopqrstuv", rendered)
|
||||
self.assertIn("bob $2a$14$zyxwvutsrqponmlkjihg", rendered)
|
||||
|
||||
def test_hash_password_produces_a_hash_basic_auth_accepts(self):
|
||||
# Regression: Caddy's hash-password can emit argon2id, but the HTTP
|
||||
# basic auth provider in Caddy 2.11 only verifies bcrypt, so an argon2id
|
||||
# hash loads fine and then rejects every login including the right one.
|
||||
hashed = app.hash_password("correcthorsebattery")
|
||||
self.assertTrue(
|
||||
hashed.startswith("$2"),
|
||||
f"expected a bcrypt hash, got {hashed[:16]!r}",
|
||||
)
|
||||
self.assertNotIn("argon2id", hashed)
|
||||
self.assertNotIn("correcthorsebattery", hashed)
|
||||
# bcrypt salts randomly, so the same password must not hash identically.
|
||||
self.assertNotEqual(hashed, app.hash_password("correcthorsebattery"))
|
||||
|
||||
def test_generated_hash_survives_a_render_and_reparse(self):
|
||||
hashed = app.hash_password("correcthorsebattery")
|
||||
state = {
|
||||
"entries": [
|
||||
{
|
||||
"hosts": ["s.example.com"],
|
||||
"target": "10.0.0.1:80",
|
||||
"encode": True,
|
||||
"auth": {"users": ["alice"], "hashes": [hashed]},
|
||||
"managed": True,
|
||||
"reason": "",
|
||||
"lines": [],
|
||||
}
|
||||
],
|
||||
"globals": "",
|
||||
"extras": "",
|
||||
}
|
||||
reparsed = app.parse_caddyfile(app.render_caddyfile(state))
|
||||
self.assertEqual(reparsed["entries"][0]["auth"]["hashes"], [hashed])
|
||||
ok, msg = self.validate_with_real_caddy(app.render_caddyfile(state))
|
||||
self.assertTrue(ok, f"caddy rejected the generated config: {msg}")
|
||||
|
||||
def test_unknown_directive_marks_block_unmanaged_and_preserves_it(self):
|
||||
text = """\
|
||||
fancy.example.com {
|
||||
\tencode gzip
|
||||
\theader_up X-Custom abc
|
||||
\treverse_proxy 10.0.0.3:9000
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
entry = state["entries"][0]
|
||||
self.assertFalse(entry["managed"])
|
||||
self.assertIn("header_up X-Custom abc", entry["reason"])
|
||||
rendered = app.render_caddyfile(state)
|
||||
self.assertIn("header_up X-Custom abc", rendered)
|
||||
self.assertIn("reverse_proxy 10.0.0.3:9000", rendered)
|
||||
|
||||
def test_multiple_upstreams_marked_unmanaged(self):
|
||||
text = """\
|
||||
lb.example.com {
|
||||
\treverse_proxy 10.0.0.4:80 10.0.0.5:80
|
||||
}
|
||||
"""
|
||||
entry = app.parse_caddyfile(text)["entries"][0]
|
||||
self.assertFalse(entry["managed"])
|
||||
|
||||
def test_import_preserved_verbatim(self):
|
||||
text = """\
|
||||
import /etc/caddy/snippets/*.caddy
|
||||
|
||||
x.example.com {
|
||||
\treverse_proxy 10.0.0.6:80
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertIn("import /etc/caddy/snippets/*.caddy", state["extras"])
|
||||
self.assertIn("import /etc/caddy/snippets/*.caddy", app.render_caddyfile(state))
|
||||
|
||||
def test_nested_braces_do_not_split_blocks(self):
|
||||
text = """\
|
||||
a.example.com {
|
||||
\tbasic_auth {
|
||||
\t\tu $2a$14$abcdefghijklmnopqrstuv
|
||||
\t}
|
||||
\treverse_proxy 10.0.0.7:80
|
||||
}
|
||||
b.example.com {
|
||||
\treverse_proxy 10.0.0.8:80
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertEqual(len(state["entries"]), 2)
|
||||
self.assertEqual(
|
||||
targets(state), {"a.example.com": "10.0.0.7:80", "b.example.com": "10.0.0.8:80"}
|
||||
)
|
||||
|
||||
def test_comments_ignored(self):
|
||||
text = """\
|
||||
# leading comment
|
||||
a.example.com {
|
||||
\t# inner comment
|
||||
\tencode gzip
|
||||
\treverse_proxy 10.0.0.9:80 # trailing
|
||||
}
|
||||
"""
|
||||
state = app.parse_caddyfile(text)
|
||||
self.assertEqual(len(state["entries"]), 1)
|
||||
self.assertTrue(state["entries"][0]["managed"])
|
||||
|
||||
|
||||
class TestRenderRoundTrip(unittest.TestCase):
|
||||
def test_render_then_parse_is_stable(self):
|
||||
state = app.parse_caddyfile(REAL_CADDYFILE)
|
||||
first = app.render_caddyfile(state)
|
||||
second = app.render_caddyfile(app.parse_caddyfile(first))
|
||||
self.assertEqual(first, second, "render is not idempotent")
|
||||
|
||||
def test_real_targets_survive_render(self):
|
||||
state = app.parse_caddyfile(REAL_CADDYFILE)
|
||||
reparsed = app.parse_caddyfile(app.render_caddyfile(state))
|
||||
self.assertEqual(targets(state), targets(reparsed))
|
||||
self.assertEqual(
|
||||
[", ".join(e["hosts"]) for e in state["entries"]],
|
||||
[", ".join(e["hosts"]) for e in reparsed["entries"]],
|
||||
)
|
||||
|
||||
def test_render_uses_tabs_and_ends_with_newline(self):
|
||||
state = app.parse_caddyfile(REAL_CADDYFILE)
|
||||
out = app.render_caddyfile(state)
|
||||
self.assertTrue(out.endswith("\n"))
|
||||
self.assertIn("\treverse_proxy 127.0.0.1:8080", out)
|
||||
self.assertIn("\tencode gzip", out)
|
||||
|
||||
|
||||
class TestValidation(unittest.TestCase):
|
||||
def test_host_accepted(self):
|
||||
for good in ("bdmurphy.me", "A.Bdmurphy.ME", "localhost", "192.168.1.1", "a-b.c"):
|
||||
self.assertEqual(app.validate_host(good), good.lower().rstrip("."))
|
||||
|
||||
def test_host_rejected(self):
|
||||
for bad in ("", "http://a.com", "a.com/path", "a.com:80", "has space", "a..b", "-x.com"):
|
||||
with self.assertRaises(app.ValidationError, msg=bad):
|
||||
app.validate_host(bad)
|
||||
|
||||
def test_target_accepted(self):
|
||||
self.assertEqual(app.validate_target(" 192.168.123.14:8425 "), "192.168.123.14:8425")
|
||||
self.assertEqual(app.validate_target("host.local:1"), "host.local:1")
|
||||
self.assertEqual(app.validate_target("1.2.3.4:65535"), "1.2.3.4:65535")
|
||||
|
||||
def test_target_rejected(self):
|
||||
for bad in ("", "http://1.2.3.4:80", "1.2.3.4", "1.2.3.4:80:90", "1.2.3.4:0",
|
||||
"1.2.3.4:70000", "1.2.3.4:http", "1.2.3.4/80"):
|
||||
with self.assertRaises(app.ValidationError, msg=bad):
|
||||
app.validate_target(bad)
|
||||
|
||||
def test_password_rules(self):
|
||||
with self.assertRaises(app.ValidationError):
|
||||
app.validate_password("short")
|
||||
with self.assertRaises(app.ValidationError):
|
||||
app.validate_password("")
|
||||
self.assertEqual(app.validate_password("longenough1"), "longenough1")
|
||||
|
||||
def test_username_rules(self):
|
||||
with self.assertRaises(app.ValidationError):
|
||||
app.validate_username("bad user")
|
||||
with self.assertRaises(app.ValidationError):
|
||||
app.validate_username("")
|
||||
self.assertEqual(app.validate_username("alice"), "alice")
|
||||
|
||||
|
||||
class TestPublicState(unittest.TestCase):
|
||||
def test_hashes_never_leave_the_server(self):
|
||||
text = """\
|
||||
s.example.com {
|
||||
\tbasic_auth {
|
||||
\t\talice $2a$14$abcdefghijklmnopqrstuv
|
||||
\t}
|
||||
\treverse_proxy 10.0.0.1:80
|
||||
}
|
||||
"""
|
||||
public = app.public_state(app.parse_caddyfile(text))
|
||||
blob = json.dumps(public)
|
||||
self.assertNotIn("$2a$14$", blob)
|
||||
self.assertNotIn("abcdefghijklmnopqrstuv", blob)
|
||||
self.assertEqual(public["entries"][0]["auth_user"], "alice")
|
||||
self.assertEqual(public["entries"][0]["auth_count"], 1)
|
||||
|
||||
def test_open_proxy_reported_as_no_auth(self):
|
||||
public = app.public_state(app.parse_caddyfile(REAL_CADDYFILE))
|
||||
self.assertTrue(all(e["auth_user"] == "" for e in public["entries"]))
|
||||
|
||||
|
||||
class TestSessions(unittest.TestCase):
|
||||
def test_token_lifecycle(self):
|
||||
token = app.new_session()
|
||||
self.assertTrue(app.session_valid(token))
|
||||
self.assertFalse(app.session_valid("nonsense"))
|
||||
self.assertFalse(app.session_valid(""))
|
||||
app._sessions[token] = 0
|
||||
self.assertFalse(app.session_valid(token), "expired token accepted")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user