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:
bdm
2026-09-28 07:54:14 -07:00
commit 6d94fc5eb1
7 changed files with 1968 additions and 0 deletions

11
.gitignore vendored Normal file
View 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
View 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
View 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'
```

1165
app.py Normal file

File diff suppressed because it is too large Load Diff

38
docker-compose.yml Normal file
View 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
View 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
View 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)