mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 12:17:13 +00:00
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>
248 lines
8.0 KiB
Python
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]
|