commit 6d94fc5eb132038bd5924ba2a738b5e8137478ea Author: bdm Date: Mon Sep 28 07:54:14 2026 -0700 Add cadprox: web UI for the Caddy reverse proxy config Manages one Caddyfile entry per subdomain (URL -> IP:Port, plus optional HTTP Basic Auth) and applies changes with a hot-reload through the Caddy admin API, so an edit is live in ~0.3s with no restart and no certificate churn. Safety properties: - every change is validated by the real Caddy binary before it is written - a rejected reload restores the previous Caddyfile - site blocks containing directives cadprox does not model are preserved byte-for-byte, shown read-only, and never rewritten - the Caddyfile is the only source of state, so a hand-edited file cannot silently diverge from what the UI shows - password hashes never leave the server; the session cookie is HttpOnly and SameSite=Strict, and mutating requests need a custom header app.py is a single standard-library file. The image is built on caddy:2.11.4 so validation, adaptation and hashing use the same binary that serves traffic, which is why basic-auth hashes are bcrypt: Caddy's argon2id hashes load fine but its basic-auth provider only verifies bcrypt. Tests: 28 unit tests (Caddy-backed ones skip without the binary) and a 32 assertion end-to-end script that runs against a throwaway Caddy. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f12bf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Runtime state, the generated admin credential and the audit log. +data/ + +# OS / editor cruft +.DS_Store +Thumbs.db +*.swp +*~ + +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e1cbb5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# cadprox reuses the official Caddy image so that validation, config adaptation +# and password hashing are performed by the exact same binary that will serve +# the traffic. A different Caddy build could accept a config the live instance +# then rejects. +FROM caddy:2.11.4 + +# The caddy image ships no Python, and app.py is standard library only. +# The base is Alpine, so it is apk rather than apt. +RUN apk add --no-cache python3 + +WORKDIR /app +COPY app.py /app/app.py + +ENV CADPROX_CADDYFILE=/caddy/Caddyfile \ + CADPROX_CADDY_ADMIN=http://127.0.0.1:2019 \ + CADPROX_DATA=/data \ + CADPROX_PORT=8090 \ + PYTHONUNBUFFERED=1 + +EXPOSE 8090 +VOLUME ["/data"] + +ENTRYPOINT ["python3", "-u", "/app/app.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b340f2d --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# 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 +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. + +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: + +```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 +``` + +## 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` 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. + +**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 + data/ audit log and the generated admin credential (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' +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..8b609db --- /dev/null +++ b/app.py @@ -0,0 +1,1165 @@ +"""cadprox - web UI for managing the Caddy reverse proxy configuration. + +Single-file, standard-library-only HTTP server. It reads and writes the Caddyfile +on disk, then pushes the result to the running Caddy instance through the admin +API on 127.0.0.1:2019, so changes are live in milliseconds with no restart and +without disturbing existing TLS certificates or in-flight connections. + +Safety properties that matter more than features here: + + * Every change is validated with the same Caddy binary before it is written. + * The previous Caddyfile is restored if the live reload is rejected. + * A site block containing directives cadprox does not model is preserved + byte-for-byte and marked read-only in the UI, never silently rewritten. + * Password hashes are never returned to the browser. + +Only the Python standard library is used, and the Caddy binary from the official +image is reused for validation, adaptation and password hashing. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +import subprocess +import threading +import time +import urllib.error +import urllib.request +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +log = logging.getLogger("cadprox") + +# ----------------------------------------------------------------- configuration + +CADDYFILE = os.environ.get("CADPROX_CADDYFILE", "/caddy/Caddyfile") +CADDY_ADMIN = os.environ.get("CADPROX_CADDY_ADMIN", "http://127.0.0.1:2019").rstrip("/") +CADDY_BIN = os.environ.get("CADPROX_CADDY_BIN", "caddy") +DATA_DIR = os.environ.get("CADPROX_DATA", "/data") +AUDIT_FILE = os.path.join(DATA_DIR, "audit.log") +CREDS_FILE = os.path.join(DATA_DIR, "admin.json") +PORT = int(os.environ.get("CADPROX_PORT", "8090")) +ADMIN_USER = os.environ.get("CADPROX_ADMIN_USER", "admin") +ADMIN_PASSWORD = os.environ.get("CADPROX_ADMIN_PASSWORD", "") +ADMIN_PASSWORD_FILE = os.environ.get("CADPROX_ADMIN_PASSWORD_FILE", "") +SESSION_TTL = int(os.environ.get("CADPROX_SESSION_TTL", "43200")) # 12h +MIN_PASSWORD_LEN = 8 + +MANAGED_MARKER = "# Managed by cadprox" + +HEADER = f"""\ +{MANAGED_MARKER} - Caddy reverse proxy configuration +# +# Site blocks below are managed by cadprox and will be rewritten when you save +# a change in its web UI. Blocks that cadprox does not recognise are preserved +# verbatim at the end of this file. +""" + +# Directives cadprox understands inside a site block. Anything else makes the +# block read-only so that a save can never discard it. +REVERSE_PROXY_RE = re.compile(r"^reverse_proxy\s+(\S+)\s*$") +BASIC_AUTH_OPEN_RE = re.compile(r"^basic_auth\s*\{?\s*$") +BASIC_AUTH_USER_RE = re.compile(r"^([A-Za-z0-9._@+-]{1,64})\s+(\S+)\s*$") +# Caddy treats '#' as a comment only when it follows whitespace. +TRAILING_COMMENT_RE = re.compile(r"\s+#.*$") +KNOWN_DIRECTIVES = ("encode gzip", "encode", "reverse_proxy", "basic_auth", "basicauth") + +# Reentrant: upsert() and delete() hold the lock while calling apply_state(), +# which acquires it again. A plain Lock would deadlock on the very first save. +_lock = threading.RLock() +_sessions: dict[str, float] = {} + + +class ValidationError(ValueError): + """User-correctable problem. The message is safe to show in the browser.""" + + +# ----------------------------------------------------------------- credentials + + +def _scrypt(password: str, salt: bytes) -> bytes: + return hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=32 + ) + + +def load_admin() -> tuple[str, str]: + """Return (username, plaintext password) for the UI login. + + Order of preference: an explicit env var, then a password file, then a + persisted credentials file created on first boot with a random password + that is written to the log exactly once. + """ + if ADMIN_PASSWORD: + return ADMIN_USER, ADMIN_PASSWORD + + if ADMIN_PASSWORD_FILE: + try: + with open(ADMIN_PASSWORD_FILE, "r", encoding="utf-8") as fh: + password = fh.read().strip("\r\n") + if not password: + raise ValueError("file is empty") + if len(password) < MIN_PASSWORD_LEN: + raise ValueError( + f"password must be at least {MIN_PASSWORD_LEN} characters" + ) + return ADMIN_USER, password + except (OSError, ValueError) as exc: + log.error("cannot use %s (%s); refusing to start", ADMIN_PASSWORD_FILE, exc) + raise SystemExit(1) from None + + try: + with open(CREDS_FILE, "r", encoding="utf-8") as fh: + stored = json.load(fh) + salt = base64.b64decode(stored["salt"]) + if hmac.compare_digest( + _scrypt(stored["password"], salt), base64.b64decode(stored["hash"]) + ): + return stored["user"], stored["password"] + except FileNotFoundError: + pass + except (OSError, ValueError, KeyError) as exc: + log.warning("credentials file unusable (%s), regenerating", exc) + + password = secrets.token_urlsafe(18) + salt = os.urandom(16) + payload = { + "user": ADMIN_USER, + "salt": base64.b64encode(salt).decode(), + "hash": base64.b64encode(_scrypt(password, salt)).decode(), + "password": password, + "created": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + os.makedirs(DATA_DIR, exist_ok=True) + fd = os.open(CREDS_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + log.warning("=" * 72) + log.warning("cadprox initial login: user=%s password=%s", ADMIN_USER, password) + log.warning("Change it with: docker exec cadprox env CADPROX_ADMIN_PASSWORD=... ") + log.warning("=" * 72) + return ADMIN_USER, password + + +# ------------------------------------------------------------------ validation + +HOSTNAME_RE = re.compile( + r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(? str: + host = (raw or "").strip().lower().rstrip(".") + if not host: + raise ValidationError("URL is required.") + if "://" in host or "/" in host or ":" in host or " " in host: + raise ValidationError( + "Enter a bare hostname such as app.bdmurphy.me - no scheme, port or path." + ) + if not (IPV4_RE.match(host) or HOSTNAME_RE.match(host)): + raise ValidationError(f"'{host}' is not a valid hostname or IPv4 address.") + return host + + +def validate_target(raw: str) -> str: + target = (raw or "").strip() + if not target: + raise ValidationError("Proxy target is required.") + if "://" in target or "/" in target: + raise ValidationError( + "Enter the target as IP:Port only, e.g. 192.168.123.14:8425 - not a URL." + ) + if target.count(":") != 1: + raise ValidationError("Target must be exactly IP:Port.") + host, _, port = target.partition(":") + if not (IPV4_RE.match(host) or HOSTNAME_RE.match(host)): + raise ValidationError(f"'{host}' is not a valid IP address or hostname.") + if not port.isdigit() or not 1 <= int(port) <= 65535: + raise ValidationError(f"'{port}' is not a valid port (1-65535).") + return f"{host}:{port}" + + +def validate_username(raw: str) -> str: + user = (raw or "").strip() + if not user: + raise ValidationError("Username is required when Basic Auth is enabled.") + if not re.fullmatch(r"[A-Za-z0-9._@+-]{1,64}", user): + raise ValidationError( + "Username may contain letters, digits and . _ @ + - only (max 64)." + ) + return user + + +def validate_password(raw: str) -> str: + if not raw: + raise ValidationError("Password is required when Basic Auth is enabled.") + if len(raw) < MIN_PASSWORD_LEN: + raise ValidationError(f"Password must be at least {MIN_PASSWORD_LEN} characters.") + if len(raw) > 256: + raise ValidationError("Password must be at most 256 characters.") + return raw + + +# ------------------------------------------------------------- caddy invocation + + +def caddy_run(args: list[str], stdin: str | None = None) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [CADDY_BIN, *args], input=stdin, capture_output=True, text=True, timeout=60 + ) + return proc.returncode, proc.stdout, proc.stderr + except subprocess.TimeoutExpired: + raise ValidationError("Caddy timed out while processing the request.") from None + except FileNotFoundError: + raise ValidationError( + f"Caddy binary '{CADDY_BIN}' not found inside the cadprox container." + ) from None + + +def caddy_error_detail(stderr: str) -> str: + lines = [ln for ln in stderr.splitlines() if ln.strip()] + errors = [ln for ln in lines if '"level":"error"' in ln] + chosen = errors[-1] if errors else (lines[-1] if lines else "unknown error") + match = re.search(r'"msg":"(.*?)"', chosen) + if match: + return match.group(1)[:300] + return chosen[:300] + + +def hash_password(password: str) -> str: + """Hash a password for basic_auth using Caddy itself. + + bcrypt is used deliberately, and this is not a default. Caddy's + `hash-password` can emit argon2id, but the HTTP basic auth provider in + Caddy 2.11 only verifies bcrypt: an argon2id hash is accepted at config + load and then rejects every login, including the correct password. bcrypt + at cost 14 is also Caddy's own default for basic_auth. + """ + code, out, err = caddy_run( + ["hash-password", "--plaintext", password, "--algorithm", "bcrypt"] + ) + if code != 0: + raise ValidationError(f"Could not hash the password: {caddy_error_detail(err)}") + hashed = out.strip() + if not hashed.startswith("$2"): + raise ValidationError("Unexpected bcrypt hash format from caddy hash-password.") + return hashed + + +def validate_caddyfile(config: str) -> tuple[bool, str]: + # --adapter must be explicit: with --config - Caddy defaults to the JSON + # adapter and rejects a Caddyfile as "not valid JSON". + code, _, err = caddy_run( + ["validate", "--adapter", "caddyfile", "--config", "-"], stdin=config + ) + if code == 0: + return True, "valid" + return False, caddy_error_detail(err) + + +def caddyfile_to_json(config: str) -> dict: + code, out, err = caddy_run( + ["adapt", "--adapter", "caddyfile", "--config", "-"], stdin=config + ) + if code != 0: + raise ValidationError(f"Caddy could not parse the config: {caddy_error_detail(err)}") + try: + return json.loads(out) + except json.JSONDecodeError as exc: + raise ValidationError(f"Caddy returned malformed JSON: {exc}") from None + + +# ------------------------------------------------------------- Caddyfile parse + +def _split_blocks(lines: list[str]) -> tuple[list[dict], list[str], list[str]]: + """Split a Caddyfile into site blocks, its global options, and leftovers. + + The global options block is tracked separately because Caddy requires it to + be the first thing in the file. Everything else cadprox does not model + (imports, single-line sites) is kept verbatim so a save can reproduce it + exactly instead of discarding it. + """ + entries: list[dict] = [] + globals_: list[str] = [] + extras: list[str] = [] + total = len(lines) + idx = 0 + + # Find an optional global options block. Leading blank lines and comments are + # skipped, because the file legitimately starts with a comment header and + # must not stop the global block from being recognised. + lead: list[str] = [] + while idx < total: + stripped = lines[idx].strip() + if not stripped or stripped.startswith("#"): + lead.append(lines[idx]) + idx += 1 + continue + break + if idx < total and lines[idx].strip() == "{": + start, depth = idx, 0 + while idx < total: + depth += lines[idx].count("{") - lines[idx].count("}") + idx += 1 + if depth == 0: + break + globals_ = lead + lines[start:idx] + else: + # No global block, so the leading comments belong to nobody. Keep them + # rather than dropping text the operator wrote. + extras.extend(lead) + + while idx < total: + raw = lines[idx] + stripped = raw.strip() + + if not stripped or stripped.startswith("#"): + if not raw[:1].isspace() and not stripped: + extras.append(raw) + idx += 1 + continue + + if not raw[:1].isspace(): + if not stripped.endswith("{"): + # `import foo` or a single-line site: keep verbatim, it is not ours. + extras.append(raw) + idx += 1 + continue + start = idx + depth = 0 + while idx < total: + depth += lines[idx].count("{") - lines[idx].count("}") + idx += 1 + if depth == 0: + break + entries.append({"lines": lines[start:idx]}) + else: + # Indented but outside any block: an orphan we must not drop. + extras.append(raw) + idx += 1 + + return entries, globals_, extras + + +def parse_block(block_lines: list[str]) -> dict: + """Interpret one site block. Unknown directives mark it read-only.""" + header = block_lines[0].strip().rstrip("{").strip() + hosts = [h.strip() for h in header.split(",") if h.strip()] + + target: str | None = None + encode = False + users: list[str] = [] + hashes: list[str] = [] + unknown: list[str] = [] + + idx = 1 + limit = len(block_lines) - 1 # exclude the closing brace + while idx < limit: + body = TRAILING_COMMENT_RE.sub("", block_lines[idx].strip()) + if not body or body.startswith("#"): + idx += 1 + continue + + proxy = REVERSE_PROXY_RE.match(body) + if proxy: + target = proxy.group(1) + idx += 1 + continue + + if body in ("encode gzip", "encode zstd gzip"): + encode = "gzip" in body + idx += 1 + continue + + if BASIC_AUTH_OPEN_RE.match(body): + idx += 1 + while idx < limit: + inner = TRAILING_COMMENT_RE.sub("", block_lines[idx].strip()) + if inner == "}": + idx += 1 + break + if inner and not inner.startswith("#"): + user = BASIC_AUTH_USER_RE.match(inner) + if user: + users.append(user.group(1)) + hashes.append(user.group(2)) + else: + unknown.append(inner) + idx += 1 + continue + + if body.split()[0] in KNOWN_DIRECTIVES: + unknown.append(body) + else: + unknown.append(body) + idx += 1 + + # Multiple upstreams, or a body we only partly understand, is not editable. + if target is None or unknown: + return { + "hosts": hosts, + "target": target, + "encode": encode, + "auth": {"users": users, "hashes": hashes} if users else None, + "managed": False, + "reason": ( + f"contains directives cadprox does not model: {'; '.join(unknown)}" + if unknown + else "no reverse_proxy target found" + ), + "lines": block_lines, + } + + return { + "hosts": hosts, + "target": target, + "encode": encode, + "auth": {"users": users, "hashes": hashes} if users else None, + "managed": True, + "reason": "", + "lines": block_lines, + } + + +def strip_own_header(lines: list[str]) -> list[str]: + """Drop a leading comment run that cadprox itself wrote. + + The header is regenerated on every save. If it were also treated as + preserved leading text it would be absorbed into the parsed state, emitted + again on the next save, and the file would grow by one header per change + forever. A leading comment run is only removed when it carries cadprox's own + marker, so comments the operator wrote are still preserved. + """ + idx = 0 + seen_marker = False + while idx < len(lines): + stripped = lines[idx].strip() + if not stripped or stripped.startswith("#"): + if stripped.startswith(MANAGED_MARKER): + seen_marker = True + idx += 1 + continue + break + return lines[idx:] if seen_marker else lines + + +def parse_caddyfile(text: str) -> dict: + lines = strip_own_header(text.splitlines()) + blocks, globals_, extras = _split_blocks(lines) + entries = [] + for block in blocks: + parsed = parse_block(block["lines"]) + # A block with no site address is a keyless/global block, never a host. + # It is preserved rather than listed, so it can never surface in the UI + # as an entry with a blank URL. + if not parsed["hosts"]: + extras.extend(block["lines"]) + extras.append("") + continue + entries.append(parsed) + return { + "entries": entries, + "globals": "\n".join(globals_).strip("\n"), + "extras": "\n".join(extras).strip("\n"), + } + + +def render_block(entry: dict) -> str: + lines = ["%s {" % ", ".join(entry["hosts"])] + if entry.get("encode", True): + lines.append("\tencode gzip") + auth = entry.get("auth") + if auth and auth.get("users"): + lines.append("\tbasic_auth {") + for user, hashed in zip(auth["users"], auth["hashes"]): + lines.append("\t\t%s %s" % (user, hashed)) + lines.append("\t}") + lines.append("\treverse_proxy %s" % entry["target"]) + lines.append("}") + return "\n".join(lines) + + +def render_caddyfile(state: dict) -> str: + parts = [HEADER.rstrip(), ""] + + # Caddy requires the global options block to come first, so it is emitted + # ahead of the site blocks rather than alongside the other preserved text. + globals_ = (state.get("globals") or "").strip("\n") + if globals_: + parts.append(globals_) + parts.append("") + + for entry in state["entries"]: + if entry.get("managed"): + parts.append(render_block(entry)) + else: + parts.append("\n".join(entry["lines"])) # preserved byte-for-byte + parts.append("") + + extras = (state.get("extras") or "").strip("\n") + if extras: + parts.append("# --- preserved verbatim (not managed by cadprox) ---") + parts.append(extras) + parts.append("") + + return "\n".join(parts).rstrip() + "\n" + + +# ------------------------------------------------------------------ persistence + + +def read_caddyfile() -> str: + with open(CADDYFILE, "r", encoding="utf-8") as fh: + return fh.read() + + +def write_atomic(path: str, content: str) -> None: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(content) + os.replace(tmp, path) + + +def load_state() -> dict: + """The Caddyfile on disk is the single source of truth. + + Nothing is cached separately, and that is deliberate. Targets, the encode + setting and the basic_auth hashes are all written into the Caddyfile, so + re-parsing it recovers the complete state including credential hashes. + Keeping a second copy in state.json would mean a hand-edited Caddyfile + silently diverged from what the UI displays, and the next save would + overwrite the operator's edit with stale data. + """ + return parse_caddyfile(read_caddyfile()) + + +def public_state(state: dict) -> dict: + """Strip password hashes before anything leaves the server.""" + entries = [] + for entry in state["entries"]: + auth = entry.get("auth") + entries.append( + { + "url": ", ".join(entry["hosts"]), + "target": entry.get("target") or "", + "encode": bool(entry.get("encode", True)), + "auth_user": auth["users"][0] if auth and auth.get("users") else "", + "auth_count": len(auth["users"]) if auth else 0, + "managed": bool(entry.get("managed")), + "reason": entry.get("reason", ""), + } + ) + return { + "entries": entries, + "preserved": bool((state.get("extras") or "").strip()) + or bool((state.get("globals") or "").strip()), + } + + +# ----------------------------------------------------------------------- reload + + +def admin_reachable() -> bool: + try: + with urllib.request.urlopen(f"{CADDY_ADMIN}/config/", timeout=5): + return True + except urllib.error.HTTPError: + return True + except Exception: # noqa: BLE001 + return False + + +def reload_caddy(config: str) -> tuple[bool, str]: + """Push the config to the live Caddy instance over the admin API.""" + payload = caddyfile_to_json(config) + apps = payload.setdefault("apps", {}) + + # The admin endpoint is itself part of the config. Replacing the config with + # one that omits `apps.admin` would shut the endpoint down and leave this + # tool with no way to roll back, so carry the live admin app across. + try: + with urllib.request.urlopen(f"{CADDY_ADMIN}/config/apps/admin", timeout=5) as resp: + live_admin = json.loads(resp.read().decode() or "null") + if live_admin: + apps["admin"] = live_admin + except urllib.error.HTTPError: + pass + except Exception as exc: # noqa: BLE001 + return False, f"Could not read the live admin config, refusing to reload: {exc}" + + request = urllib.request.Request( + f"{CADDY_ADMIN}/load", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as resp: + resp.read() + return True, "Reloaded via the admin API; no restart, certificates untouched." + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace") + match = re.search(r'"msg":"(.*?)"', detail) + return False, f"Caddy rejected the config (HTTP {exc.code}): {match.group(1) if match else detail[:200]}" + except Exception as exc: # noqa: BLE001 + return False, f"Could not reach the Caddy admin API at {CADDY_ADMIN}: {exc}" + + +# ----------------------------------------------------------------------- apply + + +def audit(action: str, detail: str) -> None: + try: + os.makedirs(DATA_DIR, exist_ok=True) + with open(AUDIT_FILE, "a", encoding="utf-8") as fh: + fh.write("%s %-9s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S"), action, detail)) + except OSError: + pass + + +def apply_state(state: dict, action: str, detail: str) -> dict: + """Render, validate, write, hot-reload. Restores the previous file on failure.""" + with _lock: + config = render_caddyfile(state) + ok, message = validate_caddyfile(config) + if not ok: + audit("REJECTED", f"{action} {detail}: {message}") + return {"ok": False, "stage": "validate", "error": f"Caddy rejected it: {message}"} + + previous_config = read_caddyfile() + + write_atomic(CADDYFILE, config) + + applied, reload_msg = reload_caddy(config) + if not applied: + write_atomic(CADDYFILE, previous_config) + audit("ROLLBACK", f"{action} {detail}: {reload_msg}") + return {"ok": False, "stage": "reload", "error": reload_msg} + + audit("APPLIED", f"{action} {detail}") + return {"ok": True, "message": reload_msg} + + +def upsert(url: str, target: str, auth_spec: dict | None) -> dict: + with _lock: + state = load_state() + match = next((e for e in state["entries"] if e["hosts"] == [url]), None) + + if match and not match.get("managed"): + raise ValidationError( + f"{url} is preserved verbatim ({match['reason']}) and cannot be edited here." + ) + + auth = None + if auth_spec: + username = validate_username(auth_spec.get("username", "")) + password = auth_spec.get("password") + existing = (match or {}).get("auth") or None + if password: + auth = {"users": [username], "hashes": [hash_password(password)]} + elif existing and existing["users"] == [username]: + auth = existing # unchanged credential, hash carried over + else: + raise ValidationError("A password is required for a new basic-auth user.") + + if match: + match["target"] = target + match["auth"] = auth + action = "UPDATE" + else: + state["entries"].append( + { + "hosts": [url], + "target": target, + "encode": True, + "auth": auth, + "managed": True, + "reason": "", + "lines": [], + } + ) + action = "ADD" + + return apply_state(state, action, f"{url} -> {target}") + + +def delete(url: str) -> dict: + with _lock: + state = load_state() + if not any(e["hosts"] == [url] for e in state["entries"]): + raise ValidationError(f"{url} is not configured.") + state["entries"] = [e for e in state["entries"] if e["hosts"] != [url]] + return apply_state(state, "DELETE", url) + + +# -------------------------------------------------------------------- sessions + + +def new_session() -> str: + token = secrets.token_urlsafe(32) + _sessions[token] = time.time() + SESSION_TTL + return token + + +def session_valid(token: str) -> bool: + if not token: + return False + expiry = _sessions.get(token) + if not expiry: + return False + if expiry < time.time(): + _sessions.pop(token, None) + return False + return True + + +# ------------------------------------------------------------------------ HTML + +PAGE = """ + + +cadprox + + +
+

