The password now lives in CADPRox_Secrets (mode 600, git-ignored) and is
mounted read-only into the container, rather than being read from the
compose file. Two reasons:
- the password contains '#', which YAML reads as the start of a comment,
so putting it in docker-compose.yml was never going to work;
- anything in the compose file is visible in `docker compose config` and
`docker inspect`, and would be easy to commit by accident.
read_secrets_file() accepts either a bare password on one line, which is
the common case, or a key=value file so the same file can hold more than
one secret later. A bare password containing '=' is not mangled, because
the key/value parse only wins when it recognises one of its own keys.
Also detect a bind mount whose source is missing. Docker creates a
directory in that case, which would otherwise fail confusingly and
quietly leave the app with no password.
The port stays CADPROX_PORT in the compose file and is deliberately not
added to ports:. Publishing a port needs bridge networking, which cannot
reach Caddy's admin API on 127.0.0.1:2019, and Docker discards a ports:
entry under host mode with only a warning - so it would read as a mapping
that is not there. Documented in the README instead.
7.2 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
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
- 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. 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'