نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user