# 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://: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' ```