"""Caddy adapter for :class:`DomainRouterPort`. Publishes tenant preview domains by maintaining a *managed snippet file* that the main Caddyfile imports, then reloading Caddy. The main Caddyfile is never edited by this adapter. Why a snippet instead of editing the Caddyfile --------------------------------------------- Rewriting an operator's Caddyfile means parsing and re-emitting a file we do not own; a mistake there takes down every site on the host. A separate file that Caddy imports by glob keeps the blast radius to tenant preview routes only. Setup (one line, once) ---------------------- Add this to the Caddyfile and reload Caddy:: import /etc/caddy/agy-preview/*.caddy The adapter refuses to run if that import is missing, because silently writing a file that Caddy never reads would report success for a domain that does not resolve. Security rules enforced here, not only upstream ----------------------------------------------- * the host must be a single label under ``PREVIEW_ROOT_DOMAIN`` (:func:`validate_managed_host`) — this is what stops a crafted host from closing the site block and injecting directives * the proxy target must be loopback with a numeric port (:func:`validate_proxy_target`) — SSRF prevention * protected platform ports are refused (:func:`assert_not_protected_port`) * the rendered config is validated *before* it is loaded, and every failed change is rolled back to the previous file The ``caddy`` binary runs on the platform host. That is a different trust domain from tenant code: tenant commands only ever run inside a sandbox adapter. """ from __future__ import annotations import asyncio import hashlib import re from pathlib import Path from app.application.ports import PreviewDomainInfo, ReverseProxyResult from app.core.errors import ProviderUnavailableError from app.domain.platform.rules import ( RuleViolation, assert_not_protected_port, assert_port_allocatable, build_host, validate_managed_host, validate_proxy_target, ) _BEGIN = "# >>> agy-managed: {host}" _END = "# <<< agy-managed: {host}" _BLOCK_RE = re.compile( r"# >>> agy-managed: (?P\S+)\n(?P.*?)# <<< agy-managed: (?P=host)\n", re.DOTALL, ) _VALIDATE_TIMEOUT_SECONDS = 15 _RELOAD_TIMEOUT_SECONDS = 30 class CaddyDomainRouterAdapter: """Reverse-proxy routes managed through a Caddy snippet file.""" def __init__( self, *, caddyfile_path: str, root_domain: str, port_start: int = 8080, port_end: int = 8999, snippet_dir: str | None = None, caddy_binary: str = "caddy", ) -> None: self.caddyfile_path = Path(caddyfile_path) self.root_domain = root_domain self.port_start = port_start self.port_end = port_end self.caddy_binary = caddy_binary # Default to a sibling directory of the Caddyfile so the import glob is # a short, predictable path. self.snippet_dir = ( Path(snippet_dir) if snippet_dir else self.caddyfile_path.parent / "agy-preview" ) self.snippet_path = self.snippet_dir / "preview.caddy" # -- port allocation ----------------------------------------------------- async def allocate_port( self, *, organization_id: str, project_id: str, service_name: str ) -> int: """Pick the lowest free port that is not already routed. The database is the authoritative port ledger (``PreviewDomainRepository``); this only avoids handing out a port that a live route already occupies. """ in_use = {entry.port for entry in self._parse(self._read_snippet())} for port in range(self.port_start, self.port_end + 1): if port in in_use: continue try: assert_port_allocatable(port, start=self.port_start, end=self.port_end) except RuleViolation: continue return port raise ProviderUnavailableError("پورت آزادی در بازهٔ تعیین‌شده وجود ندارد.") async def claim_preview_domain( self, *, organization_id: str, project_id: str, requested_subdomain: str, port: int, ) -> PreviewDomainInfo: host = build_host(requested_subdomain, self.root_domain) result = await self.configure_reverse_proxy(host=host, port=port) if not result.ok: raise RuleViolation(result.message or "پیکربندی دامنه ناموفق بود.") return PreviewDomainInfo(host=host, port=port, url=result.url, status="active") # -- routing ------------------------------------------------------------- async def configure_reverse_proxy(self, *, host: str, port: int) -> ReverseProxyResult: """Create or replace the route for ``host`` and reload Caddy.""" try: clean_host = validate_managed_host(host, self.root_domain) # Raises unless the target is loopback with a numeric port. validate_proxy_target(f"localhost:{port}") assert_port_allocatable(port, start=self.port_start, end=self.port_end) assert_not_protected_port(port) except RuleViolation as exc: # A rejected rule is a caller error, not an outage. return ReverseProxyResult(ok=False, message=str(exc)) if not self._import_is_wired(): return ReverseProxyResult( ok=False, message=( "فایل Caddyfile خط import مربوط به AGY را ندارد. " f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy" ), ) previous = self._read_snippet() entries = [e for e in self._parse(previous) if e.host != clean_host] entries.append(_Entry(host=clean_host, port=port)) updated = self._render(entries) self._write_snippet(updated) error = await self._validate() if error is None: error = await self._reload() if error is not None: # Roll back so a failed change never leaves a half-written config. self._write_snippet(previous) await self._reload() return ReverseProxyResult(ok=False, message=error) return ReverseProxyResult( ok=True, message="مسیر دامنه ثبت و Caddy بارگذاری مجدد شد.", url=f"https://{clean_host}" ) async def remove_preview_domain(self, *, domain_id: str) -> None: """Remove a route. ``domain_id`` is the host, matching how the application layer calls this (``remove_preview_domain(domain_id=record["host"])``). """ try: clean_host = validate_managed_host(domain_id, self.root_domain) except RuleViolation: # Not one of ours — nothing to remove, and not an error. return previous = self._read_snippet() entries = [e for e in self._parse(previous) if e.host != clean_host] if len(entries) == len(self._parse(previous)): return self._write_snippet(self._render(entries)) error = await self._validate() if error is None: error = await self._reload() if error is not None: self._write_snippet(previous) await self._reload() async def list_project_domains(self, *, project_id: str) -> list[PreviewDomainInfo]: """Every managed route in the snippet. NOT tenant-scoped: the snippet records hosts and ports, not projects, and ``configure_reverse_proxy`` is not given a project id. The database (``PreviewDomainRepository``) is the authoritative, tenant-scoped ledger — never use this method for authorization. """ return [ PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}") for e in self._parse(self._read_snippet()) ] # -- full re-sync (owner console) ---------------------------------------- def routes_fingerprint(self) -> str: """A stable digest of the routes currently in the snippet. The owner console's "re-sync" is a convenience button, and pressing it must not reload Caddy when nothing changed: a reload drops every in-flight connection on the host, including tenants' live demos. """ entries = sorted(self._parse(self._read_snippet()), key=lambda e: e.host) material = "\n".join(f"{e.host}:{e.port}" for e in entries) return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32] async def list_all_routes(self) -> list[PreviewDomainInfo]: """Every managed route, with no tenant filter (owner console only).""" return [ PreviewDomainInfo(host=e.host, port=e.port, url=f"https://{e.host}") for e in sorted(self._parse(self._read_snippet()), key=lambda e: e.host) ] async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult: """Make the snippet match ``routes`` exactly, in one write and one reload. Idempotent by construction: the desired state is rendered, compared against what is on disk, and written only when it differs. This is what makes "re-sync from the database" a safe thing to expose as a button — it converges on the truth rather than accumulating edits. """ try: entries = [ _Entry(host=validate_managed_host(host, self.root_domain), port=int(port)) for host, port in routes ] except (RuleViolation, ValueError) as exc: return ReverseProxyResult(ok=False, message=str(exc)) for entry in entries: try: validate_proxy_target(f"localhost:{entry.port}") assert_port_allocatable(entry.port, start=self.port_start, end=self.port_end) assert_not_protected_port(entry.port) except RuleViolation as exc: return ReverseProxyResult(ok=False, message=str(exc)) if not self._import_is_wired(): return ReverseProxyResult( ok=False, message=( "فایل Caddyfile خط import مربوط به AGY را ندارد. " f"این خط را اضافه کنید: import {self.snippet_dir}/*.caddy" ), ) previous = self._read_snippet() updated = self._render(entries) if updated == previous: # Nothing to do. A reload here would drop every live connection for # no reason at all. return ReverseProxyResult( ok=True, message="مسیرها از قبل همگام بودند.", url="" ) self._write_snippet(updated) error = await self._validate() if error is None: error = await self._reload() if error is not None: self._write_snippet(previous) await self._reload() return ReverseProxyResult(ok=False, message=error) return ReverseProxyResult( ok=True, message=f"{len(entries)} مسیر همگام شد.", url="" ) # -- file handling ------------------------------------------------------- def _read_snippet(self) -> str: if not self.snippet_path.exists(): return "" return self.snippet_path.read_text(encoding="utf-8") def _write_snippet(self, content: str) -> None: """Write atomically: a reader must never see a partial file.""" try: self.snippet_dir.mkdir(parents=True, exist_ok=True) tmp = self.snippet_path.with_suffix(".tmp") tmp.write_text(content, encoding="utf-8") tmp.replace(self.snippet_path) except OSError as exc: raise ProviderUnavailableError( f"نوشتن فایل پیکربندی Caddy ناموفق بود: {exc}" ) from exc def _import_is_wired(self) -> bool: """Confirm the Caddyfile actually imports our snippet.""" try: text = self.caddyfile_path.read_text(encoding="utf-8") except OSError: return False needle = str(self.snippet_dir) return any( line.strip().startswith("import") and needle in line for line in text.splitlines() ) # -- rendering and parsing ---------------------------------------------- @staticmethod def _render(entries: list[_Entry]) -> str: header = ( "# This file is generated and owned by AGY (web preview domains).\n" "# Manual edits will be overwritten. Managed by CaddyDomainRouterAdapter.\n\n" ) blocks = [] for entry in sorted(entries, key=lambda e: e.host): blocks.append( f"{_BEGIN.format(host=entry.host)}\n" f"{entry.host} {{\n" f"\treverse_proxy localhost:{entry.port}\n" f"}}\n" f"{_END.format(host=entry.host)}\n" ) return header + "\n".join(blocks) @staticmethod def _parse(text: str) -> list[_Entry]: entries: list[_Entry] = [] for match in _BLOCK_RE.finditer(text or ""): port_match = re.search(r"reverse_proxy\s+localhost:(\d+)", match.group("body")) if not port_match: continue entries.append(_Entry(host=match.group("host"), port=int(port_match.group(1)))) return entries # -- caddy process ------------------------------------------------------- async def _run(self, *args: str, timeout: int) -> tuple[int, str]: """Run the ``caddy`` binary. Platform process, never tenant code.""" try: process = await asyncio.create_subprocess_exec( self.caddy_binary, *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) except FileNotFoundError as exc: raise ProviderUnavailableError( f"اجرایی «{self.caddy_binary}» پیدا نشد؛ برای DOMAIN_ROUTER=caddy لازم است." ) from exc try: stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) except TimeoutError: process.kill() await process.wait() return 1, "عملیات Caddy زمان‌بر شد." combined = (stderr or stdout or b"").decode("utf-8", errors="replace").strip() return process.returncode or 0, combined async def _validate(self) -> str | None: """Return an error message, or ``None`` when the config is valid.""" code, output = await self._run( "validate", "--config", str(self.caddyfile_path), timeout=_VALIDATE_TIMEOUT_SECONDS, ) if code != 0: return f"پیکربندی Caddy نامعتبر است: {output[:400]}" return None async def _reload(self) -> str | None: code, output = await self._run( "reload", "--config", str(self.caddyfile_path), timeout=_RELOAD_TIMEOUT_SECONDS, ) if code != 0: return f"بارگذاری مجدد Caddy ناموفق بود: {output[:400]}" return None class _Entry: """One managed route.""" __slots__ = ("host", "port") def __init__(self, *, host: str, port: int) -> None: self.host = host self.port = port def __eq__(self, other: object) -> bool: return isinstance(other, _Entry) and (self.host, self.port) == (other.host, other.port)