mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
resolve_term follows a redirect source to its target so an alias inherits the TLS settings of the application it points at. A redirect to a domain another host serves ends that chain on a domain this host does not know, and the tls and cert lookups failed: galaxyserver full-1 stopped in web-opt-rdr-domains on 'tls: domain project.infinito.nexus not found' for its project.veen.world -> project.infinito.nexus redirect. When the chain ends outside the host's domains and web-opt-rdr-domains is deployed, the source now resolves to web-opt-rdr-domains with the source itself as primary domain, which is the role that serves its vhost and whose certificate bundle names it. Only the outermost call falls back, so the source asked for stays the primary domain across a longer chain; without the redirect role, or for a target this host serves, nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
393 lines
12 KiB
Python
393 lines
12 KiB
Python
#
|
|
# Shared strict resolution helpers for Infinito.Nexus TLS/cert lookups.
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from ansible.errors import AnsibleError
|
|
|
|
from utils.domains.application_domain_index import resolve_app_id_for_domain
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable
|
|
|
|
AVAILABLE_FLAVORS = {"letsencrypt", "self_signed"}
|
|
REDIRECT_SERVING_APP = "web-opt-rdr-domains"
|
|
|
|
|
|
def as_str(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
return str(value).strip()
|
|
|
|
|
|
def norm_domain(value: Any) -> str:
|
|
return as_str(value).lower()
|
|
|
|
|
|
def require(variables: dict, name: str, expected_type: type | tuple[type, ...]) -> Any:
|
|
if name not in variables:
|
|
raise AnsibleError(f"tls_common: required variable '{name}' is missing")
|
|
value = variables[name]
|
|
if not isinstance(value, expected_type):
|
|
raise AnsibleError(
|
|
f"tls_common: variable '{name}' must be {expected_type}, got {type(value).__name__}"
|
|
)
|
|
return value
|
|
|
|
|
|
def get_path(data: Any, path: str, default: Any = None) -> Any:
|
|
if not isinstance(data, dict):
|
|
return default
|
|
cur: Any = data
|
|
for part in path.split("."):
|
|
if not isinstance(cur, dict) or part not in cur:
|
|
return default
|
|
cur = cur[part]
|
|
return cur
|
|
|
|
|
|
def want_get(data: Any, dotted: str) -> Any:
|
|
if not dotted:
|
|
return data
|
|
cur: Any = data
|
|
for part in dotted.split("."):
|
|
if not isinstance(cur, dict) or part not in cur:
|
|
raise AnsibleError(f"want='{dotted}' not found (missing '{part}')")
|
|
cur = cur[part]
|
|
return cur
|
|
|
|
|
|
def iter_domains(value: Any) -> Iterable[str]:
|
|
"""
|
|
Flatten domains from the *global* domains mapping entry.
|
|
Supports:
|
|
- str
|
|
- list[str]
|
|
- dict[str, str] (only one level, legacy behavior)
|
|
"""
|
|
if isinstance(value, str):
|
|
if value.strip():
|
|
yield value.strip()
|
|
return
|
|
|
|
if isinstance(value, dict):
|
|
for v in value.values():
|
|
if isinstance(v, str) and v.strip():
|
|
yield v.strip()
|
|
return
|
|
|
|
if isinstance(value, list):
|
|
for v in value:
|
|
if isinstance(v, str) and v.strip():
|
|
yield v.strip()
|
|
return
|
|
|
|
|
|
def uniq_preserve(items: Iterable[str]) -> list[str]:
|
|
seen: set[str] = set()
|
|
out: list[str] = []
|
|
for it in items:
|
|
s = as_str(it)
|
|
if not s:
|
|
continue
|
|
s = norm_domain(s)
|
|
if s in seen:
|
|
continue
|
|
seen.add(s)
|
|
out.append(s)
|
|
return out
|
|
|
|
|
|
def resolve_app_id_from_domain(domains: dict, domain: str, *, err_prefix: str) -> str:
|
|
"""
|
|
Legacy reverse-lookup from the *global* domains mapping.
|
|
This does NOT know about per-app aliases/canonicals stored in applications.
|
|
"""
|
|
needle = norm_domain(domain)
|
|
matches: list[str] = []
|
|
|
|
for app_id, val in domains.items():
|
|
matches.extend(
|
|
str(app_id) for d in iter_domains(val) if norm_domain(d) == needle
|
|
)
|
|
|
|
if not matches:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: domain '{domain}' not found in domains mapping"
|
|
)
|
|
|
|
if len(matches) > 1:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: domain '{domain}' is ambiguous, matches applications {matches}"
|
|
)
|
|
|
|
return matches[0]
|
|
|
|
|
|
def resolve_primary_domain_from_app(
|
|
domains: dict, app_id: str, *, err_prefix: str
|
|
) -> str:
|
|
if app_id not in domains:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: application_id '{app_id}' not found in domains mapping"
|
|
)
|
|
|
|
val = domains[app_id]
|
|
|
|
if isinstance(val, str):
|
|
if not val:
|
|
raise AnsibleError(f"{err_prefix}: domains['{app_id}'] is empty")
|
|
return val
|
|
|
|
if isinstance(val, dict):
|
|
try:
|
|
first_val = next(iter(val.values()))
|
|
except StopIteration:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: domains['{app_id}'] dict is empty"
|
|
) from None
|
|
if not isinstance(first_val, str) or not first_val:
|
|
raise AnsibleError(f"{err_prefix}: invalid primary domain for '{app_id}'")
|
|
return first_val
|
|
|
|
if isinstance(val, list):
|
|
if not val:
|
|
raise AnsibleError(f"{err_prefix}: domains['{app_id}'] list is empty")
|
|
first = val[0]
|
|
if not isinstance(first, str) or not first:
|
|
raise AnsibleError(f"{err_prefix}: invalid primary domain for '{app_id}'")
|
|
return first
|
|
|
|
raise AnsibleError(
|
|
f"{err_prefix}: domains['{app_id}'] has unsupported type {type(val).__name__}"
|
|
)
|
|
|
|
|
|
def collect_domains_for_app(
|
|
domains: dict, app_id: str, *, err_prefix: str
|
|
) -> list[str]:
|
|
if app_id not in domains:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: application_id '{app_id}' not found in domains mapping"
|
|
)
|
|
return uniq_preserve(list(iter_domains(domains[app_id])))
|
|
|
|
|
|
def collect_domains_global(domains: dict) -> list[str]:
|
|
all_items: list[str] = []
|
|
for val in domains.values():
|
|
all_items.extend(list(iter_domains(val)))
|
|
return uniq_preserve(all_items)
|
|
|
|
|
|
def redirect_targets(variables: dict) -> dict[str, str]:
|
|
"""
|
|
Returns {source: target} from CURRENT_PLAY_REDIRECT_DOMAINS, both normalized;
|
|
empty until the constructor has set the fact.
|
|
"""
|
|
mappings = variables.get("CURRENT_PLAY_REDIRECT_DOMAINS") or []
|
|
return {norm_domain(m["source"]): norm_domain(m["target"]) for m in mappings}
|
|
|
|
|
|
def is_domain_term(term: str, forced_mode: str, *, err_prefix: str) -> bool:
|
|
"""Tell whether a lookup term names a domain rather than an application id.
|
|
|
|
Args:
|
|
term: The lookup term.
|
|
forced_mode: "auto" (a dot makes it a domain), "domain" or "app".
|
|
err_prefix: Prefix of the error raised for an unknown mode.
|
|
|
|
Returns:
|
|
True when the term is a domain.
|
|
"""
|
|
forced = as_str(forced_mode).lower()
|
|
if forced not in {"auto", "domain", "app"}:
|
|
raise AnsibleError(f"{err_prefix}: mode must be one of: auto, domain, app")
|
|
if forced == "auto":
|
|
return "." in as_str(term)
|
|
return forced == "domain"
|
|
|
|
|
|
def resolve_term(
|
|
term: str,
|
|
*,
|
|
domains: dict,
|
|
applications: dict | None = None,
|
|
redirects: dict[str, str] | None = None,
|
|
forced_mode: str,
|
|
err_prefix: str,
|
|
_following: bool = False,
|
|
) -> tuple[str, str]:
|
|
"""
|
|
Returns (app_id, primary_domain) where primary_domain is normalized lower-case.
|
|
|
|
term can be:
|
|
- application_id
|
|
- domain (canonical or alias)
|
|
|
|
forced_mode: "auto" | "domain" | "app"
|
|
|
|
Behavior:
|
|
1) If term is a domain and exists in *global* domains mapping -> primary_domain = that term (normalized).
|
|
2) If term is a domain and only exists in applications[*].domains -> map to app_id,
|
|
and primary_domain = canonical primary from global domains mapping (first entry).
|
|
3) If term is a domain found in neither but is a source in redirects (see
|
|
redirect_targets), such as an AUTO_BUILD_ALIASES alias -> resolve its target,
|
|
following a chain of redirects; a source is used once, so a cycle fails.
|
|
4) If that chain ends on a domain this host does not serve, such as a redirect
|
|
to another host, and REDIRECT_SERVING_APP is in domains -> map the source to
|
|
REDIRECT_SERVING_APP, with the source itself as primary_domain.
|
|
"""
|
|
t = as_str(term)
|
|
if not t:
|
|
raise AnsibleError(f"{err_prefix}: term is empty")
|
|
|
|
if is_domain_term(t, forced_mode, err_prefix=err_prefix):
|
|
try:
|
|
app_id_legacy = resolve_app_id_from_domain(
|
|
domains, t, err_prefix=err_prefix
|
|
)
|
|
return str(app_id_legacy), norm_domain(t)
|
|
except AnsibleError:
|
|
pass
|
|
|
|
apps = applications or {}
|
|
if not isinstance(apps, dict):
|
|
raise AnsibleError(
|
|
f"{err_prefix}: applications must be dict when resolving domain terms"
|
|
)
|
|
|
|
app_id = resolve_app_id_for_domain(apps, t)
|
|
if not app_id and t.lower().startswith("www."):
|
|
bare = t[4:]
|
|
try:
|
|
app_id_legacy = resolve_app_id_from_domain(
|
|
domains, bare, err_prefix=err_prefix
|
|
)
|
|
return str(app_id_legacy), norm_domain(bare)
|
|
except AnsibleError:
|
|
pass
|
|
app_id = resolve_app_id_for_domain(apps, bare)
|
|
target = (redirects or {}).get(norm_domain(t))
|
|
if not app_id and target and target != norm_domain(t):
|
|
try:
|
|
return resolve_term(
|
|
target,
|
|
domains=domains,
|
|
applications=applications,
|
|
redirects={
|
|
source: dest
|
|
for source, dest in (redirects or {}).items()
|
|
if source != norm_domain(t)
|
|
},
|
|
forced_mode="domain",
|
|
err_prefix=err_prefix,
|
|
_following=True,
|
|
)
|
|
except AnsibleError:
|
|
if _following or REDIRECT_SERVING_APP not in domains:
|
|
raise
|
|
return REDIRECT_SERVING_APP, norm_domain(t)
|
|
if not app_id:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: domain '{t}' not found (domains/applications)"
|
|
)
|
|
|
|
primary = norm_domain(
|
|
resolve_primary_domain_from_app(domains, str(app_id), err_prefix=err_prefix)
|
|
)
|
|
return str(app_id), primary
|
|
|
|
app_id = t
|
|
primary = norm_domain(
|
|
resolve_primary_domain_from_app(domains, app_id, err_prefix=err_prefix)
|
|
)
|
|
return app_id, primary
|
|
|
|
|
|
def is_onion_domain(domain: Any) -> bool:
|
|
"""True if the domain is a Tor v3 ``.onion`` address."""
|
|
return norm_domain(domain).endswith(".onion")
|
|
|
|
|
|
def resolve_enabled(
|
|
app: dict, enabled_default: bool, *, primary_domain: str = ""
|
|
) -> bool:
|
|
if is_onion_domain(primary_domain):
|
|
return False
|
|
override = get_path(app, "server.tls.enabled", None)
|
|
return enabled_default if override is None else bool(override)
|
|
|
|
|
|
def align_domain_to_consumer(
|
|
domains: dict,
|
|
app_id: str,
|
|
primary_domain: str,
|
|
*,
|
|
consumer: str = "",
|
|
variables: dict | None = None,
|
|
templar: Any = None,
|
|
) -> str:
|
|
"""Family-align a cross-app reference: when a clearnet-primary consumer
|
|
resolves an onion-primary target, return the target's first clearnet
|
|
domain from the merged map; keep ``primary_domain`` whenever the consumer
|
|
is unknown/onion, the target is clearnet, or no clearnet sibling exists.
|
|
"""
|
|
if not is_onion_domain(primary_domain):
|
|
return primary_domain
|
|
raw = consumer or (variables or {}).get("application_id", "")
|
|
if templar is not None and isinstance(raw, str) and raw:
|
|
raw = templar.template(raw)
|
|
consumer_id = as_str(raw)
|
|
if not consumer_id or consumer_id == app_id or consumer_id not in domains:
|
|
return primary_domain
|
|
consumer_primary = resolve_primary_domain_from_app(
|
|
domains, consumer_id, err_prefix="tls_common"
|
|
)
|
|
if is_onion_domain(consumer_primary):
|
|
return primary_domain
|
|
for candidate in iter_domains(domains.get(app_id)):
|
|
if not is_onion_domain(candidate):
|
|
return candidate
|
|
return primary_domain
|
|
|
|
|
|
def resolve_mode(
|
|
app: dict, enabled: bool, mode_default: str, *, err_prefix: str
|
|
) -> str:
|
|
if not enabled:
|
|
return "off"
|
|
override = get_path(app, "server.tls.mode", None)
|
|
if not (isinstance(override, str) and override.strip()):
|
|
override = get_path(app, "server.tls.flavor", None)
|
|
if isinstance(override, str) and override.strip():
|
|
mode = override.strip()
|
|
else:
|
|
mode = mode_default
|
|
|
|
if mode not in AVAILABLE_FLAVORS:
|
|
raise AnsibleError(
|
|
f"{err_prefix}: TLS_MODE/server.tls.flavor must be one of {sorted(AVAILABLE_FLAVORS)}, got '{mode}'"
|
|
)
|
|
return mode
|
|
|
|
|
|
def resolve_le_name(app: dict, domain: str) -> str:
|
|
override = get_path(app, "server.tls.letsencrypt_cert_name", "")
|
|
name = as_str(override)
|
|
return name or domain
|
|
|
|
|
|
def override_san_list(app: dict) -> list[str] | None:
|
|
raw = get_path(app, "server.tls.domains_san", None)
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, str):
|
|
s = raw.strip()
|
|
return [s] if s else []
|
|
if isinstance(raw, list):
|
|
return [str(x).strip() for x in raw if str(x).strip()]
|
|
return []
|