# Copyright 2026 Umbra Tools (umbratools.com) # SPDX-License-Identifier: Apache-2.0 """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 ) # 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. 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: password = read_secrets_file(ADMIN_PASSWORD_FILE) 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()