cadprox

+checking +verbatim block + + +
+ +
+
+ +
+

Sign in

+
+
+
+
+
+
+
+
The generated password is in the cadprox container log on first boot.
+
+ +
+
+

Add or update a reverse proxy

+
+
+
+
+
+
+
+
+
Saving an existing URL updates its target. A Let's Encrypt +certificate is issued automatically the first time a hostname goes live.
+
+ + +
+
+ +
+

Configured proxies

+ +
URLTargetAuth
+
+
+
+ +""" + + +# ----------------------------------------------------------------- HTTP handler + + +class Handler(BaseHTTPRequestHandler): + server_version = "cadprox/1.0" + protocol_version = "HTTP/1.1" + username, password = "admin", "" + + def log_message(self, fmt, *args): + log.info("%s %s", self.address_string(), fmt % args) + + # ------------------------------------------------------------- utilities + + def _session(self) -> str: + raw = self.headers.get("Cookie", "") + if not raw: + return "" + jar = SimpleCookie() + try: + jar.load(raw) + except Exception: # noqa: BLE001 + return "" + morsel = jar.get("cadprox_session") + return morsel.value if morsel else "" + + def _authed(self) -> bool: + if session_valid(self._session()): + return True + header = self.headers.get("X-API-Token", "") + return bool(header) and hmac.compare_digest(header, self.password) + + def _drain(self) -> None: + """Discard an unread request body so keep-alive stays in sync.""" + try: + remaining = int(self.headers.get("Content-Length", 0)) + except ValueError: + return + while remaining > 0: + chunk = self.rfile.read(min(remaining, 65536)) + if not chunk: + return + remaining -= len(chunk) + + def _json(self, payload: dict, status: int = HTTPStatus.OK, cookie: str | None = None): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + if cookie: + self.send_header("Set-Cookie", cookie) + self.end_headers() + self.wfile.write(body) + + def _html(self, page: str, status: int = HTTPStatus.OK): + body = page.encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'") + self.end_headers() + self.wfile.write(body) + + def _body(self) -> dict: + try: + length = int(self.headers.get("Content-Length", 0)) + except ValueError: + raise ValidationError("Invalid Content-Length.") from None + if length <= 0: + return {} + if length > 64 * 1024: + raise ValidationError("Request body too large.") + try: + data = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, UnicodeDecodeError): + raise ValidationError("Invalid JSON in the request body.") from None + if not isinstance(data, dict): + raise ValidationError("Expected a JSON object.") + return data + + def _cookie_header(self, token: str, clear: bool = False) -> str: + if clear: + return "cadprox_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0" + return f"cadprox_session={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_TTL}" + + # ----------------------------------------------------------------- verbs + + def do_GET(self): + path = urlparse(self.path).path + if path in ("/", "/index.html"): + return self._html(PAGE) + if path == "/api/entries": + if not self._authed(): + self._drain() + return self._json({"error": "Sign in required."}, HTTPStatus.UNAUTHORIZED) + try: + return self._json({**public_state(load_state()), + "caddy_admin_reachable": admin_reachable()}) + except Exception as exc: # noqa: BLE001 + log.exception("entries failed") + return self._json({"error": f"Could not read the Caddyfile: {exc}"}, + HTTPStatus.INTERNAL_SERVER_ERROR) + return self._json({"error": "Not found."}, HTTPStatus.NOT_FOUND) + + def do_POST(self): + path = urlparse(self.path).path + + # Read the body before any early return. Under HTTP/1.1 keep-alive an + # unread body stays in the socket and is then parsed as the next + # request, which desynchronises the connection and hangs the client. + try: + body = self._body() + except ValidationError as exc: + return self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + if path == "/api/login": + return self._login(body) + + if not self._authed(): + return self._json({"error": "Sign in required."}, HTTPStatus.UNAUTHORIZED) + # Custom header requirement blocks cross-origin form posts (CSRF). + if self.headers.get("X-Cadprox") != "1": + return self._json({"error": "Missing X-Cadprox header."}, + HTTPStatus.FORBIDDEN) + + try: + if path == "/api/logout": + token = self._session() + _sessions.pop(token, None) + return self._json({"ok": True, "message": "Signed out."}, + cookie=self._cookie_header("", clear=True)) + if path == "/api/entries": + return self._json(self._upsert(body)) + if path == "/api/delete": + return self._json(self._delete(body)) + except ValidationError as exc: + return self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + except Exception as exc: # noqa: BLE001 + log.exception("request failed") + return self._json({"error": f"Unexpected error: {exc}"}, + HTTPStatus.INTERNAL_SERVER_ERROR) + return self._json({"error": "Not found."}, HTTPStatus.NOT_FOUND) + + # ------------------------------------------------------------ endpoints + + def _login(self, body: dict): + username = str(body.get("username", "")) + supplied = str(body.get("password", "")) + ok = hmac.compare_digest(username, self.username) and hmac.compare_digest( + supplied, self.password + ) + if not ok: + audit("LOGIN-FAIL", f"user={username!r} from {self.address_string()}") + return self._json({"error": "Incorrect username or password."}, + HTTPStatus.UNAUTHORIZED) + audit("LOGIN", f"user={username!r} from {self.address_string()}") + return self._json({"ok": True, "message": "Signed in."}, + cookie=self._cookie_header(new_session())) + + def _upsert(self, body: dict) -> dict: + url = validate_host(body.get("url", "")) + target = validate_target(body.get("target", "")) + result = upsert(url, target, body.get("auth")) + if not result["ok"]: + return {"error": result["error"]} + return {"ok": True, "message": f"Saved {url} -> {target}. {result['message']}"} + + def _delete(self, body: dict) -> dict: + url = validate_host(body.get("url", "")) + result = delete(url) + if not result["ok"]: + return {"error": result["error"]} + return {"ok": True, "message": f"Removed {url}. {result['message']}"} + + +def main(): + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" + ) + os.makedirs(DATA_DIR, exist_ok=True) + Handler.username, Handler.password = load_admin() + + if not os.path.exists(CADDYFILE): + log.error("Caddyfile not found at %s - mount the host Caddyfile here", CADDYFILE) + log.info("caddyfile=%s admin=%s port=%d", CADDYFILE, CADDY_ADMIN, PORT) + log.info("caddy admin reachable: %s", admin_reachable()) + + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + server.daemon_threads = True + log.info("cadprox listening on 0.0.0.0:%d", PORT) + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + finally: + log.info("cadprox stopped") + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7568395 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +# cadprox - web UI for the Caddy reverse proxy configuration +# +# network_mode: host is required, not stylistic. The Caddy admin API listens on +# 127.0.0.1:2019 on the host loopback and Caddy itself is in host mode, so this +# is the only way cadprox can reach it to hot-reload without restarting Caddy. +# +# The Caddyfile is mounted read-write because cadprox is the writer. Caddy keeps +# its own read-only mount, so a cadprox edit does not disturb the running +# container until the admin API accepts the new config. + +services: + cadprox: + build: . + image: cadprox:1.0 + container_name: cadprox + restart: unless-stopped + network_mode: host + + volumes: + # The managed Caddyfile. Read-write: cadprox writes it, Caddy reads it. + - /home/bdm/caddy:/caddy + # State, audit log and the generated admin credential. + - ./data:/data + + environment: + CADPROX_CADDYFILE: /caddy/Caddyfile + CADPROX_CADDY_ADMIN: http://127.0.0.1:2019 + CADPROX_CADDY_BIN: caddy + CADPROX_DATA: /data + 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 diff --git a/tests/e2e.sh b/tests/e2e.sh new file mode 100755 index 0000000..2566f55 --- /dev/null +++ b/tests/e2e.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# End-to-end test for cadprox against a throwaway Caddy. Production is never touched. +set -u +B=http://127.0.0.1:8091 +PROXY_PORT=8099 +J=/tmp/opencode/e2e/jar +PASS=0; FAIL=0 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; } + +ups(){ curl -s -m 20 -b $J -X POST $B/api/entries -H 'Content-Type: application/json' -H 'X-Cadprox: 1' -d "$1"; } +del(){ curl -s -m 20 -b $J -X POST $B/api/delete -H 'Content-Type: application/json' -H 'X-Cadprox: 1' -d "$1"; } +# Issue a request through the throwaway Caddy, using SNI so the right site block +# is selected. -k accepts Caddy's internal-CA certificate. A brand new hostname +# needs Caddy a moment to provision its certificate, so retry briefly. +req(){ local h="$1"; shift + for _ in $(seq 1 20); do + c=$(curl -sk -o /dev/null -w '%{http_code}' -m 20 \ + --resolve "$h:$PROXY_PORT:127.0.0.1" "$@" "https://$h:$PROXY_PORT/") + # 000 means the TLS handshake failed, which is what an unprovisioned + # certificate looks like. Any real HTTP status is a real answer. + [ "$c" != "000" ] && { echo "$c"; return; } + sleep 1 + done + echo "000"; } +err(){ ups "$1" | python3 -c "import json,sys;print(json.load(sys.stdin).get('error',''))"; } +login(){ rm -f $J + curl -s -c $J -X POST $B/api/login -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"e2etestpassword"}' >/dev/null; } +# Block until the throwaway Caddy admin API answers, so a restart is never +# raced by a reload request. +wait_admin(){ for _ in $(seq 1 30); do + [ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 http://127.0.0.1:2020/config/)" = "200" ] && return + sleep 1 + done + echo " WARNING: caddy admin API never became ready" >&2; } + +echo "=== reset ===" +docker rm -f caddy-e2e backend-e2e cadprox-e2e >/dev/null 2>&1 +# cadprox runs as root and owns the files it writes, so the directory has to be +# cleared from a container too. A plain rm leaves root-owned files behind and the +# next run silently inherits stale state. +docker run --rm -v /tmp/opencode/e2e:/x caddy:2.11.4 sh -c 'rm -rf /x/*' >/dev/null 2>&1 +rm -rf /tmp/opencode/e2e; mkdir -p /tmp/opencode/e2e/datadocker network rm cadprox-e2e-net >/dev/null 2>&1 +docker network create cadprox-e2e-net >/dev/null +docker run -d --name backend-e2e --network cadprox-e2e-net \ + python:3.12-alpine python3 -m http.server 9001 --directory /tmp >/dev/null +# local_certs keeps auto-HTTPS on but issues from Caddy's internal CA, so the +# site blocks behave exactly as they do in production but need no public DNS. +cat > /tmp/opencode/e2e/Caddyfile <<'EOF' +{ + admin 0.0.0.0:2019 + local_certs +} + +one.test { + encode gzip + reverse_proxy backend-e2e:9001 +} +EOF +docker run -d --name caddy-e2e --network cadprox-e2e-net \ + -p 127.0.0.1:2020:2019 -p 127.0.0.1:$PROXY_PORT:443 \ + -v /tmp/opencode/e2e/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2.11.4 >/dev/null +docker run -d --name cadprox-e2e --network host \ + -v /tmp/opencode/e2e:/caddy -v /tmp/opencode/e2e/data:/data \ + -e CADPROX_CADDY_ADMIN=http://127.0.0.1:2020 -e CADPROX_PORT=8091 \ + -e CADPROX_ADMIN_PASSWORD=e2etestpassword cadprox:1.0 >/dev/null +wait_admin +sleep 1 +login +[ -s $J ] && ok "login issues a session cookie" || bad "no session cookie" + +echo "=== 1. baseline traffic through the proxy (no auth) ===" +check "open proxy serves the backend" "$(req one.test)" "200" + +echo "=== 2. add a proxy, traffic follows immediately ===" +ups '{"url":"two.test","target":"backend-e2e:9001"}' >/dev/null +check "new proxy serves traffic" "$(req two.test)" "200" + +echo "=== 3. edit the target of an existing proxy ===" +ups '{"url":"two.test","target":"127.0.0.1:9"}' >/dev/null +check "bad target now returns 502" "$(req two.test)" "502" +ups '{"url":"two.test","target":"backend-e2e:9001"}' >/dev/null +check "restored target serves again" "$(req two.test)" "200" + +echo "=== 4. basic auth is enforced end to end ===" +ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":"firstpassword"}}' >/dev/null +check "no credentials -> 401" "$(req secure.test)" "401" +check "wrong password -> 401" "$(req secure.test -u alice:wrong)" "401" +check "correct credentials -> 200" "$(req secure.test -u alice:firstpassword)" "200" +ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":"secondpassword"}}' >/dev/null +check "old password now -> 401" "$(req secure.test -u alice:firstpassword)" "401" +check "new password -> 200" "$(req secure.test -u alice:secondpassword)" "200" + +echo "=== 5. editing the target keeps the credential ===" +ups '{"url":"secure.test","target":"backend-e2e:9001","auth":{"username":"alice","password":null}}' >/dev/null +check "auth still required after target edit" "$(req secure.test)" "401" +check "credential still valid after target edit" "$(req secure.test -u alice:secondpassword)" "200" + +echo "=== 6. remove auth from a proxy ===" +ups '{"url":"secure.test","target":"backend-e2e:9001","auth":null}' >/dev/null +check "auth removed -> 200 without credentials" "$(req secure.test)" "200" + +echo "=== 7. delete a proxy ===" +del '{"url":"two.test"}' >/dev/null +# A deleted hostname has no route and no certificate, so Caddy answers the +# handshake with a non-200 outcome rather than proxying to the backend. +two_now=$(req two.test) +if [ "$two_now" != "200" ]; then ok "deleted proxy no longer serves the backend (got $two_now)" +else bad "deleted proxy still serving traffic"; fi +check "untouched proxy still routes" "$(req one.test)" "200" + +echo "=== 8. a hand-written block survives a save ===" +# -i is required: without it docker does not forward the heredoc on stdin. +docker exec -i cadprox-e2e sh -c 'cat >> /caddy/Caddyfile' <<'EOF' + +handwritten.test { + header X-Special yes + reverse_proxy backend-e2e:9001 +} +EOF +docker restart caddy-e2e >/dev/null; wait_admin +docker restart cadprox-e2e >/dev/null; sleep 3 +login +managed=$(curl -s -b $J $B/api/entries | python3 -c " +import json,sys +e=[x for x in json.load(sys.stdin)['entries'] if x['url']=='handwritten.test'] +print(e[0]['managed'] if e else 'absent')") +check "hand-written block is read-only in the UI" "$managed" "False" +refusal=$(err '{"url":"handwritten.test","target":"127.0.0.1:9"}') +case "$refusal" in + *"preserved verbatim"*) ok "editing a preserved block is refused";; + *) bad "expected a refusal, got: '$refusal'";; +esac +check "preserved block serves traffic before the save" "$(req handwritten.test)" "200" +ups '{"url":"one.test","target":"backend-e2e:9001"}' >/dev/null +check "preserved block still in the file after a save" \ + "$(docker exec cadprox-e2e sh -c 'grep -c "header X-Special yes" /caddy/Caddyfile')" "1" +check "preserved block still serves traffic after a save" "$(req handwritten.test)" "200" +check "preserved block's custom directive survived the save" \ + "$(docker exec cadprox-e2e sh -c 'grep -c "X-Special" /caddy/Caddyfile')" "1" +check "the cadprox header did not accumulate across saves" \ + "$(docker exec cadprox-e2e sh -c 'grep -c "Managed by cadprox" /caddy/Caddyfile')" "1" + +echo "=== 9. security ===" +check "unauthenticated API is refused" "$(curl -s -o /dev/null -w '%{http_code}' -m 10 $B/api/entries)" "401" +check "POST without the CSRF header is refused" \ + "$(curl -s -o /dev/null -w '%{http_code}' -m 10 -b $J -X POST $B/api/entries -H 'Content-Type: application/json' -d '{"url":"x.test","target":"1.2.3.4:80"}')" "403" +check "wrong password is refused" \ + "$(curl -s -o /dev/null -w '%{http_code}' -m 10 -X POST $B/api/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"nope"}')" "401" +check "no password hash is exposed by the API" \ + "$(curl -s -b $J $B/api/entries | grep -c '\$2[aby]\$')" "0" +check "invalid target is rejected" \ + "$(err '{"url":"bad.test","target":"1.2.3.4:99999"}' | grep -c 'not a valid port')" "1" +check "URL with a scheme is rejected" \ + "$(err '{"url":"http://bad.test","target":"1.2.3.4:80"}' | grep -c 'no scheme')" "1" +# curl's cookie jar records HttpOnly but silently drops SameSite, so the raw +# Set-Cookie header is inspected instead. +sc=$(curl -s -i -X POST $B/api/login -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"e2etestpassword"}' | grep -i '^set-cookie:') +case "$sc" in *HttpOnly*) ok "session cookie is HttpOnly";; *) bad "cookie not HttpOnly: $sc";; esac +case "$sc" in *SameSite=Strict*) ok "session cookie is SameSite=Strict";; *) bad "cookie lacks SameSite=Strict: $sc";; esac +case "$sc" in *Secure*) bad "cookie marked Secure but cadprox serves plain HTTP";; + *) ok "cookie not falsely marked Secure over HTTP";; esac + +echo "=== 10. the Caddyfile on disk still validates ===" +docker exec cadprox-e2e caddy validate --adapter caddyfile --config /caddy/Caddyfile >/dev/null 2>&1 \ + && ok "caddy validate accepts the final file" || bad "caddy validate rejected the final file" + +echo +echo "================================" +echo " passed: $PASS failed: $FAIL" +echo "================================" +[ "$FAIL" -eq 0 ] diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..d38cde3 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,415 @@ +"""Tests for cadprox's Caddyfile parsing and rendering. + +These cover the parts that can silently destroy the user's configuration: +round-tripping a hand-written Caddyfile, preserving blocks cadprox does not +model, and making sure password hashes never leave the server. + +Run with: python3 -m unittest discover -s tests -v +""" + +import json +import os +import subprocess +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import app # noqa: E402 + +REAL_CADDYFILE = """\ +bdmurphy.me { + encode gzip + reverse_proxy 127.0.0.1:8080 +} + +vault.bdmurphy.me { + encode gzip + reverse_proxy 127.0.0.1:8082 +} + +umbraudio.bdmurphy.me { + encode gzip + reverse_proxy 192.168.123.12:8420 +} +""" + + +def targets(state): + return {", ".join(e["hosts"]): e["target"] for e in state["entries"]} + + +def caddy_available() -> bool: + try: + return subprocess.run( + [app.CADDY_BIN, "version"], capture_output=True, timeout=30 + ).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +class CaddyBackedTest(unittest.TestCase): + """Base class for tests that need the real Caddy binary to adjudicate.""" + + @classmethod + def setUpClass(cls): + if not caddy_available(): + raise unittest.SkipTest( + "caddy binary not reachable; set CADPROX_CADDY_BIN to test against a real Caddy" + ) + + def validate_with_real_caddy(self, config: str) -> tuple[bool, str]: + return app.validate_caddyfile(config) + + +class TestParse(CaddyBackedTest): + def test_parses_real_caddyfile(self): + state = app.parse_caddyfile(REAL_CADDYFILE) + self.assertEqual( + targets(state), + { + "bdmurphy.me": "127.0.0.1:8080", + "vault.bdmurphy.me": "127.0.0.1:8082", + "umbraudio.bdmurphy.me": "192.168.123.12:8420", + }, + ) + self.assertTrue(all(e["managed"] for e in state["entries"])) + + def test_header_does_not_accumulate_across_saves(self): + # Regression: the generated header used to be absorbed as preserved + # leading text, so every save added another copy and the file grew. + state = app.parse_caddyfile(REAL_CADDYFILE) + text = app.render_caddyfile(state) + for _ in range(5): + text = app.render_caddyfile(app.parse_caddyfile(text)) + self.assertEqual( + text.count(app.MANAGED_MARKER), 1, + "the cadprox header was duplicated across repeated saves", + ) + self.assertEqual( + len([ln for ln in text.splitlines() if ln.startswith("#") and not ln.startswith("# ")]), + 1, + ) + ok, msg = self.validate_with_real_caddy(text) + self.assertTrue(ok, f"repeated saves produced an invalid config: {msg}") + + def test_operator_comments_at_the_top_are_preserved(self): + text = """\ +# My own note about this file +# second line + +a.example.com { +\treverse_proxy 10.0.0.1:80 +} +""" + state = app.parse_caddyfile(text) + rendered = app.render_caddyfile(state) + self.assertIn("# My own note about this file", rendered) + self.assertIn("# second line", rendered) + self.assertEqual(rendered.count(app.MANAGED_MARKER), 1) + + def test_header_does_not_hide_global_block(self): + # Regression: cadprox writes its own comment header at the top of the + # file, and those comments used to stop the global block being found, + # which turned it into a phantom entry with a blank URL. + text = """\ +# Caddy reverse proxy configuration +# +# Site blocks below are managed by cadprox. + +{ +\tadmin 127.0.0.1:2019 +\tauto_https off +} + +a.example.com { +\tencode gzip +\treverse_proxy 10.0.0.1:80 +} +""" + state = app.parse_caddyfile(text) + self.assertIn("auto_https off", state["globals"]) + self.assertEqual([e["hosts"] for e in state["entries"]], [["a.example.com"]]) + for entry in state["entries"]: + self.assertTrue(entry["hosts"], "entry with no site address leaked into the UI") + rendered = app.render_caddyfile(state) + self.assertLess(rendered.index("auto_https off"), rendered.index("a.example.com")) + ok, msg = self.validate_with_real_caddy(rendered) + self.assertTrue(ok, f"rendered config rejected by caddy: {msg}") + + def test_keyless_block_never_becomes_an_entry(self): + state = app.parse_caddyfile("{\n\tadmin 0.0.0.0:2019\n}\n") + self.assertEqual(state["entries"], []) + self.assertIn("admin 0.0.0.0:2019", state["globals"]) + + def test_mid_file_keyless_block_preserved_not_listed(self): + text = """\ +a.example.com { +\treverse_proxy 10.0.0.1:80 +} + +{ +\tservers { +\t\ttrusted_proxies static 10.0.0.0/8 +\t} +} +""" + state = app.parse_caddyfile(text) + self.assertEqual([e["hosts"] for e in state["entries"]], [["a.example.com"]]) + self.assertIn("trusted_proxies", state["extras"]) + self.assertIn("trusted_proxies", app.render_caddyfile(state)) + + def test_global_options_block_preserved(self): + text = """\ +{ +\tadmin 127.0.0.1:2019 +\temail me@example.com +} + +a.example.com { +\treverse_proxy 10.0.0.1:80 +} +""" + state = app.parse_caddyfile(text) + self.assertEqual(len(state["entries"]), 1) + self.assertIn("email me@example.com", state["globals"]) + self.assertIn("admin 127.0.0.1:2019", state["globals"]) + self.assertEqual(state["extras"], "") + rendered = app.render_caddyfile(state) + self.assertIn("email me@example.com", rendered) + # Caddy rejects a global block that is not first, so it must precede + # every site block in the rendered output. + self.assertLess( + rendered.index("email me@example.com"), + rendered.index("a.example.com"), + ) + + def test_global_block_alone_round_trips(self): + text = "{\n\tadmin 0.0.0.0:2019\n}\n" + state = app.parse_caddyfile(text) + self.assertEqual(state["entries"], []) + self.assertIn("admin 0.0.0.0:2019", app.render_caddyfile(state)) + + def test_basic_auth_round_trips_hashes(self): + text = """\ +secure.example.com { +\tencode gzip +\tbasic_auth { +\t\talice $2a$14$abcdefghijklmnopqrstuv +\t\tbob $2a$14$zyxwvutsrqponmlkjihg +\t} +\treverse_proxy 10.0.0.2:8080 +} +""" + state = app.parse_caddyfile(text) + entry = state["entries"][0] + self.assertTrue(entry["managed"]) + self.assertEqual(entry["auth"]["users"], ["alice", "bob"]) + self.assertEqual( + entry["auth"]["hashes"], + ["$2a$14$abcdefghijklmnopqrstuv", "$2a$14$zyxwvutsrqponmlkjihg"], + ) + # The exact hashes must survive a render. + rendered = app.render_caddyfile(state) + self.assertIn("alice $2a$14$abcdefghijklmnopqrstuv", rendered) + self.assertIn("bob $2a$14$zyxwvutsrqponmlkjihg", rendered) + + def test_hash_password_produces_a_hash_basic_auth_accepts(self): + # Regression: Caddy's hash-password can emit argon2id, but the HTTP + # basic auth provider in Caddy 2.11 only verifies bcrypt, so an argon2id + # hash loads fine and then rejects every login including the right one. + hashed = app.hash_password("correcthorsebattery") + self.assertTrue( + hashed.startswith("$2"), + f"expected a bcrypt hash, got {hashed[:16]!r}", + ) + self.assertNotIn("argon2id", hashed) + self.assertNotIn("correcthorsebattery", hashed) + # bcrypt salts randomly, so the same password must not hash identically. + self.assertNotEqual(hashed, app.hash_password("correcthorsebattery")) + + def test_generated_hash_survives_a_render_and_reparse(self): + hashed = app.hash_password("correcthorsebattery") + state = { + "entries": [ + { + "hosts": ["s.example.com"], + "target": "10.0.0.1:80", + "encode": True, + "auth": {"users": ["alice"], "hashes": [hashed]}, + "managed": True, + "reason": "", + "lines": [], + } + ], + "globals": "", + "extras": "", + } + reparsed = app.parse_caddyfile(app.render_caddyfile(state)) + self.assertEqual(reparsed["entries"][0]["auth"]["hashes"], [hashed]) + ok, msg = self.validate_with_real_caddy(app.render_caddyfile(state)) + self.assertTrue(ok, f"caddy rejected the generated config: {msg}") + + def test_unknown_directive_marks_block_unmanaged_and_preserves_it(self): + text = """\ +fancy.example.com { +\tencode gzip +\theader_up X-Custom abc +\treverse_proxy 10.0.0.3:9000 +} +""" + state = app.parse_caddyfile(text) + entry = state["entries"][0] + self.assertFalse(entry["managed"]) + self.assertIn("header_up X-Custom abc", entry["reason"]) + rendered = app.render_caddyfile(state) + self.assertIn("header_up X-Custom abc", rendered) + self.assertIn("reverse_proxy 10.0.0.3:9000", rendered) + + def test_multiple_upstreams_marked_unmanaged(self): + text = """\ +lb.example.com { +\treverse_proxy 10.0.0.4:80 10.0.0.5:80 +} +""" + entry = app.parse_caddyfile(text)["entries"][0] + self.assertFalse(entry["managed"]) + + def test_import_preserved_verbatim(self): + text = """\ +import /etc/caddy/snippets/*.caddy + +x.example.com { +\treverse_proxy 10.0.0.6:80 +} +""" + state = app.parse_caddyfile(text) + self.assertIn("import /etc/caddy/snippets/*.caddy", state["extras"]) + self.assertIn("import /etc/caddy/snippets/*.caddy", app.render_caddyfile(state)) + + def test_nested_braces_do_not_split_blocks(self): + text = """\ +a.example.com { +\tbasic_auth { +\t\tu $2a$14$abcdefghijklmnopqrstuv +\t} +\treverse_proxy 10.0.0.7:80 +} +b.example.com { +\treverse_proxy 10.0.0.8:80 +} +""" + state = app.parse_caddyfile(text) + self.assertEqual(len(state["entries"]), 2) + self.assertEqual( + targets(state), {"a.example.com": "10.0.0.7:80", "b.example.com": "10.0.0.8:80"} + ) + + def test_comments_ignored(self): + text = """\ +# leading comment +a.example.com { +\t# inner comment +\tencode gzip +\treverse_proxy 10.0.0.9:80 # trailing +} +""" + state = app.parse_caddyfile(text) + self.assertEqual(len(state["entries"]), 1) + self.assertTrue(state["entries"][0]["managed"]) + + +class TestRenderRoundTrip(unittest.TestCase): + def test_render_then_parse_is_stable(self): + state = app.parse_caddyfile(REAL_CADDYFILE) + first = app.render_caddyfile(state) + second = app.render_caddyfile(app.parse_caddyfile(first)) + self.assertEqual(first, second, "render is not idempotent") + + def test_real_targets_survive_render(self): + state = app.parse_caddyfile(REAL_CADDYFILE) + reparsed = app.parse_caddyfile(app.render_caddyfile(state)) + self.assertEqual(targets(state), targets(reparsed)) + self.assertEqual( + [", ".join(e["hosts"]) for e in state["entries"]], + [", ".join(e["hosts"]) for e in reparsed["entries"]], + ) + + def test_render_uses_tabs_and_ends_with_newline(self): + state = app.parse_caddyfile(REAL_CADDYFILE) + out = app.render_caddyfile(state) + self.assertTrue(out.endswith("\n")) + self.assertIn("\treverse_proxy 127.0.0.1:8080", out) + self.assertIn("\tencode gzip", out) + + +class TestValidation(unittest.TestCase): + def test_host_accepted(self): + for good in ("bdmurphy.me", "A.Bdmurphy.ME", "localhost", "192.168.1.1", "a-b.c"): + self.assertEqual(app.validate_host(good), good.lower().rstrip(".")) + + def test_host_rejected(self): + for bad in ("", "http://a.com", "a.com/path", "a.com:80", "has space", "a..b", "-x.com"): + with self.assertRaises(app.ValidationError, msg=bad): + app.validate_host(bad) + + def test_target_accepted(self): + self.assertEqual(app.validate_target(" 192.168.123.14:8425 "), "192.168.123.14:8425") + self.assertEqual(app.validate_target("host.local:1"), "host.local:1") + self.assertEqual(app.validate_target("1.2.3.4:65535"), "1.2.3.4:65535") + + def test_target_rejected(self): + for bad in ("", "http://1.2.3.4:80", "1.2.3.4", "1.2.3.4:80:90", "1.2.3.4:0", + "1.2.3.4:70000", "1.2.3.4:http", "1.2.3.4/80"): + with self.assertRaises(app.ValidationError, msg=bad): + app.validate_target(bad) + + def test_password_rules(self): + with self.assertRaises(app.ValidationError): + app.validate_password("short") + with self.assertRaises(app.ValidationError): + app.validate_password("") + self.assertEqual(app.validate_password("longenough1"), "longenough1") + + def test_username_rules(self): + with self.assertRaises(app.ValidationError): + app.validate_username("bad user") + with self.assertRaises(app.ValidationError): + app.validate_username("") + self.assertEqual(app.validate_username("alice"), "alice") + + +class TestPublicState(unittest.TestCase): + def test_hashes_never_leave_the_server(self): + text = """\ +s.example.com { +\tbasic_auth { +\t\talice $2a$14$abcdefghijklmnopqrstuv +\t} +\treverse_proxy 10.0.0.1:80 +} +""" + public = app.public_state(app.parse_caddyfile(text)) + blob = json.dumps(public) + self.assertNotIn("$2a$14$", blob) + self.assertNotIn("abcdefghijklmnopqrstuv", blob) + self.assertEqual(public["entries"][0]["auth_user"], "alice") + self.assertEqual(public["entries"][0]["auth_count"], 1) + + def test_open_proxy_reported_as_no_auth(self): + public = app.public_state(app.parse_caddyfile(REAL_CADDYFILE)) + self.assertTrue(all(e["auth_user"] == "" for e in public["entries"])) + + +class TestSessions(unittest.TestCase): + def test_token_lifecycle(self): + token = app.new_session() + self.assertTrue(app.session_valid(token)) + self.assertFalse(app.session_valid("nonsense")) + self.assertFalse(app.session_valid("")) + app._sessions[token] = 0 + self.assertFalse(app.session_valid(token), "expired token accepted") + + +if __name__ == "__main__": + unittest.main(verbosity=2)