feat(UI): fix migration, errors happening in local deploy and swarm integraton issues, tor merged and implement stalwart with tor

This commit is contained in:
Alejandro Roman committed 2026-09-01 12:34:57 +04:00
1 parent 333079cc2a
commit accc9e4c7c
18 files changed
+421 -37

No files matched your search

@@ -309,6 +309,68 @@ class TestEmailLookup(unittest.TestCase):
self.assertTrue(result["auth"])
self.assertFalse(result["start_tls"])
def test_a_provider_without_plaintext_submission_falls_back_to_its_mx(
self,
) -> None:
"""Stalwart's default config binds the implicit-TLS client ports and the
MX, never 587, so the onion path (which drops TLS) has no submission
listener to reach. The port follows what the provider declares, and AUTH
drops with it: an MX offers none, and announcing it aborts msmtp with
EX_UNAVAILABLE before a message is ever sent."""
_write_role_config(
self._tmp,
"web-app-mailprov",
{"mailprov": {"ports": {"public": {"smtp": 25, "smtps": 465}}}},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
"group_names": ["web-app-mailprov"],
"groups": {"web-app-mailprov": ["host1"]},
"SYSTEM_EMAIL_HOST": "mail.x.onion",
"TLS_ENABLED": True,
"DOMAIN_PRIMARY": "x.onion",
"inventory_hostname": "host1",
}
result = self.lookup.run(
[], variables=variables, roles_dir=str(self._tmp / "roles")
)[0]
self.assertFalse(result["tls"])
self.assertEqual(result["port"], 25)
self.assertFalse(result["auth"])
self.assertEqual(result["auth_mechanism"], "off")
def test_a_provider_declaring_submission_keeps_the_authenticated_path(
self,
) -> None:
"""Mailu binds 587, so the same onion target stays on plaintext
submission with AUTH PLAIN. The MX fallback above must not reach a
provider that declares a submission listener."""
_write_role_config(
self._tmp,
"web-app-mailprov",
{
"mailprov": {
"ports": {"public": {"smtp": 25, "smtps": 465, "submission": 587}}
}
},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
"group_names": ["web-app-mailprov"],
"groups": {"web-app-mailprov": ["host1"]},
"SYSTEM_EMAIL_HOST": "mail.x.onion",
"TLS_ENABLED": True,
"DOMAIN_PRIMARY": "x.onion",
"inventory_hostname": "host1",
}
result = self.lookup.run(
[], variables=variables, roles_dir=str(self._tmp / "roles")
)[0]
self.assertFalse(result["tls"])
self.assertEqual(result["port"], 587)
self.assertTrue(result["auth"])
self.assertEqual(result["auth_mechanism"], "plain")
def test_sso_relay_active_with_untemplated_enabled_gate(self) -> None:
# The role default gates sso.enabled on group_names (raw Jinja here);
# the guard must not treat the untemplated string as false.
@@ -37,6 +37,21 @@ class _DummyTemplar:
return value
class _RenderingTemplar:
"""A templar that actually resolves expressions, like the real one.
``_DummyTemplar`` returns its input unchanged, so it cannot expose a value
that only misbehaves while still an unrendered template.
"""
def __init__(self, available_variables=None, rendered=None):
self.available_variables = available_variables or {}
self._rendered = rendered or {}
def template(self, value):
return self._rendered.get(value, value)
def _email(external=True, host=RELAY):
return {"external": external, "host": host}
@@ -67,6 +82,18 @@ class TestSmtpHostLookup(unittest.TestCase):
vars_ = {"DOCKER_IN_CONTAINER": True, "DEPLOYMENT_MODE": "compose"}
self.assertEqual(self._resolve(vars_), "127.0.0.1")
def test_an_unrendered_group_var_still_reaches_the_rig_loopback(self):
"""DOCKER_IN_CONTAINER is a group_vars expression, and a lookup receives it
raw. Read unrendered it is neither true nor false, so the rig kept the
configured relay instead of loopback — and on an onion node that relay is a
.onion, which msmtp cannot route (EX_UNAVAILABLE, 'proxy failure')."""
raw = "{{ (ansible_facts['env']['container'] | default('')) != '' }}"
vars_ = {"DOCKER_IN_CONTAINER": raw, "DEPLOYMENT_MODE": "compose"}
lm = self.LookupModule()
lm._templar = _RenderingTemplar(vars_, {raw: True})
lm._loader = None
self.assertEqual(lm.run([_email()], variables=vars_)[0], "127.0.0.1")
def test_swarm_node_uses_loopback(self):
vars_ = {
"DOCKER_IN_CONTAINER": True,
@@ -12,6 +12,7 @@ filter shim, since ``bool`` is an Ansible filter.
from __future__ import annotations
import re
import unittest
from jinja2 import Environment, StrictUndefined
@@ -45,17 +46,38 @@ def _env() -> Environment:
)
env.filters["bool"] = bool
env.filters["on_off"] = lambda value: "on" if value else "off"
env.filters["regex_replace"] = lambda value, pattern, repl="": re.sub(
pattern, repl, str(value)
)
return env
def _render(*, in_container: bool, ca_injected: bool, **extra) -> str:
def _render(
*, in_container: bool, ca_injected: bool, email=None, smtp_host=None, **extra
) -> str:
"""Render msmtprc.
Args:
in_container: whether the rendered file is mounted into a container.
ca_injected: what the ca_injected lookup reports.
email: the email mapping the lookup returns.
smtp_host: the host smtp_host resolves to. Defaults to the configured
relay, which is what the resolver returns outside the rig; pass
loopback to exercise the in-rig path.
"""
email_config = email if email is not None else EMAIL
def lookup(kind, *args, **kwargs):
if kind == "email":
return EMAIL
return email_config
if kind == "smtp_host":
return "relay"
return smtp_host if smtp_host is not None else email_config["host"]
if kind == "ca_injected":
return ca_injected
if kind == "tor_socks_proxy":
return "socks5h://tor:9050"
if kind == "tor_socks":
return "127.0.0.1:9050"
raise AssertionError(f"unexpected lookup: {kind}")
template = _env().from_string(read_text(str(TEMPLATE)))
@@ -101,5 +123,60 @@ class TestMsmtprcCaTrust(unittest.TestCase):
self.assertIn(trust, (None, CA_CONTAINER), f"host path leaked: {trust}")
class TestMsmtprcOnionProxy(unittest.TestCase):
"""Reaching a .onion relay at all.
A .onion peer has no route off the node, so msmtp has to hand the connection
to Tor's SOCKS5 listener. Without it every send fails "cannot connect ...
Connection refused" and the hlth-msmtp unit takes the deploy down with it.
"""
ONION_EMAIL = dict(EMAIL, host="mail.abc123.onion", tls=False, port=25)
def _proxy(self, rendered: str) -> dict[str, str]:
found = {}
for line in rendered.splitlines():
parts = line.split()
if len(parts) == 2 and parts[0] in ("proxy_host", "proxy_port"):
found[parts[0]] = parts[1]
return found
def test_a_clearnet_relay_names_no_proxy(self):
rendered = _render(in_container=False, ca_injected=False)
self.assertEqual(self._proxy(rendered), {})
def test_an_onion_relay_is_reached_through_the_node_socks_listener(self):
rendered = _render(
in_container=False, ca_injected=False, email=self.ONION_EMAIL
)
self.assertEqual(
self._proxy(rendered), {"proxy_host": "127.0.0.1", "proxy_port": "9050"}
)
def test_a_rig_loopback_target_is_never_routed_through_tor(self):
"""Inside the rig smtp_host returns loopback even when the provider's canonical
is an onion. Gating the proxy on email.host instead of the dialled host sent
127.0.0.1 through Tor, and every send hung until the timeout killed it
(rc=124)."""
rendered = _render(
in_container=False,
ca_injected=False,
email=self.ONION_EMAIL,
smtp_host="127.0.0.1",
)
self.assertEqual(self._proxy(rendered), {})
def test_a_container_render_reaches_tor_over_the_shared_endpoint(self):
rendered = _render(
in_container=True,
ca_injected=False,
email=self.ONION_EMAIL,
application_id="web-app-moodle",
)
self.assertEqual(
self._proxy(rendered), {"proxy_host": "tor", "proxy_port": "9050"}
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,92 @@
"""Whether Roundcube forces https on its generated URLs, per canonical scheme.
Roundcube derives the OAuth ``redirect_uri`` from the request scheme, and the
reverse proxy speaks plain HTTP to the container. Forcing https is therefore
right on a clearnet vhost and wrong on an onion one, which is served over HTTP
because Tor carries the encryption. Getting it wrong is not cosmetic: Keycloak
compares the ``redirect_uri`` against the client's registered list verbatim and
answers "Invalid parameter: redirect_uri" on a scheme mismatch, so the webmail
login dies before the form is ever drawn.
Both sides are built from ``canonical_url``, so the assertion here is that the
template follows that one source rather than a hard-coded scheme.
"""
from __future__ import annotations
import unittest
from jinja2 import Environment, StrictUndefined
from utils.cache.files import read_text
from . import PROJECT_ROOT
TEMPLATE = (
PROJECT_ROOT / "roles" / "web-app-stalwart" / "templates" / "roundcube-oauth.inc.php.j2"
)
FORCE_HTTPS = "$_SERVER['HTTPS'] = 'on';"
ONION_BASE = "http://webmail.4dl6yijmuksgh5bfaf3csqoelji7zptyuo6wy5g5prjpnk6vdbzfwnad.onion"
CLEARNET_BASE = "https://webmail.infinito.example"
def _render(base_url: str, *, sso_enabled: bool = True, socks_proxy: str = "") -> str:
env = Environment(
trim_blocks=True,
lstrip_blocks=True,
undefined=StrictUndefined,
autoescape=False, # noqa: S701 - a PHP config file, and Ansible's templar does not escape either
)
env.filters["bool"] = bool
return env.from_string(read_text(TEMPLATE)).render(
STALWART_SSO_ENABLED=sso_enabled,
STALWART_WEBMAIL_BASE_URL=base_url,
STALWART_OIDC_SOCKS_PROXY=socks_proxy,
STALWART_WEBMAIL_OIDC_CLIENT_ID="roundcube",
OIDC={
"BUTTON_TEXT": "Sign in",
"ATTRIBUTES": {"USERNAME": "preferred_username"},
"CLIENT": {
"AUTHORIZE_URL": "https://auth.infinito.example/auth",
"TOKEN_URL": "https://auth.infinito.example/token",
"USER_INFO_URL": "https://auth.infinito.example/userinfo",
"LOGOUT_URL": "https://auth.infinito.example/logout",
},
},
)
class TestRoundcubeOauthScheme(unittest.TestCase):
def test_a_clearnet_vhost_still_forces_https(self):
self.assertIn(FORCE_HTTPS, _render(CLEARNET_BASE))
def test_an_onion_vhost_is_left_on_http(self):
self.assertNotIn(FORCE_HTTPS, _render(ONION_BASE))
def test_the_scheme_is_never_decided_by_the_onion_suffix_alone(self):
self.assertIn(FORCE_HTTPS, _render("https://webmail.example.onion"))
def test_an_onion_issuer_routes_the_back_channel_through_tor(self):
rendered = _render(ONION_BASE, socks_proxy="socks5h://host.docker.internal:9050")
self.assertIn(
"$config['http_client'] = ['proxy' => 'socks5h://host.docker.internal:9050'];",
rendered,
)
def test_the_proxy_must_hand_the_name_to_tor_to_resolve(self):
"""socks5:// resolves locally, which is what cURL refuses to do for .onion."""
rendered = _render(ONION_BASE, socks_proxy="socks5h://host.docker.internal:9050")
self.assertNotIn("'proxy' => 'socks5://", rendered)
def test_a_clearnet_issuer_gets_no_proxy_at_all(self):
self.assertNotIn("http_client", _render(CLEARNET_BASE))
def test_nothing_is_emitted_without_sso(self):
rendered = _render(CLEARNET_BASE, sso_enabled=False, socks_proxy="socks5h://tor:9050")
self.assertNotIn(FORCE_HTTPS, rendered)
self.assertNotIn("oauth_client_id", rendered)
self.assertNotIn("http_client", rendered)
if __name__ == "__main__":
unittest.main()