#!/usr/bin/env python3 """Maintain the Caddy admin-only IP denylist with durable expiry state.""" import argparse from contextlib import contextmanager import hashlib import ipaddress import json import os from pathlib import Path import re import stat import subprocess import sys import tempfile import time BAN_SECONDS = 24 * 60 * 60 MAX_BANS = 4096 MAX_FILE_BYTES = 512 * 1024 STATE_VERSION = 1 EMPTY_SNIPPET = "# Managed by truf-caddy-admin-denylist. Admin-route import only.\n" SHA256_RE = re.compile(r"^[0-9a-f]{64}$") PROFILE_PATH = Path("/etc/truf/deployment-profile") STANDALONE_PROFILE = "standalone-edge-v1" SHARED_HOST_PROFILE = "shared-host-edge-v1" class UpdateError(RuntimeError): pass class CommandFailure(UpdateError): pass class RollbackFailure(UpdateError): pass def canonical_ip(value): text = str(value or "") if not text or len(text) > 64 or "%" in text or any(char.isspace() for char in text): raise UpdateError("invalid IP address") try: address = ipaddress.ip_address(text) except ValueError as exc: raise UpdateError("invalid IP address") from exc if address.is_unspecified or address.is_multicast: raise UpdateError("unsupported IP address") return address.compressed.lower() def render_snippet(bans, matcher="remote_ip"): if matcher not in {"remote_ip", "client_ip"}: raise UpdateError("unsupported denylist matcher") addresses = sorted( (ipaddress.ip_address(address) for address in bans), key=lambda address: (address.version, int(address)), ) if not addresses: return EMPTY_SNIPPET.encode("ascii") lines = [EMPTY_SNIPPET.rstrip("\n")] for offset in range(0, len(addresses), 64): name = f"truf_admin_denied_{offset // 64:04d}" values = " ".join(address.compressed.lower() for address in addresses[offset:offset + 64]) lines.append(f"@{name} {matcher} {values}") lines.append(f'respond @{name} "" 403') return ("\n".join(lines) + "\n").encode("ascii") def _digest(content): return hashlib.sha256(content).hexdigest() def _check_parent(path): parent = path.parent details = parent.lstat() if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode): raise UpdateError("managed parent must be a real directory") if os.name == "posix" and stat.S_IMODE(details.st_mode) & 0o002: raise UpdateError("managed parent must not be world-writable") def _read_optional(path): _check_parent(path) try: details = path.lstat() except FileNotFoundError: return None if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode): raise UpdateError("managed path must be a regular file") flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags) try: current = os.fstat(descriptor) if not stat.S_ISREG(current.st_mode) or current.st_size > MAX_FILE_BYTES: raise UpdateError("managed file is invalid or too large") chunks = [] remaining = MAX_FILE_BYTES + 1 while remaining: chunk = os.read(descriptor, min(65536, remaining)) if not chunk: break chunks.append(chunk) remaining -= len(chunk) content = b"".join(chunks) if len(content) > MAX_FILE_BYTES: raise UpdateError("managed file is too large") return content finally: os.close(descriptor) def _sync_parent(parent): if os.name != "posix": return descriptor = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(descriptor) finally: os.close(descriptor) def _atomic_write(path, content, mode): _check_parent(path) if len(content) > MAX_FILE_BYTES: raise UpdateError("managed content is too large") try: existing = path.lstat() except FileNotFoundError: existing = None if existing is not None and (not stat.S_ISREG(existing.st_mode) or stat.S_ISLNK(existing.st_mode)): raise UpdateError("managed path must be a regular file") descriptor, temporary = tempfile.mkstemp(prefix=".truf-denylist-", dir=path.parent) temporary_path = Path(temporary) try: if hasattr(os, "fchmod"): os.fchmod(descriptor, mode) else: os.chmod(temporary_path, mode) with os.fdopen(descriptor, "wb", closefd=True) as handle: descriptor = -1 handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temporary_path, path) _sync_parent(path.parent) finally: if descriptor >= 0: os.close(descriptor) try: temporary_path.unlink() except FileNotFoundError: pass def _restore(path, content, mode): if content is not None: _atomic_write(path, content, mode) return try: details = path.lstat() except FileNotFoundError: return if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode): raise UpdateError("managed path changed during rollback") path.unlink() _sync_parent(path.parent) @contextmanager def _exclusive_lock(path): _check_parent(path) flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags, 0o600) try: details = os.fstat(descriptor) if not stat.S_ISREG(details.st_mode): raise UpdateError("lock path must be a regular file") if os.name == "posix": import fcntl fcntl.flock(descriptor, fcntl.LOCK_EX) yield finally: os.close(descriptor) def _load_state(content): if content is None: return {"version": STATE_VERSION, "bans": {}, "applied_sha256": ""} try: value = json.loads(content.decode("ascii")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise UpdateError("denylist state is not valid JSON") from exc if not isinstance(value, dict) or set(value) != {"version", "bans", "applied_sha256"}: raise UpdateError("denylist state has an invalid schema") if value["version"] != STATE_VERSION or not isinstance(value["bans"], dict): raise UpdateError("denylist state has an unsupported version") if len(value["bans"]) > MAX_BANS: raise UpdateError("denylist state exceeds its entry bound") applied = value["applied_sha256"] if not isinstance(applied, str) or (applied and not SHA256_RE.fullmatch(applied)): raise UpdateError("denylist state has an invalid applied digest") bans = {} for address, expires_at in value["bans"].items(): canonical = canonical_ip(address) if canonical != address or isinstance(expires_at, bool) or not isinstance(expires_at, int): raise UpdateError("denylist state has a noncanonical entry") if expires_at <= 0 or expires_at > 253402300799: raise UpdateError("denylist state has an invalid expiry") bans[canonical] = expires_at return {"version": STATE_VERSION, "bans": bans, "applied_sha256": applied} def _encode_state(state): return (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode("ascii") def _subprocess_runner(command): environment = { "HOME": "/root", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", } try: completed = subprocess.run( command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=environment, timeout=45, check=False, ) except (OSError, subprocess.SubprocessError): return False return completed.returncode == 0 class DenylistUpdater: def __init__( self, state_path, snippet_path, project_directory="/opt/truf", env_file="/etc/truf-edge/edge.env", profile=None, runner=None, clock=None, ): self.state_path = Path(state_path) self.snippet_path = Path(snippet_path) self.lock_path = self.state_path.with_suffix(self.state_path.suffix + ".lock") if profile is None: try: profile = PROFILE_PATH.read_text(encoding="ascii").strip() except FileNotFoundError: profile = STANDALONE_PROFILE except (OSError, UnicodeError): raise UpdateError("deployment profile is unreadable") from None if profile not in {STANDALONE_PROFILE, SHARED_HOST_PROFILE}: raise UpdateError("deployment profile is unsupported") compose_file = ( "compose.shared-host.yaml" if profile == SHARED_HOST_PROFILE else "compose.edge.yaml" ) caddyfile = ( "/etc/caddy/Caddyfile.shared-host" if profile == SHARED_HOST_PROFILE else "/etc/caddy/Caddyfile" ) self.matcher = "client_ip" if profile == SHARED_HOST_PROFILE else "remote_ip" compose = ( "docker", "compose", "--ansi", "never", "--env-file", str(env_file), "--project-directory", str(project_directory), "--file", str(Path(project_directory) / "compose.yaml"), "--file", str(Path(project_directory) / compose_file), ) self.validate_command = compose + ( "exec", "-T", "edge", "caddy", "validate", "--config", caddyfile, "--adapter", "caddyfile", ) self.reload_command = compose + ( "exec", "-T", "edge", "caddy", "reload", "--config", caddyfile, "--adapter", "caddyfile", "--address", "unix//run/caddy-admin.sock", ) self.runner = runner or _subprocess_runner self.clock = clock or time.time def _run(self, command, phase): try: succeeded = self.runner(command) except Exception as exc: raise CommandFailure(f"{phase} command failed") from exc if not succeeded: raise CommandFailure(f"{phase} command failed") def update(self, operation, address=None): if operation not in {"ban", "unban", "expire", "status"}: raise UpdateError("unsupported operation") canonical = canonical_ip(address) if operation in {"ban", "unban"} else None now = int(self.clock()) if now <= 0: raise UpdateError("system clock is invalid") with _exclusive_lock(self.lock_path): old_state_content = _read_optional(self.state_path) old_snippet_content = _read_optional(self.snippet_path) state = _load_state(old_state_content) bans = { ip: expires_at for ip, expires_at in state["bans"].items() if expires_at > now } expired = len(state["bans"]) - len(bans) if operation == "ban": if canonical not in bans and len(bans) >= MAX_BANS: raise UpdateError("denylist entry bound reached") bans[canonical] = max(bans.get(canonical, 0), now + BAN_SECONDS) elif operation == "unban": bans.pop(canonical, None) desired_snippet = render_snippet(bans, self.matcher) desired_digest = _digest(desired_snippet) pending_state = { "version": STATE_VERSION, "bans": bans, "applied_sha256": state["applied_sha256"], } pending_content = _encode_state(pending_state) needs_reload = ( old_snippet_content != desired_snippet or state["applied_sha256"] != desired_digest ) needs_state_write = old_state_content != pending_content if needs_reload: reload_attempted = False try: _atomic_write(self.state_path, pending_content, 0o600) _atomic_write(self.snippet_path, desired_snippet, 0o640) self._run(self.validate_command, "validation") reload_attempted = True self._run(self.reload_command, "reload") pending_state["applied_sha256"] = desired_digest _atomic_write(self.state_path, _encode_state(pending_state), 0o600) except Exception as original: try: _restore(self.state_path, old_state_content, 0o600) _restore(self.snippet_path, old_snippet_content, 0o640) if reload_attempted: self._run(self.validate_command, "rollback validation") self._run(self.reload_command, "rollback reload") except Exception as rollback: raise RollbackFailure("denylist rollback failed") from rollback if isinstance(original, UpdateError): raise raise UpdateError("denylist update failed") from original elif needs_state_write: pending_state["applied_sha256"] = desired_digest _atomic_write(self.state_path, _encode_state(pending_state), 0o600) return { "operation": operation, "ip": canonical, "expired": expired, "bans": dict(bans), } def _emit(result): bans = result["bans"] if result["operation"] == "status": addresses = sorted( bans, key=lambda value: (ipaddress.ip_address(value).version, int(ipaddress.ip_address(value))) ) payload = { "active": len(addresses), "bans": [ {"ip": address, "expires_at": bans[address]} for address in addresses[:256] ], "event": "admin_denylist_status", "truncated": len(addresses) > 256, } else: payload = { "active": len(bans), "event": "admin_denylist_" + result["operation"], "expired": result["expired"], } if result["ip"] is not None: payload["ip"] = result["ip"] print(json.dumps(payload, sort_keys=True, separators=(",", ":")), flush=True) def parse_args(argv=None): parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument("--state-path", default="/var/lib/truf-edge/admin-denylist.json") parser.add_argument("--snippet-path", default="/etc/truf-edge/denylist/admin-denylist.caddy") parser.add_argument("--project-directory", default="/opt/truf") parser.add_argument("--env-file", default="/etc/truf-edge/edge.env") commands = parser.add_subparsers(dest="operation", required=True) for name in ("ban", "unban"): command = commands.add_parser(name, allow_abbrev=False) command.add_argument("ip") commands.add_parser("expire", allow_abbrev=False) commands.add_parser("status", allow_abbrev=False) return parser.parse_args(argv) def main(argv=None): args = parse_args(argv) updater = DenylistUpdater( args.state_path, args.snippet_path, project_directory=args.project_directory, env_file=args.env_file, ) try: result = updater.update(args.operation, getattr(args, "ip", None)) except Exception as exc: payload = { "event": "admin_denylist_error", "operation": args.operation, "reason": type(exc).__name__, } print(json.dumps(payload, sort_keys=True, separators=(",", ":")), file=sys.stderr) return 1 _emit(result) return 0 if __name__ == "__main__": raise SystemExit(main())