Files
computer-playbook/plugins/lookup/email.py
T
kevinveenbirkenbachandClaude Opus 5.5 08ef66d8de fix(web-app-stalwart): authenticate bots with an app password under SSO
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>
2026-10-08 08:57:22 +02:00

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