The password now lives in CADPRox_Secrets (mode 600, git-ignored) and is
mounted read-only into the container, rather than being read from the
compose file. Two reasons:
- the password contains '#', which YAML reads as the start of a comment,
so putting it in docker-compose.yml was never going to work;
- anything in the compose file is visible in `docker compose config` and
`docker inspect`, and would be easy to commit by accident.
read_secrets_file() accepts either a bare password on one line, which is
the common case, or a key=value file so the same file can hold more than
one secret later. A bare password containing '=' is not mangled, because
the key/value parse only wins when it recognises one of its own keys.
Also detect a bind mount whose source is missing. Docker creates a
directory in that case, which would otherwise fail confusingly and
quietly leave the app with no password.
The port stays CADPROX_PORT in the compose file and is deliberately not
added to ports:. Publishing a port needs bridge networking, which cannot
reach Caddy's admin API on 127.0.0.1:2019, and Docker discards a ports:
entry under host mode with only a warning - so it would read as a mapping
that is not there. Documented in the README instead.
1209 lines
45 KiB
Python
1209 lines
45 KiB
Python
"""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 =>
|
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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()
|