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

The UI is then on http://<host>:8090. Sign in as admin.

The password comes from CADPRox_Secrets in this directory, which is mounted read-only into the container and never written into docker-compose.yml:

cat CADPRox_Secrets          # the admin password, one line, mode 600

That file is in .gitignore. It is deliberately kept out of the compose file because the password contains a #, which YAML would read as the start of a comment, and because anything in the compose file is visible in docker compose config and docker inspect.

CADPRox_Secrets accepts either shape:

your-own-secret-here                         <- bare password, the common case
# comment lines and blank lines are ignored
CADPROX_ADMIN_PASSWORD=some-secret-here      <- key=value, for holding more later

If neither key is recognised, the whole file is treated as the password, so a bare password that happens to contain = still works.

To change the password, edit the file and docker compose up -d --build. The old one stops working immediately. If the file is missing, Docker silently creates a directory in its place; cadprox detects that and refuses to start rather than coming up with no password.

Why the port is not in the docker ps output

docker ps shows a PORTS column only for published ports, and publishing a port requires bridge networking. Bridge networking cannot reach Caddy's admin API, which binds to 127.0.0.1:2019 on the host loopback. Sharing the host network is therefore what makes the no-restart hot-reload possible, and host networking is exactly the mode that shows no ports.

Docker does not reject a ports: entry alongside network_mode: host; it prints WARNING: Published ports are discarded when using host network mode and carries on, which would look like a mapping that is not there. The port is therefore set as CADPROX_PORT: "8090" in the compose file, which is where it is actually authoritative, and is left out of ports: on purpose.

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. With CADPRox_Secrets in place it is read from that file on every start; if that is removed, cadprox falls back to generating a random password on first boot and recording its scrypt hash in data/admin.json. 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
  CADPRox_Secrets      the admin password, mode 600 (git-ignored)
  data/                audit log (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%