diff --git a/.gitignore b/.gitignore index 7f12bf4..348429e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Runtime state, the generated admin credential and the audit log. data/ +# Secrets. Never commit this. +CADPRox_Secrets +*.secrets + # OS / editor cruft .DS_Store Thumbs.db diff --git a/README.md b/README.md index b340f2d..5d96835 100644 --- a/README.md +++ b/README.md @@ -25,22 +25,55 @@ on an edit keeps the credential that is already stored. ```bash 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://: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. +The UI is then on `http://:8090`. Sign in as `admin`. -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: +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 -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 +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. @@ -70,11 +103,14 @@ 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. +**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 @@ -98,7 +134,8 @@ 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) + 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 ``` diff --git a/app.py b/app.py index 8b609db..e27eda9 100644 --- a/app.py +++ b/app.py @@ -92,6 +92,50 @@ def _scrypt(password: str, salt: bytes) -> bytes: ) +# Accepted key names in a "key=value" secrets file, in priority order. +SECRETS_KEYS = ("cadprox_admin_password", "admin_password", "password") + + +def read_secrets_file(path: str) -> str: + """Read the admin password out of a secrets file. + + Two shapes are accepted: + + * a bare password on one line, which is how a file whose only job is to + hold the password is normally written, and + * a "key=value" file, so the same file can carry more than one secret + later without needing a new mount. + + A bare password that happens to contain "=" is still handled correctly: the + key/value parse only wins when it finds a key we actually recognise, and + otherwise the file contents are returned verbatim. + """ + # A bind-mount whose source does not exist is created by Docker as a + # directory, which is a confusing way to fail. Name it explicitly. + if os.path.isdir(path): + raise ValueError( + "expected a file but found a directory - Docker creates a directory " + "for a bind mount whose source path does not exist" + ) + + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + + pairs: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + pairs[key.strip().lower()] = value.strip() + + for key in SECRETS_KEYS: + if key in pairs: + return pairs[key] + + return text.strip() + + def load_admin() -> tuple[str, str]: """Return (username, plaintext password) for the UI login. @@ -104,8 +148,7 @@ def load_admin() -> tuple[str, str]: if ADMIN_PASSWORD_FILE: try: - with open(ADMIN_PASSWORD_FILE, "r", encoding="utf-8") as fh: - password = fh.read().strip("\r\n") + password = read_secrets_file(ADMIN_PASSWORD_FILE) if not password: raise ValueError("file is empty") if len(password) < MIN_PASSWORD_LEN: diff --git a/docker-compose.yml b/docker-compose.yml index 7568395..99084cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,20 +19,31 @@ services: volumes: # The managed Caddyfile. Read-write: cadprox writes it, Caddy reads it. - /home/bdm/caddy:/caddy - # State, audit log and the generated admin credential. + # Audit log and runtime state. The generated admin credential is not + # written here any more, because the password now comes from the file + # below; this directory is kept for the audit log and for the fallback + # path if CADPROX_ADMIN_PASSWORD_FILE is ever removed. - ./data:/data + # The admin password, kept out of this file so it never appears in + # `docker inspect` output or in version control. Read-only. + - ./CADPRox_Secrets:/run/secrets/cadprox-admin:ro environment: CADPROX_CADDYFILE: /caddy/Caddyfile CADPROX_CADDY_ADMIN: http://127.0.0.1:2019 CADPROX_CADDY_BIN: caddy CADPROX_DATA: /data + # The UI port. cadprox listens on the host network, so this binds 0.0.0.0 + # of the host directly. It is set here explicitly so the value is + # visible in `docker compose config`. + # + # This deliberately does NOT appear in the PORTS column of `docker ps`: + # publishing a port requires bridge networking, and bridge networking + # cannot reach Caddy's admin API on 127.0.0.1:2019. Docker also discards + # a "ports:" entry under host mode with only a warning, so adding one + # would look like a mapping that is not there. The port is plain HTTP on + # the LAN - do not forward it to the internet. CADPROX_PORT: "8090" CADPROX_ADMIN_USER: admin - # Leave CADPROX_ADMIN_PASSWORD unset to auto-generate one on first boot. - # The generated password is written to the container log exactly once. - # CADPROX_ADMIN_PASSWORD: "change-me-at-least-8-chars" - # - # Or keep the password in a root-only file instead of the compose file, so - # it never ends up in `docker inspect` output or a committed config. - # CADPROX_ADMIN_PASSWORD_FILE: /run/secrets/cadprox-password + # Read from CADPRox_Secrets, mounted above at /run/secrets/cadprox-admin. + CADPROX_ADMIN_PASSWORD_FILE: /run/secrets/cadprox-admin diff --git a/tests/test_app.py b/tests/test_app.py index d38cde3..1be316e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -411,5 +411,71 @@ class TestSessions(unittest.TestCase): self.assertFalse(app.session_valid(token), "expired token accepted") +class TestSecretsFile(unittest.TestCase): + """The admin password may live in a file rather than the compose file. + + The file is mounted by Docker, so both the "bare password" and "key=value" + shapes have to work, and a bind mount whose source is missing has to fail + loudly rather than silently. + """ + + def setUp(self): + import tempfile + + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + import shutil + + shutil.rmtree(self.tmp, ignore_errors=True) + + def write(self, name, text): + path = os.path.join(self.tmp, name) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + return path + + def test_bare_password_with_trailing_newline(self): + path = self.write("bare", "Fake#Pass@99\n") + self.assertEqual(app.read_secrets_file(path), "Fake#Pass@99") + + def test_special_characters_survive_verbatim(self): + # '#' is literal mid-line, not a comment, and the value is not quoted + # or unescaped on the way in. These are fixtures, never real secrets. + for password in ("Fake#Pass@99", "a b c", "p@ss w#rd", " leading-ish"): + path = self.write("s", password + "\n") + self.assertEqual(app.read_secrets_file(path), password.strip()) + + def test_key_value_form(self): + path = self.write("kv", "CADPROX_ADMIN_PASSWORD=Fake#Pass@99\nOTHER=ignored\n") + self.assertEqual(app.read_secrets_file(path), "Fake#Pass@99") + + def test_key_value_form_with_comments(self): + path = self.write( + "kv", "# a leading comment\n\nCADPROX_ADMIN_PASSWORD=Fake#Pass@99\n" + ) + self.assertEqual(app.read_secrets_file(path), "Fake#Pass@99") + + def test_unrecognised_key_falls_back_to_whole_file(self): + # A bare password that happens to contain '=' must not be mangled by + # the key/value parse, because no key we recognise is present. + path = self.write("eq", "abc=def\n") + self.assertEqual(app.read_secrets_file(path), "abc=def") + + def test_empty_file_returns_empty_string(self): + # load_admin() turns this into a startup error with a clear message. + self.assertEqual(app.read_secrets_file(self.write("empty", "")), "") + + def test_directory_instead_of_file_is_named_as_such(self): + # Docker silently creates a directory when a bind source is missing. + with self.assertRaises(ValueError) as ctx: + app.read_secrets_file(self.tmp) + self.assertIn("directory", str(ctx.exception)) + + def test_missing_file_raises_oserror(self): + with self.assertRaises(OSError): + app.read_secrets_file(os.path.join(self.tmp, "nope")) + + if __name__ == "__main__": unittest.main(verbosity=2)