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.
179 lines
7.2 KiB
Markdown
179 lines
7.2 KiB
Markdown
# 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
|
|
```
|
|
|
|
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`:
|
|
|
|
```bash
|
|
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
|
|
|
|
```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'
|
|
```
|