bdm 6d94fc5eb1 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.
2026-09-28 07:54:14 -07:00

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

  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

# 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'
Description
No description provided
Readme Apache-2.0 109 KiB
Languages
Python 86.2%
Shell 12.7%
Dockerfile 1.1%