mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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:
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()
|
||||
Reference in new issue
Block a user