Files
computer-playbook/plugins/lookup/cert.py
T
kevinveenbirkenbachandClaude Opus 5.5 47160d755e fix(tls): serve every vhost the certificate of its own domain
ssl_credentials.j2 resolved the certificate by application_id, so every vhost of an application got the lineage of its primary domain. echoserver serves webmail.infinito.nexus the CN=academy.infinito.nexus certificate that names mail.infinito.nexus but not webmail, and browsers refuse it with a hostname mismatch. The template and the letsencrypt existence check now resolve by the vhost's domain, so a secondary vhost points at its own live/<domain> link and a missing link triggers the issuance instead of a mismatch. Primary vhosts resolve to the same files as before in every TLS mode and flavor.

The LDAP stream passes its primary domain explicitly. Onion domains stay on the application lineage, and the app-scoped self-signed path always names the application leaf, because neither has a per-domain certificate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 23:41:33 +02:00

248 lines
8.0 KiB
Python

# Certificate planning lookup for Infinito.Nexus:
# - Computes certificate file paths (cert/key/ca)
# - Computes effective SAN list (domains.san)
# - Supports self-signed scope: "app" | "global"
#
# See utils/tls_common.py for shared resolution logic.
from __future__ import annotations
from pathlib import Path
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from utils.templating.jinja import render_strict
from utils.tls_common import (
AVAILABLE_FLAVORS,
as_str,
collect_domains_for_app,
is_domain_term,
is_onion_domain,
norm_domain,
override_san_list,
redirect_targets,
require,
resolve_enabled,
resolve_le_name,
resolve_mode,
resolve_primary_domain_from_app,
resolve_term,
uniq_preserve,
want_get,
)
LE_FULLCHAIN = "fullchain.pem"
LE_PRIVKEY = "privkey.pem"
PER_DOMAIN_LIVE_FLAVORS = frozenset({"san", "dedicated"})
def _join(*parts: Any) -> str:
cleaned = [str(p).strip() for p in parts if str(p).strip()]
return str(Path(*cleaned)) if cleaned else ""
def _require_current_play_domains_all_strict(variables: dict) -> list[str]:
"""
STRICT FORMAT REQUIREMENT
CURRENT_PLAY_DOMAINS_ALL MUST:
- exist
- be list[str]
- be non-empty
- contain only non-empty strings
No fallback. No coercion. No dict support.
"""
value = variables.get("CURRENT_PLAY_DOMAINS_ALL")
if not isinstance(value, list):
raise AnsibleError(
"cert(strict): CURRENT_PLAY_DOMAINS_ALL must be of type list[str]. "
f"Got {type(value).__name__}."
)
cleaned: list[str] = []
for raw_item in value:
if not isinstance(raw_item, str):
raise AnsibleError(
"cert(strict): CURRENT_PLAY_DOMAINS_ALL must contain only strings."
)
item = raw_item.strip()
if not item:
raise AnsibleError(
"cert(strict): CURRENT_PLAY_DOMAINS_ALL must not contain empty strings."
)
cleaned.append(item)
if not cleaned:
raise AnsibleError("cert(strict): CURRENT_PLAY_DOMAINS_ALL must not be empty.")
return cleaned
class LookupModule(LookupBase):
def run(self, terms, variables: dict | None = None, **kwargs):
variables = variables or {}
if not terms or len(terms) not in (1, 2):
raise AnsibleError(
"cert: one or two terms required: (domain|application_id[, want_path])"
)
term = as_str(terms[0])
if not term:
raise AnsibleError("cert: term is empty")
want = as_str(terms[1]).strip() if len(terms) == 2 else ""
domains = lookup_loader.get(
"domains", loader=self._loader, templar=getattr(self, "_templar", None)
).run([], variables=variables)[0]
applications = lookup_loader.get(
"applications", loader=self._loader, templar=getattr(self, "_templar", None)
).run([], variables=variables)[0]
enabled_default = require(variables, "TLS_ENABLED", (bool, int))
mode_default = as_str(require(variables, "TLS_MODE", str))
if mode_default not in AVAILABLE_FLAVORS:
raise AnsibleError(
f"cert: TLS_MODE must be one of {sorted(AVAILABLE_FLAVORS)}, got '{mode_default}'"
)
forced_mode = as_str(kwargs.get("mode", "auto")).lower()
app_id, primary_domain = resolve_term(
term,
domains=domains,
applications=applications,
redirects=redirect_targets(variables),
forced_mode=forced_mode,
err_prefix="cert",
)
app = applications.get(app_id, {})
if not isinstance(app, dict):
app = {}
enabled = resolve_enabled(app, bool(enabled_default))
mode = resolve_mode(app, enabled, mode_default, err_prefix="cert")
cert_file = ""
key_file = ""
ca_file = ""
san_domains: list[str] = []
cert_id = ""
scope = "app"
if mode == "off":
pass
elif mode == "letsencrypt":
le_live_raw = require(variables, "LETSENCRYPT_LIVE_PATH", str)
le_live = render_strict(
le_live_raw,
variables=variables,
var_name="LETSENCRYPT_LIVE_PATH",
err_prefix="cert",
)
flavor = as_str(require(variables, "CERTBOT_FLAVOR", str))
domain_term = is_domain_term(term, forced_mode, err_prefix="cert")
if domain_term and is_onion_domain(term):
le_domain = norm_domain(
resolve_primary_domain_from_app(domains, app_id, err_prefix="cert")
)
elif domain_term and flavor in PER_DOMAIN_LIVE_FLAVORS:
le_domain = norm_domain(term)
else:
le_domain = primary_domain
le_name = resolve_le_name(app, le_domain)
cert_id = le_name
cert_file = _join(le_live, le_name, LE_FULLCHAIN)
key_file = _join(le_live, le_name, LE_PRIVKEY)
all_domains = collect_domains_for_app(domains, app_id, err_prefix="cert")
all_domains = (
uniq_preserve([primary_domain, *all_domains])
if all_domains
else [primary_domain]
)
san_override = override_san_list(app)
if san_override is None:
san_domains = all_domains[:]
else:
san_domains = uniq_preserve([primary_domain, *san_override])
elif mode == "self_signed":
ss_base_raw = require(variables, "TLS_SELFSIGNED_BASE_PATH", str)
ss_base = render_strict(
ss_base_raw,
variables=variables,
var_name="TLS_SELFSIGNED_BASE_PATH",
err_prefix="cert",
)
ss_scope = as_str(variables.get("TLS_SELFSIGNED_SCOPE")).lower()
if ss_scope not in {"app", "global"}:
raise AnsibleError(
"cert: TLS_SELFSIGNED_SCOPE must be 'app' or 'global'"
)
scope = ss_scope
if ss_scope == "global":
cert_id = "global"
cert_file = _join(ss_base, cert_id, LE_FULLCHAIN)
key_file = _join(ss_base, cert_id, LE_PRIVKEY)
san_domains = _require_current_play_domains_all_strict(variables)
if primary_domain:
san_domains = uniq_preserve([primary_domain, *san_domains])
else:
cert_id = app_id
app_primary = norm_domain(
resolve_primary_domain_from_app(domains, app_id, err_prefix="cert")
)
cert_file = _join(ss_base, app_id, app_primary, LE_FULLCHAIN)
key_file = _join(ss_base, app_id, app_primary, LE_PRIVKEY)
all_domains = collect_domains_for_app(
domains, app_id, err_prefix="cert"
)
all_domains = (
uniq_preserve([primary_domain, *all_domains])
if all_domains
else [primary_domain]
)
san_override = override_san_list(app)
if san_override is None:
san_domains = all_domains[:]
else:
san_domains = uniq_preserve([primary_domain, *san_override])
else:
raise AnsibleError(f"cert: unsupported mode '{mode}'")
resolved: dict[str, Any] = {
"application_id": app_id,
"domain": primary_domain,
"enabled": enabled,
"mode": mode,
"scope": scope,
"cert_id": cert_id,
"domains": {"san": san_domains},
"files": {"cert": cert_file, "key": key_file, "ca": ca_file},
}
if want:
return [want_get(resolved, want)]
return [resolved]