مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
327 lines
12 KiB
Python
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)
|