mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
get_merged_domains gated the onion injection on node_onion being set AND svc-net-tor being in group_names. When the node was empty it answered the clearnet map with no error and no log line, and because group_names is part of the cache key, a clearnet map and an onion map coexist in one process under separate keys. One render then serves onion while another freezes a clearnet asset URL into a stylesheet. That URL is unrecoverable at request time: body_filter.lua returns a CSS response before onionize(), so the <link href> in the head is rewritten while the url(...) inside the stylesheet body is not. The symptom is a single toHaveCSS mismatch and nothing else. An empty node under a deployed svc-net-tor now raises. A scope carrying no group_names at all stays silent, because that is indistinguishable from a legitimate non-tor deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
295 lines
9.9 KiB
Python
295 lines
9.9 KiB
Python
"""Domain-name cache: canonical-domains map derived from merged apps.
|
|
|
|
Owns `_MERGED_DOMAINS_CACHE`. Public API: `get_merged_domains`. The
|
|
domain map is intentionally derived from the applications view rather
|
|
than living in a parallel top-level overrides path. Per-app domain
|
|
declarations live in `applications.<app>.domains` and flow through the
|
|
regular applications-merge pipeline.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from .base import (
|
|
_RENDER_GUARD,
|
|
_cache_key,
|
|
_resolve_roles_dir,
|
|
_stable_variables_signature,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
import os
|
|
|
|
_MERGED_DOMAINS_CACHE: dict[tuple, dict[str, Any]] = {}
|
|
|
|
|
|
def _as_bool(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in ("true", "1", "yes", "on")
|
|
return bool(value)
|
|
|
|
|
|
def _resolved_flag(
|
|
value: Any,
|
|
*,
|
|
app: str,
|
|
name: str,
|
|
templar: Any,
|
|
variables: Any,
|
|
apps: Any,
|
|
) -> bool:
|
|
"""A tor service flag as a boolean, never as the text of its own template.
|
|
|
|
``services.tor.*`` is declared as Jinja in 99 of the 105 roles that carry
|
|
it. When this map is built from an applications tree that is still being
|
|
rendered (``utils.cache.applications`` returns the raw tree while its own
|
|
render is in flight) the flag arrives as its source text, which every
|
|
boolean coercion reads as False. The onion injection would then silently
|
|
not happen and the clearnet domain would be frozen into whatever value the
|
|
render was producing.
|
|
|
|
Args:
|
|
value: the declared flag, rendered or not.
|
|
app: application id, for the error message.
|
|
name: flag name under ``services.tor``, for the error message.
|
|
templar: templar to render with; ``None`` renders nothing.
|
|
variables: scope the template resolves against.
|
|
apps: applications tree seeded into the nested render.
|
|
|
|
Returns:
|
|
The flag as a boolean.
|
|
|
|
Raises:
|
|
ValueError: the flag still carries template text after rendering.
|
|
"""
|
|
if isinstance(value, str) and ("{{" in value or "{%" in value):
|
|
from utils.cache.base import _render_with_templar
|
|
|
|
value = _render_with_templar(
|
|
value, templar=templar, variables=variables, raw_applications=apps
|
|
)
|
|
if isinstance(value, str) and ("{{" in value or "{%" in value):
|
|
raise ValueError(
|
|
f"get_merged_domains: '{app}' services.tor.{name} stayed template "
|
|
f"text ({value!r}). Reading it as a boolean would drop the onion "
|
|
"domains without saying so."
|
|
)
|
|
return _as_bool(value)
|
|
|
|
|
|
def _onion_of(domain: str, primary: str, node_onion: str) -> str | None:
|
|
"""Return ``domain`` with the clearnet ``primary`` suffix swapped for the
|
|
node onion, or None when the domain is not under ``primary``."""
|
|
if domain == primary:
|
|
return node_onion
|
|
suffix = "." + primary
|
|
if domain.endswith(suffix):
|
|
return domain[: -len(primary)] + node_onion
|
|
return None
|
|
|
|
|
|
def _inject_onion_domains(
|
|
merged: dict[str, Any],
|
|
apps: dict[str, Any],
|
|
primary: str,
|
|
node_onion: str,
|
|
*,
|
|
templar: Any = None,
|
|
variables: Any = None,
|
|
) -> dict[str, Any]:
|
|
"""Add ``<sub>.<node-onion>`` domains for apps opting into onion routing.
|
|
|
|
Driven by ``services.tor`` per app: ``enabled`` gates it (consumer-owned,
|
|
no provider fallback — an app without a tor block opts out). ``exclusive``
|
|
replaces the clearnet domain (onion only), ``primary`` puts the onion
|
|
first; both resolve consumer-override -> ``svc-net-tor`` provider default
|
|
via :func:`resolve_service_config`.
|
|
"""
|
|
from utils.roles.applications.services.config import resolve_service_config
|
|
from utils.roles.applications.services.registry import (
|
|
build_service_registry_from_applications,
|
|
)
|
|
|
|
registry = build_service_registry_from_applications(apps)
|
|
|
|
def _flag(value: Any, app: str, name: str) -> bool:
|
|
return _resolved_flag(
|
|
value,
|
|
app=app,
|
|
name=name,
|
|
templar=templar,
|
|
variables=variables,
|
|
apps=apps,
|
|
)
|
|
|
|
out: dict[str, Any] = {}
|
|
for app, domains in merged.items():
|
|
tor = ((apps.get(app) or {}).get("services") or {}).get("tor") or {}
|
|
if not isinstance(domains, (list, dict)) or not _flag(
|
|
tor.get("enabled"), app, "enabled"
|
|
):
|
|
out[app] = domains
|
|
continue
|
|
exclusive = _flag(
|
|
resolve_service_config(
|
|
apps, app, "tor", "exclusive", default=False, service_registry=registry
|
|
),
|
|
app,
|
|
"exclusive",
|
|
)
|
|
is_primary = _flag(
|
|
resolve_service_config(
|
|
apps, app, "tor", "primary", default=False, service_registry=registry
|
|
),
|
|
app,
|
|
"primary",
|
|
)
|
|
if isinstance(domains, dict):
|
|
out[app] = _inject_onion_into_dict(
|
|
domains, primary, node_onion, exclusive, is_primary
|
|
)
|
|
continue
|
|
onion = [o for d in domains if (o := _onion_of(str(d), primary, node_onion))]
|
|
if exclusive:
|
|
out[app] = onion or domains
|
|
elif is_primary:
|
|
out[app] = onion + [d for d in domains if d not in onion]
|
|
else:
|
|
out[app] = list(domains) + [o for o in onion if o not in domains]
|
|
return out
|
|
|
|
|
|
def _inject_onion_into_dict(
|
|
domains: dict[str, Any],
|
|
primary: str,
|
|
node_onion: str,
|
|
exclusive: bool,
|
|
is_primary: bool,
|
|
) -> dict[str, Any]:
|
|
"""Onionize a named-canonical dict (``{key: domain}``) while keeping every
|
|
value a plain string, so ``get_primary_domain`` still returns a string.
|
|
|
|
``exclusive`` swaps each value for its onion; otherwise the onion domains
|
|
are added under ``<key>_onion`` siblings (before the clearnet keys when
|
|
``primary`` is set) so ``generate_all_domains`` emits an onion vhost per
|
|
named canonical without changing which value is primary.
|
|
"""
|
|
if exclusive:
|
|
return {
|
|
key: (_onion_of(str(value), primary, node_onion) or value)
|
|
for key, value in domains.items()
|
|
}
|
|
onion_pairs: dict[str, Any] = {}
|
|
for key, value in domains.items():
|
|
onion = _onion_of(str(value), primary, node_onion)
|
|
if onion and onion != value:
|
|
onion_pairs[f"{key}_onion"] = onion
|
|
if is_primary:
|
|
return {**onion_pairs, **domains}
|
|
return {**domains, **onion_pairs}
|
|
|
|
|
|
def get_merged_domains(
|
|
*,
|
|
variables: dict[str, Any] | None = None,
|
|
roles_dir: str | os.PathLike[str] | None = None,
|
|
templar: Any = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the canonical-domain map lazily from the merged applications view.
|
|
|
|
The result is canonical_domains_map(applications, DOMAIN_PRIMARY).
|
|
Per-app domain declarations live in `applications.<app>.domains`
|
|
(canonical/aliases) and flow through the regular applications-merge
|
|
pipeline.
|
|
|
|
Cached keyed on (roles_dir, variables_signature, render_state). A read
|
|
issued while the applications render is in progress sees the unrendered
|
|
tree, whose service flags are still Jinja strings; its map is cached under
|
|
its own key so the render's own call sites reuse it without it ever being
|
|
served as the finished map. The tor flags are rendered before they are
|
|
read (``_resolved_flag``), so that mid-render map carries the same onion
|
|
domains the finished one does.
|
|
"""
|
|
from plugins.filter.canonical_domains_map import (
|
|
FilterModule as _CanonicalDomainsFilter,
|
|
)
|
|
|
|
from .applications import get_merged_applications
|
|
|
|
variables = variables or {}
|
|
resolved_roles_dir = _resolve_roles_dir(roles_dir=roles_dir)
|
|
|
|
cache_key = (
|
|
_cache_key(resolved_roles_dir),
|
|
_stable_variables_signature(variables),
|
|
bool(getattr(_RENDER_GUARD, "applications", False)),
|
|
)
|
|
cached = _MERGED_DOMAINS_CACHE.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
primary_domain = (
|
|
variables.get("DOMAIN_PRIMARY") or variables.get("SYSTEM_EMAIL_DOMAIN") or ""
|
|
)
|
|
if not primary_domain:
|
|
raise ValueError(
|
|
"get_merged_domains: DOMAIN_PRIMARY (or SYSTEM_EMAIL_DOMAIN fallback) "
|
|
"must be set in variables."
|
|
)
|
|
|
|
from utils.templating.ansible import render_ansible_strict
|
|
|
|
def _render_raw(raw: Any, name: str) -> Any:
|
|
if isinstance(raw, str) and ("{{" in raw or "{%" in raw):
|
|
return render_ansible_strict(
|
|
templar=None,
|
|
raw=raw,
|
|
var_name=name,
|
|
err_prefix="get_merged_domains",
|
|
variables={},
|
|
)
|
|
return raw
|
|
|
|
primary_domain = _render_raw(primary_domain, "DOMAIN_PRIMARY")
|
|
|
|
apps = get_merged_applications(
|
|
variables=variables,
|
|
roles_dir=roles_dir,
|
|
templar=templar,
|
|
)
|
|
|
|
filter_instance = _CanonicalDomainsFilter()
|
|
merged = filter_instance.canonical_domains_map(apps, primary_domain)
|
|
|
|
node_raw = (
|
|
((apps.get("svc-net-tor") or {}).get("services") or {}).get("tor") or {}
|
|
).get("node")
|
|
node_onion = str(_render_raw(node_raw or "", "services.tor.node") or "").strip()
|
|
group_names = variables.get("group_names") or []
|
|
if "svc-net-tor" in group_names:
|
|
if not node_onion:
|
|
raise ValueError(
|
|
"get_merged_domains: 'svc-net-tor' is deployed but "
|
|
"applications.svc-net-tor.services.tor.node is empty. "
|
|
"Skipping the onion injection would serve every exclusive "
|
|
"app on clearnet without saying so."
|
|
)
|
|
merged = _inject_onion_domains(
|
|
merged,
|
|
apps,
|
|
str(primary_domain),
|
|
node_onion,
|
|
templar=templar,
|
|
variables=variables,
|
|
)
|
|
|
|
_MERGED_DOMAINS_CACHE[cache_key] = merged
|
|
return merged
|
|
|
|
|
|
def _reset() -> None:
|
|
_MERGED_DOMAINS_CACHE.clear()
|