Files
smart-org/apps/api/app/infrastructure/adapters/caddy.py
T
mjalmoudy94 717e53a7ce نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
2026-10-13 00:39:28 +03:30

327 lines
12 KiB
Python

"""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 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<host>\S+)\n(?P<body>.*?)# <<< 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())
]
# -- 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)