mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Under SSO the role tried to let applications relay without SMTP AUTH by widening the relay rule to the private networks. The rule compared the peer address to the text of a CIDR, which never matches, so an application could reach local mailboxes but got "550 5.1.2 Relay not allowed" for every external recipient. Repairing the comparison would have opened a relay, because published ports, Tor and the swarm mesh all hand outside clients an address inside those networks. Stalwart checks app passwords before it asks the external directory, so a bot can authenticate while the directory is OIDC. With SSO enabled the role now creates one app password per bot, acting for that account as the recovery admin, and stores it in the token store the SMTP password is already read from. A bot gets a new one when its entry is deleted in Stalwart or its token is removed. The relay rule is back to authenticated sessions only. The lookup loses its SSO relay branch and the role its submission_via_relay flag, so an SSO deployment submits on 465 with AUTH like every other one. The role's CLI test now fails when an unauthenticated session is given an external recipient, or when the bot credential is refused one. Closes #793 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
# nocheck: comments-valid explanatory WHY-comments in this SPOT lookup predate the stricter lint
|
|
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 plugins.lookup.applications import LookupModule as ApplicationsLookup
|
|
from plugins.lookup.domain import LookupModule as DomainLookup
|
|
from plugins.lookup.users import LookupModule as UsersLookup
|
|
from utils.domains.primary_domain import get_domain
|
|
from utils.mail.provider import deployed_roles, resolve_active_provider
|
|
from utils.roles.entity.name import get_entity_name
|
|
|
|
SYSTEM_EMAIL_PREFIX = "SYSTEM_EMAIL_"
|
|
|
|
# Default mail provider role; overridable per inventory via the MAIL_PROVIDER var.
|
|
DEFAULT_MAIL_PROVIDER = "web-app-stalwart"
|
|
|
|
# Declared in resolution order so each computed default sees already-resolved
|
|
# predecessors (for example ``port`` depends on ``tls``).
|
|
RESOLUTION_ORDER = (
|
|
"enabled",
|
|
"timeout",
|
|
"external",
|
|
"environment",
|
|
"domain",
|
|
"host",
|
|
"tls",
|
|
"port",
|
|
"auth",
|
|
"auth_mechanism",
|
|
"start_tls",
|
|
"smtp",
|
|
"from",
|
|
"username",
|
|
"password",
|
|
)
|
|
|
|
_TRUE_STRINGS = frozenset({"true", "yes", "1", "on"})
|
|
_FALSE_STRINGS = frozenset({"false", "no", "0", "off"})
|
|
|
|
|
|
def _short_to_var(short_key: str) -> str:
|
|
return SYSTEM_EMAIL_PREFIX + short_key.upper()
|
|
|
|
|
|
def _as_bool(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in _TRUE_STRINGS:
|
|
return True
|
|
if lowered in _FALSE_STRINGS:
|
|
return False
|
|
return bool(value)
|
|
|
|
|
|
def _render(
|
|
value: Any,
|
|
templar: Any | None,
|
|
variables: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
if templar is None:
|
|
return value
|
|
if not isinstance(value, str) or "{{" not in value:
|
|
return value
|
|
# Loop: inventory expressions like DOMAIN_PRIMARY="{{ lookup('env','INFINITO_DOMAIN') }}" need multiple substitution passes.
|
|
from utils.templating.ansible import _templar_render_best_effort
|
|
|
|
base_vars: dict[str, Any] = dict(variables or {})
|
|
for _ in range(4):
|
|
if not isinstance(value, str) or "{{" not in value:
|
|
break
|
|
prev = value
|
|
try:
|
|
value = _templar_render_best_effort(templar, value, base_vars)
|
|
except Exception:
|
|
break
|
|
if value == prev:
|
|
break
|
|
return value
|
|
|
|
|
|
class LookupModule(LookupBase):
|
|
def run(
|
|
self,
|
|
terms: list[Any] | None,
|
|
variables: dict[str, Any] | None = None,
|
|
**kwargs: Any,
|
|
) -> list[Any]:
|
|
terms = terms or []
|
|
if len(terms) > 1:
|
|
raise AnsibleError(
|
|
"email: expected 0 or 1 terms: lookup('email'[, application_id])"
|
|
)
|
|
|
|
variables = variables or getattr(self._templar, "available_variables", {}) or {}
|
|
self._kwargs = kwargs
|
|
|
|
templar = getattr(self, "_templar", None)
|
|
resolved: dict[str, Any] = {}
|
|
for short_key in RESOLUTION_ORDER:
|
|
var_name = _short_to_var(short_key)
|
|
raw = variables.get(var_name)
|
|
if raw is None or raw == "":
|
|
resolved[short_key] = _render(
|
|
self._compute(short_key, resolved, variables),
|
|
templar,
|
|
variables,
|
|
)
|
|
else:
|
|
resolved[short_key] = _render(raw, templar, variables)
|
|
|
|
if len(terms) == 0:
|
|
return [resolved]
|
|
|
|
application_id = str(terms[0]).strip()
|
|
overrides = self._app_email_overrides(application_id, variables)
|
|
merged = dict(resolved)
|
|
for key, value in overrides.items():
|
|
merged[str(key).lower()] = _render(value, templar, variables)
|
|
return [merged]
|
|
|
|
def _compute(
|
|
self,
|
|
short_key: str,
|
|
resolved: dict[str, Any],
|
|
variables: dict[str, Any],
|
|
) -> Any:
|
|
if short_key == "enabled":
|
|
return True
|
|
if short_key == "timeout":
|
|
return "30"
|
|
if short_key == "external":
|
|
# Cluster-wide, not per-host: in swarm the mail provider is
|
|
# manager-pinned so worker group_names lack it, yet every node
|
|
# relays through the central provider (routing mesh). Gate on the
|
|
# group having a host so workers use the authenticated config, not
|
|
# a localhost root sender the provider rejects
|
|
# ("Sender address rejected: Domain not found").
|
|
groups = variables.get("groups") or {}
|
|
return bool(groups.get(self._mail_provider(variables)))
|
|
if short_key == "environment":
|
|
external = _as_bool(resolved.get("external"))
|
|
tls_enabled = _as_bool(variables.get("TLS_ENABLED"))
|
|
docker_in_container = _as_bool(variables.get("DOCKER_IN_CONTAINER"))
|
|
base = "external" if (external or tls_enabled) else "localhost"
|
|
suffix = "_container" if (docker_in_container and not tls_enabled) else ""
|
|
return base + suffix
|
|
if short_key == "domain":
|
|
return variables.get("DOMAIN_PRIMARY")
|
|
if short_key == "tls":
|
|
external = _as_bool(resolved.get("external"))
|
|
if not external:
|
|
return False
|
|
mail_host = str(resolved.get("host") or "").lower()
|
|
if mail_host.endswith(".onion") or self._provider_onion_primary(variables):
|
|
return False
|
|
return _as_bool(variables.get("TLS_ENABLED"))
|
|
if short_key == "port":
|
|
external = _as_bool(resolved.get("external"))
|
|
if not external:
|
|
return 25
|
|
ports = self._provider_ports(variables)
|
|
if _as_bool(resolved.get("tls")):
|
|
return ports.get("smtps", 465)
|
|
# Plaintext submission is the onion/no-TLS path, but a provider is
|
|
# only reachable there if it binds one. Stalwart's default config
|
|
# binds the implicit-TLS client ports plus the MX, so it advertises
|
|
# no `submission`; fall back to the MX port rather than a listener
|
|
# nothing answers on (`auth` drops with it -- an MX takes local
|
|
# recipients without SMTP AUTH).
|
|
return ports.get("submission") or ports.get("smtp", 25)
|
|
if short_key == "host":
|
|
env = resolved.get("environment")
|
|
# `environment` folds TLS_ENABLED into its "external" base, so a
|
|
# non-external relay under TLS would otherwise resolve to the mailu
|
|
# domain while auth/tls/from stay in localhost mode -- an unreachable,
|
|
# inconsistent render that aborts the msmtp health unit. Gate host on
|
|
# `external` directly, like tls/auth/from do.
|
|
if not _as_bool(resolved.get("external")) or env in (
|
|
"external_container",
|
|
"localhost",
|
|
"localhost_container",
|
|
):
|
|
return "localhost"
|
|
return self._lookup_mail_provider_domain(variables)
|
|
if short_key == "auth":
|
|
env = resolved.get("environment")
|
|
if env in ("external_container", "localhost"):
|
|
return False
|
|
# The MX listener offers no SMTP AUTH: when `port` fell back to it
|
|
# because the provider binds no plaintext submission, announcing
|
|
# AUTH aborts msmtp with EX_UNAVAILABLE before it ever sends.
|
|
ports = self._provider_ports(variables)
|
|
if not ports.get("submission") and resolved.get("port") == ports.get(
|
|
"smtp", 25
|
|
):
|
|
return False
|
|
return _as_bool(resolved.get("external"))
|
|
if short_key == "auth_mechanism":
|
|
if not _as_bool(resolved.get("auth")):
|
|
return "off"
|
|
return "on" if _as_bool(resolved.get("tls")) else "plain"
|
|
if short_key == "start_tls":
|
|
return False
|
|
if short_key == "smtp":
|
|
return True
|
|
if short_key == "from":
|
|
external = _as_bool(resolved.get("external"))
|
|
if external:
|
|
no_reply = self._lookup_no_reply_user(variables)
|
|
if isinstance(no_reply, dict):
|
|
email = no_reply.get("email")
|
|
if email:
|
|
return email
|
|
inventory_hostname = variables.get("inventory_hostname") or "localhost"
|
|
return f"root@{inventory_hostname}.localdomain"
|
|
if short_key == "username":
|
|
return resolved.get("from")
|
|
if short_key == "password":
|
|
no_reply = self._lookup_no_reply_user(variables)
|
|
if isinstance(no_reply, dict):
|
|
tokens = no_reply.get("tokens") or {}
|
|
if isinstance(tokens, dict):
|
|
return tokens.get(self._mail_provider(variables), "") or ""
|
|
return ""
|
|
raise AnsibleError(f"email: unknown key {short_key!r}")
|
|
|
|
def _mail_provider(self, variables: dict[str, Any]) -> str:
|
|
value = variables.get("MAIL_PROVIDER")
|
|
configured = str(value).strip() if value else DEFAULT_MAIL_PROVIDER
|
|
return resolve_active_provider(
|
|
configured,
|
|
deployed_roles(variables.get("groups")),
|
|
Path.cwd() / "roles",
|
|
)
|
|
|
|
def _provider_services(self, variables: dict[str, Any]) -> dict[str, Any]:
|
|
"""The active provider's ``services`` block, or ``{}`` when it cannot be
|
|
resolved. The port probe reads the provider's own self-declaration."""
|
|
apps = ApplicationsLookup()
|
|
apps._templar = getattr(self, "_templar", None)
|
|
forwarded = {
|
|
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
|
|
}
|
|
try:
|
|
entry = apps.run(
|
|
[self._mail_provider(variables), {}],
|
|
variables=variables,
|
|
**forwarded,
|
|
)[0]
|
|
except AnsibleError:
|
|
return {}
|
|
if not isinstance(entry, dict):
|
|
return {}
|
|
services = entry.get("services") or {}
|
|
return services if isinstance(services, dict) else {}
|
|
|
|
def _provider_ports(self, variables: dict[str, Any]) -> dict[str, int]:
|
|
"""The SMTP ports the active provider publishes, keyed by protocol.
|
|
|
|
Read from the provider's own ``services.<entity>.ports.public`` rather
|
|
than assumed, so a provider that binds no plaintext ``submission``
|
|
(Stalwart) resolves differently from one that does (Mailu) without
|
|
either being named here.
|
|
"""
|
|
services = self._provider_services(variables)
|
|
entity = get_entity_name(self._mail_provider(variables))
|
|
entry = services.get(entity) or {}
|
|
if not isinstance(entry, dict):
|
|
return {}
|
|
ports = (entry.get("ports") or {}).get("public") or {}
|
|
if not isinstance(ports, dict):
|
|
return {}
|
|
out: dict[str, int] = {}
|
|
for name, value in ports.items():
|
|
try:
|
|
out[str(name)] = int(value)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
def _provider_onion_primary(self, variables: dict[str, Any]) -> bool:
|
|
"""True when the provider's own host serves it onion-first.
|
|
|
|
Resolved from the provider host's ``group_names`` rather than this
|
|
host's: in swarm only the manager is in ``svc-net-tor``, and the
|
|
provider's ``tor.enabled`` renders against ``group_names``, so a
|
|
worker would otherwise see the clearnet name the provider never
|
|
serves a certificate for.
|
|
"""
|
|
groups = variables.get("groups") or {}
|
|
provider = self._mail_provider(variables)
|
|
tor_hosts = set(groups.get("svc-net-tor") or [])
|
|
provider_hosts = [h for h in groups.get(provider) or [] if h in tor_hosts]
|
|
if not provider_hosts:
|
|
return False
|
|
own_groups = list(variables.get("group_names") or [])
|
|
fallback = own_groups + [
|
|
g for g in ("svc-net-tor", provider) if g not in own_groups
|
|
]
|
|
try:
|
|
provider_groups = variables["hostvars"][provider_hosts[0]]["group_names"]
|
|
except (KeyError, TypeError):
|
|
provider_groups = fallback
|
|
provider_view = dict(variables)
|
|
provider_view["group_names"] = list(provider_groups or fallback)
|
|
forwarded = {
|
|
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
|
|
}
|
|
try:
|
|
domains = lookup_loader.get(
|
|
"domains",
|
|
loader=getattr(self, "_loader", None),
|
|
templar=getattr(self, "_templar", None),
|
|
).run([], variables=provider_view, **forwarded)[0]
|
|
domain = get_domain(domains, provider)
|
|
except Exception:
|
|
return False
|
|
return str(domain or "").lower().endswith(".onion")
|
|
|
|
def _lookup_mail_provider_domain(self, variables: dict[str, Any]) -> Any:
|
|
domain_lookup = DomainLookup()
|
|
domain_lookup._templar = getattr(self, "_templar", None)
|
|
try:
|
|
return domain_lookup.run(
|
|
[self._mail_provider(variables)], variables=variables
|
|
)[0]
|
|
except Exception:
|
|
return "localhost"
|
|
|
|
def _lookup_no_reply_user(self, variables: dict[str, Any]) -> dict[str, Any]:
|
|
users_lookup = UsersLookup()
|
|
users_lookup._templar = getattr(self, "_templar", None)
|
|
forwarded = {
|
|
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
|
|
}
|
|
try:
|
|
entry = users_lookup.run(
|
|
["no-reply", {}], variables=variables, **forwarded
|
|
)[0]
|
|
except AnsibleError:
|
|
return {}
|
|
return entry if isinstance(entry, dict) else {}
|
|
|
|
def _app_email_overrides(
|
|
self,
|
|
application_id: str,
|
|
variables: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
apps = ApplicationsLookup()
|
|
apps._templar = getattr(self, "_templar", None)
|
|
forwarded = {
|
|
k: v for k, v in getattr(self, "_kwargs", {}).items() if k == "roles_dir"
|
|
}
|
|
try:
|
|
entry = apps.run(
|
|
[application_id, {}],
|
|
variables=variables,
|
|
**forwarded,
|
|
)[0]
|
|
except AnsibleError:
|
|
return {}
|
|
if not isinstance(entry, dict):
|
|
return {}
|
|
# Per the materialised payload exposes services under the
|
|
# bare ``services`` key (no ``compose.services`` envelope).
|
|
services = entry.get("services") or {}
|
|
email = services.get("email") or {} if isinstance(services, dict) else {}
|
|
if not isinstance(email, dict):
|
|
return {}
|
|
return email
|