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.
5.6 KiB
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
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:
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
- The current Caddyfile is parsed into structured entries.
- The new entry set is rendered back to a Caddyfile.
- The result is validated with the real Caddy binary, via
caddy validate --adapter caddyfile. An invalid config never reaches disk. - The file is written atomically (temp file plus
os.replace). - It is pushed to the live instance with
POST /loadon127.0.0.1:2019. - 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
# 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:
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:
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'