feat(delivery): owner-only domains + idempotent route re-sync (v2 phase A3-4)
Domains are infrastructure, and in v2 infrastructure is the platform owner's alone (D9). A tenant gets one automatic demo host and nothing else. - admin_domains.py (owner-only): list every route with its owning app, attach an extra host, detach with the host typed back as confirmation (audited), re-sync the router from the database, and read the suggested MCP gateway route. - domain/platform/rules.py: validate_extra_host. Two shapes only — a single label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS is on. A wildcard is refused in both (it would match another tenant's subdomain), and so are 30 protected platform labels (git, mcp, panel, api...). Enforced in the router *and* again in the adapter. - Both router adapters gained routes_fingerprint / list_all_routes / sync_routes. Re-sync writes and reloads only when the rendered state differs: a reload drops every in-flight connection on the host, including live demos. Adapter-level extras, not additions to the frozen DomainRouterPort. - The member-facing preview-domain routes are owner-only now. They are kept because the demo host is still created through them at app-creation time. Tests: 473 API (+58); ruff and mypy clean. Report: docs/plan/reports/agent-3-phase-4.md
This commit is contained in:
@@ -123,6 +123,104 @@ def assert_port_allocatable(
|
||||
return port
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extra hosts (v2: only the platform owner may attach one)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Labels that must never become a tenant host. They are either platform
|
||||
#: services or would shadow one: `git` is the forge, `mcp` the MCP gateway,
|
||||
#: `panel`/`admin` the console, `api` the backend itself. A tenant host that
|
||||
#: resolves to one of these is a phishing surface and, worse, a routing
|
||||
#: conflict with a real service.
|
||||
PROTECTED_HOST_LABELS: frozenset[str] = frozenset(
|
||||
{
|
||||
"admin",
|
||||
"api",
|
||||
"app",
|
||||
"assets",
|
||||
"billing",
|
||||
"cdn",
|
||||
"console",
|
||||
"dashboard",
|
||||
"ftp",
|
||||
"git",
|
||||
"gitea",
|
||||
"grafana",
|
||||
"internal",
|
||||
"mail",
|
||||
"mcp",
|
||||
"media",
|
||||
"monitor",
|
||||
"ns",
|
||||
"panel",
|
||||
"registry",
|
||||
"sandbox",
|
||||
"smtp",
|
||||
"ssh",
|
||||
"static",
|
||||
"status",
|
||||
"storage",
|
||||
"support",
|
||||
"webhook",
|
||||
"www",
|
||||
}
|
||||
)
|
||||
|
||||
#: A strict fully-qualified domain name. Deliberately does **not** allow a
|
||||
#: wildcard (`*.example.com` in a Caddy site block matches every subdomain,
|
||||
#: including one belonging to another tenant) and requires at least one dot.
|
||||
_FQDN_RE = re.compile(
|
||||
r"^(?!-)[a-z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-z0-9-]{1,63}(?<!-))+$"
|
||||
)
|
||||
|
||||
#: Longest host we will ever write into a config file.
|
||||
MAX_HOST_LENGTH = 253
|
||||
|
||||
|
||||
def validate_extra_host(
|
||||
host: str, *, root_domain: str = DEFAULT_ROOT_DOMAIN, allow_custom: bool = False
|
||||
) -> str:
|
||||
"""Validate a host the owner wants to attach to an app.
|
||||
|
||||
Two shapes are allowed:
|
||||
|
||||
* a **single label under the managed root** (``shop.msa.artacloud.ir``), which
|
||||
is the default and needs no DNS work from anyone; and
|
||||
* a **full custom FQDN** (``app.customer.com``), but only when the operator
|
||||
has turned ``ALLOW_CUSTOM_DOMAINS`` on — a custom host needs its own TLS
|
||||
certificate and DNS record, and silently accepting one that will never
|
||||
resolve produces a domain that looks configured and is not.
|
||||
|
||||
A wildcard is refused in both cases. So is anything whose first label is a
|
||||
protected platform name, in either shape.
|
||||
"""
|
||||
clean = (host or "").strip().lower().rstrip(".")
|
||||
if not clean:
|
||||
raise RuleViolation("نام دامنه خالی است.")
|
||||
if len(clean) > MAX_HOST_LENGTH:
|
||||
raise RuleViolation("نام دامنه بیش از حد طولانی است.")
|
||||
if "*" in clean:
|
||||
raise RuleViolation("دامنهٔ wildcard مجاز نیست.")
|
||||
if not _FQDN_RE.match(clean):
|
||||
raise RuleViolation("قالب نام دامنه نامعتبر است.")
|
||||
|
||||
first_label = clean.split(".", 1)[0]
|
||||
if first_label in PROTECTED_HOST_LABELS:
|
||||
raise RuleViolation(f"برچسب «{first_label}» برای سرویسهای سکو رزرو شده است.")
|
||||
|
||||
suffix = f".{root_domain.strip().lower()}"
|
||||
if clean.endswith(suffix):
|
||||
return validate_managed_host(clean, root_domain)
|
||||
|
||||
if clean == root_domain.strip().lower():
|
||||
raise RuleViolation("دامنهٔ ریشه قابل استفاده نیست.")
|
||||
if not allow_custom:
|
||||
raise RuleViolation(
|
||||
"افزودن دامنهٔ سفارشی غیرفعال است؛ فقط زیردامنههای سکو مجازند."
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def assert_not_protected_port(port: int) -> int:
|
||||
"""A preview domain must never point at an internal platform port."""
|
||||
if port in SYSTEM_PROTECTED_PORTS:
|
||||
|
||||
Reference in New Issue
Block a user