Files
cadprox/app.py
2026-10-18 19:01:06 -07:00

1212 lines
45 KiB
Python

# 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}(?<!-)"
r"(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$"
)
IPV4_RE = re.compile(
r"^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
r"(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$"
)
def validate_host(raw: str) -> 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 = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>cadprox</title>
<style>
:root{--bg:#0f1115;--card:#171a21;--line:#272b36;--fg:#e6e8ee;--dim:#98a1b0;
--accent:#5b8cff;--ok:#3fb950;--err:#f85149;--warn:#d29922}
*{box-sizing:border-box}
body{margin:0;font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
background:var(--bg);color:var(--fg)}
header{padding:16px 22px;border-bottom:1px solid var(--line);display:flex;
align-items:center;gap:12px;flex-wrap:wrap}
h1{font-size:17px;margin:0;letter-spacing:.4px}
header .sp{flex:1}
main{max-width:1080px;margin:0 auto;padding:22px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:18px;margin-bottom:18px}
h2{font-size:12px;margin:0 0 14px;color:var(--dim);text-transform:uppercase;
letter-spacing:.7px}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:9px 8px;border-bottom:1px solid var(--line);font-size:14px}
th{color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px}
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
input{background:#0d0f14;border:1px solid var(--line);color:var(--fg);padding:8px 10px;
border-radius:6px;font-size:14px;width:100%}
input:focus{outline:0;border-color:var(--accent)}
label{display:block;font-size:11px;color:var(--dim);margin-bottom:5px;
text-transform:uppercase;letter-spacing:.5px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}
button{background:var(--accent);color:#fff;border:0;padding:8px 14px;border-radius:6px;
font-size:14px;font-weight:500;cursor:pointer;font-family:inherit}
button:hover{filter:brightness(1.12)}
button.ghost{background:transparent;border:1px solid var(--line);color:var(--fg)}
button.danger{background:var(--err)}
button:disabled{opacity:.45;cursor:not-allowed;filter:none}
.pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600}
.pill.on{background:rgba(63,185,80,.15);color:var(--ok)}
.pill.off{background:rgba(152,161,176,.15);color:var(--dim)}
.pill.warn{background:rgba(210,153,34,.15);color:var(--warn)}
#msg{margin-bottom:16px;padding:11px 13px;border-radius:6px;font-size:14px;display:none}
#msg.ok{display:block;background:rgba(63,185,80,.12);color:var(--ok);border:1px solid rgba(63,185,80,.3)}
#msg.err{display:block;background:rgba(248,81,73,.12);color:var(--err);border:1px solid rgba(248,81,73,.3)}
.hint{font-size:12px;color:var(--dim);margin-top:6px}
.flex{display:flex;gap:9px;align-items:center}
.right{text-align:right;white-space:nowrap}
dialog{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:10px;
padding:20px;max-width:520px;width:92%}
dialog::backdrop{background:rgba(0,0,0,.65)}
#login{max-width:360px;margin:14vh auto}
.hide{display:none!important}
</style></head><body>
<header id="topbar" class="hide">
<h1>cadprox</h1>
<span id="health" class="pill off">checking</span>
<span id="preserved" class="pill warn hide">verbatim block</span>
<span class="sp"></span>
<button class="ghost" onclick="logout()">Sign out</button>
</header>
<main>
<div id="msg"></div>
<section id="login" class="card">
<h2>Sign in</h2>
<div class="row" style="grid-template-columns:1fr">
<div><label for="l_user">Username</label><input id="l_user" value="admin" autocomplete="username"></div>
</div>
<div class="row" style="grid-template-columns:1fr">
<div><label for="l_pass">Password</label><input id="l_pass" type="password" autocomplete="current-password"></div>
</div>
<div class="flex"><button onclick="doLogin()">Sign in</button></div>
<div class="hint">The generated password is in the cadprox container log on first boot.</div>
</section>
<section id="app" class="hide">
<div class="card">
<h2>Add or update a reverse proxy</h2>
<div class="row">
<div><label for="n_url">URL</label><input id="n_url" placeholder="app.bdmurphy.me"></div>
<div><label for="n_target">Proxy target (IP:Port)</label><input id="n_target" placeholder="192.168.123.14:8425"></div>
</div>
<div class="row">
<div><label for="n_user">Basic auth user (optional)</label><input id="n_user" placeholder="blank for an open proxy"></div>
<div><label for="n_pass">Basic auth password (optional)</label><input id="n_pass" type="password" placeholder="min 8 characters"></div>
</div>
<div class="hint">Saving an existing URL updates its target. A Let's Encrypt
certificate is issued automatically the first time a hostname goes live.</div>
<div class="flex" style="margin-top:14px">
<button onclick="save()">Save proxy</button>
<button class="ghost" onclick="load()">Refresh</button>
</div>
</div>
<div class="card">
<h2>Configured proxies</h2>
<table><thead><tr><th>URL</th><th>Target</th><th>Auth</th><th></th></tr></thead>
<tbody id="rows"></tbody></table>
</div>
</section>
</main>
<script>
const esc = s => String(s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let ENTRIES = [];
function notify(msg, ok){
const el = document.getElementById('msg');
el.textContent = msg;
el.className = ok ? 'ok' : 'err';
clearTimeout(notify.t);
notify.t = setTimeout(() => el.style.display = 'none', 7000);
}
async function api(path, body){
const r = await fetch(path, {
method: body ? 'POST' : 'GET',
headers: {'Content-Type':'application/json', 'X-Cadprox':'1'},
body: body ? JSON.stringify(body) : undefined
});
if (r.status === 401) { showLogin(); throw new Error('Session expired, sign in again.'); }
const j = await r.json().catch(() => ({error:'Malformed server response.'}));
if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
return j;
}
function showLogin(){
document.getElementById('app').classList.add('hide');
document.getElementById('topbar').classList.add('hide');
document.getElementById('login').classList.remove('hide');
document.getElementById('l_pass').value = '';
}
function showApp(){
document.getElementById('login').classList.add('hide');
document.getElementById('app').classList.remove('hide');
document.getElementById('topbar').classList.remove('hide');
}
async function doLogin(){
try {
await api('/api/login', {username: document.getElementById('l_user').value.trim(),
password: document.getElementById('l_pass').value});
document.getElementById('msg').style.display = 'none';
showApp(); await load();
} catch(e){ notify(e.message, false); }
}
async function logout(){
try { await api('/api/logout', {}); } catch(e){}
showLogin();
}
function render(){
const tb = document.getElementById('rows');
if (!ENTRIES.length){
tb.innerHTML = '<tr><td colspan="4" style="color:var(--dim);padding:20px 8px">No proxies configured yet.</td></tr>';
return;
}
tb.innerHTML = ENTRIES.map(e => {
const auth = e.auth_user
? `<span class="pill on">${esc(e.auth_user)}</span>` + (e.auth_count > 1 ? ` <span class="pill off">+${e.auth_count-1}</span>` : '')
: '<span class="pill off">open</span>';
const ctl = e.managed
? `<button class="ghost" onclick="fill('${encodeURIComponent(e.url)}')">Edit</button>
<button class="ghost" onclick="del('${encodeURIComponent(e.url)}')">Delete</button>`
: `<span class="pill warn" title="${esc(e.reason)}">preserved</span>`;
return `<tr>
<td class="mono">${esc(e.url)}</td>
<td class="mono">${esc(e.target || '—')}</td>
<td>${auth}</td>
<td class="right">${ctl}</td></tr>`;
}).join('');
}
async function load(){
const d = await api('/api/entries');
ENTRIES = d.entries;
render();
const h = document.getElementById('health');
h.textContent = d.caddy_admin_reachable ? 'caddy online' : 'caddy unreachable';
h.className = 'pill ' + (d.caddy_admin_reachable ? 'on' : 'off');
document.getElementById('preserved').classList.toggle('hide', !d.preserved);
}
function fill(enc){
const url = decodeURIComponent(enc);
const e = ENTRIES.find(x => x.url === url);
if (!e) return;
document.getElementById('n_url').value = url;
document.getElementById('n_target').value = e.target || '';
document.getElementById('n_user').value = e.auth_user || '';
document.getElementById('n_pass').value = '';
document.getElementById('n_url').scrollIntoView({block:'center'});
notify('Editing ' + url + '. Change the target and press Save. Leave the password blank to keep the current credential.', true);
}
async function save(){
const url = document.getElementById('n_url').value.trim();
const target = document.getElementById('n_target').value.trim();
const user = document.getElementById('n_user').value.trim();
const pass = document.getElementById('n_pass').value;
if (!url || !target) return notify('URL and target are both required.', false);
try {
const d = await api('/api/entries', {url, target, auth: user ? {username:user, password:pass} : null});
notify(d.message, true);
['n_url','n_target','n_user','n_pass'].forEach(id => document.getElementById(id).value = '');
await load();
} catch(e){ notify(e.message, false); }
}
async function del(enc){
const url = decodeURIComponent(enc);
if (!confirm('Remove ' + url + ' from the Caddy config?\\n\\nThe hostname stops serving immediately.')) return;
try {
const d = await api('/api/delete', {url});
notify(d.message, true);
await load();
} catch(e){ notify(e.message, false); }
}
document.getElementById('l_pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
api('/api/entries').then(() => { showApp(); return load(); })
.catch(() => showLogin());
</script></body></html>"""
# ----------------------------------------------------------------- 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()