Take the admin password from a secrets file instead of an env var
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.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,10 @@
|
|||||||
# Runtime state, the generated admin credential and the audit log.
|
# Runtime state, the generated admin credential and the audit log.
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Secrets. Never commit this.
|
||||||
|
CADPRox_Secrets
|
||||||
|
*.secrets
|
||||||
|
|
||||||
# OS / editor cruft
|
# OS / editor cruft
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -25,22 +25,55 @@ on an edit keeps the credential that is already stored.
|
|||||||
```bash
|
```bash
|
||||||
cd /home/bdm/cadprox
|
cd /home/bdm/cadprox
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f cadprox # first boot prints the generated password
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The UI is then on `http://<host>:8090`. Sign in as `admin` with the password
|
The UI is then on `http://<host>:8090`. Sign in as `admin`.
|
||||||
from the first boot log, or set `CADPROX_ADMIN_PASSWORD` in
|
|
||||||
`docker-compose.yml` to choose your own.
|
|
||||||
|
|
||||||
To pin the password permanently, generate one and drop it in a file that only
|
The password comes from `CADPRox_Secrets` in this directory, which is mounted
|
||||||
you can read, then reference it from the compose file:
|
read-only into the container and never written into `docker-compose.yml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
printf 'change-me-at-least-8-chars' > ~/.cadprox-password && chmod 600 ~/.cadprox-password
|
cat CADPRox_Secrets # the admin password, one line, mode 600
|
||||||
# then in docker-compose.yml:
|
|
||||||
# CADPROX_ADMIN_PASSWORD_FILE: /run/secrets/cadprox-password
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## How a change is applied
|
||||||
|
|
||||||
1. The current Caddyfile is parsed into structured entries.
|
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
|
**Changes are validated, then rolled back.** Nothing is written until Caddy has
|
||||||
accepted the rendered text, and a failed reload restores the previous file.
|
accepted the rendered text, and a failed reload restores the previous file.
|
||||||
|
|
||||||
**Credentials.** The admin password is compared with `hmac.compare_digest` and
|
**Credentials.** The admin password is compared with `hmac.compare_digest`. With
|
||||||
stored as a scrypt hash. A session is a random token in an `HttpOnly`,
|
`CADPRox_Secrets` in place it is read from that file on every start; if that is
|
||||||
`SameSite=Strict` cookie. Mutating requests also require an `X-Cadprox` header,
|
removed, cadprox falls back to generating a random password on first boot and
|
||||||
which blocks cross-origin form posts. The Basic Auth password for a subdomain is
|
recording its scrypt hash in `data/admin.json`. A session is a random token in
|
||||||
hashed by Caddy itself and the hash is never returned to the browser.
|
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
|
**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
|
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
|
app.py the whole application, standard library only
|
||||||
Dockerfile caddy:2.11.4 + python3
|
Dockerfile caddy:2.11.4 + python3
|
||||||
docker-compose.yml host networking so the admin API on 2019 is reachable
|
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/test_app.py unit tests, including Caddy-backed parse/render tests
|
||||||
tests/e2e.sh end-to-end test against a throwaway Caddy
|
tests/e2e.sh end-to-end test against a throwaway Caddy
|
||||||
```
|
```
|
||||||
|
|||||||
47
app.py
47
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]:
|
def load_admin() -> tuple[str, str]:
|
||||||
"""Return (username, plaintext password) for the UI login.
|
"""Return (username, plaintext password) for the UI login.
|
||||||
|
|
||||||
@@ -104,8 +148,7 @@ def load_admin() -> tuple[str, str]:
|
|||||||
|
|
||||||
if ADMIN_PASSWORD_FILE:
|
if ADMIN_PASSWORD_FILE:
|
||||||
try:
|
try:
|
||||||
with open(ADMIN_PASSWORD_FILE, "r", encoding="utf-8") as fh:
|
password = read_secrets_file(ADMIN_PASSWORD_FILE)
|
||||||
password = fh.read().strip("\r\n")
|
|
||||||
if not password:
|
if not password:
|
||||||
raise ValueError("file is empty")
|
raise ValueError("file is empty")
|
||||||
if len(password) < MIN_PASSWORD_LEN:
|
if len(password) < MIN_PASSWORD_LEN:
|
||||||
|
|||||||
@@ -19,20 +19,31 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# The managed Caddyfile. Read-write: cadprox writes it, Caddy reads it.
|
# The managed Caddyfile. Read-write: cadprox writes it, Caddy reads it.
|
||||||
- /home/bdm/caddy:/caddy
|
- /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
|
- ./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:
|
environment:
|
||||||
CADPROX_CADDYFILE: /caddy/Caddyfile
|
CADPROX_CADDYFILE: /caddy/Caddyfile
|
||||||
CADPROX_CADDY_ADMIN: http://127.0.0.1:2019
|
CADPROX_CADDY_ADMIN: http://127.0.0.1:2019
|
||||||
CADPROX_CADDY_BIN: caddy
|
CADPROX_CADDY_BIN: caddy
|
||||||
CADPROX_DATA: /data
|
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_PORT: "8090"
|
||||||
CADPROX_ADMIN_USER: admin
|
CADPROX_ADMIN_USER: admin
|
||||||
# Leave CADPROX_ADMIN_PASSWORD unset to auto-generate one on first boot.
|
# Read from CADPRox_Secrets, mounted above at /run/secrets/cadprox-admin.
|
||||||
# The generated password is written to the container log exactly once.
|
CADPROX_ADMIN_PASSWORD_FILE: /run/secrets/cadprox-admin
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -411,5 +411,71 @@ class TestSessions(unittest.TestCase):
|
|||||||
self.assertFalse(app.session_valid(token), "expired token accepted")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
Reference in New Issue
Block a user