fix(nextcloud): reach the onion OIDC issuer over Tor SOCKS again

libcurl refuses a .onion host by name (RFC 7686, error 6) before it
consults /etc/hosts, extra_hosts or DNS, so the platform's DNS+iptables
onion egress cannot serve a server-side backchannel at all. Measured:

    curl --noproxy '*'                          -> exit 6
    curl --resolve auth.<onion>:80:127.0.0.1 …  -> exit 6

--resolve fills curl's own cache strictly earlier than getaddrinfo and
is still refused, which makes sys-svc-container's onion_oidc_socks
template -- extra_hosts, despite its name -- a no-op for every libcurl
client. Only a configured proxy makes curl skip the check.

Since 9d91fbea8 emptied proxy.config.php.j2 there is no such proxy, while
svc-net-tor's exclusive/primary defaults replace Keycloak's clearnet
domain and KC_HOSTNAME pins the issuer to the onion. Nextcloud's
oidc_login discovery call therefore dies inside PHP, /apps/oidc_login/oidc
returns 500, and oidc_login_auto_redirect sends /login straight there.

Restoring the old socks5h://host.docker.internal:9050 alone would make it
worse: tor runs as a single manager-pinned replica publishing SOCKS with
mode: host, so only the replica on that node could connect and a
deterministic failure would become a 2-in-3 flake. In swarm, though, tor
is NOT host-networked -- that branch is compose-only -- and it already
binds SocksPort 0.0.0.0, so the shared overlay is a usable path from
every node.

Give svc-net-tor a shared_net overlay with alias 'tor' and dial it by
service name. Attachment is gated by a new consumer kind, onion_sso: the
consumer's services.sso.enabled AND the provider's services.tor.enabled.
Both are group-membership derived, so the conjunction is exactly "the
issuer is an onion" and stays false in clearnet deployments -- which
matters because the service registry is not filtered to deployed roles.
The existing kinds could not express it: keying on sso alone would also
hold without tor, and keying on tor would miss Nextcloud's variant 2,
which disables its own onion yet still needs the client path.

tor_socks_proxy keeps the swarm/compose split in one place so the six
roles sharing this defect need not restate it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-10 00:50:48 +02:00
1 parent c924b535ab
commit 20ddda8c1c
8 files changed
+207 -1

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
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.tor_socks import resolve_socks_endpoint
_SWARM_HOST = "tor"
_COMPOSE_HOST = "host.docker.internal"
def resolve_proxy_url(applications: dict[str, Any], deployment_mode: str) -> str:
"""Return the ``socks5h://`` URL a sibling container uses to reach Tor:
the ``tor`` overlay alias in swarm, the host gateway in compose.
"""
host = _SWARM_HOST if str(deployment_mode).strip() == "swarm" else _COMPOSE_HOST
return f"socks5h://{resolve_socks_endpoint(applications, host)}"
class LookupModule(LookupBase):
"""
Usage:
{{ lookup('tor_socks_proxy') }} -> socks5h://tor:<port> (swarm)
-> socks5h://host.docker.internal:<port> (compose)
Single spot for the outbound Tor SOCKS proxy URL of a containerised client,
so the swarm/compose split is not restated per consuming role.
"""
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[str]:
if terms:
raise AnsibleError("lookup('tor_socks_proxy') expects no terms.")
variables = variables or getattr(self._templar, "available_variables", {}) or {}
applications = lookup_loader.get(
"applications",
loader=getattr(self, "_loader", None),
templar=getattr(self, "_templar", None),
).run([], variables=variables, roles_dir=kwargs.get("roles_dir"))[0]
return [
resolve_proxy_url(applications, variables.get("DEPLOYMENT_MODE", "compose"))
]
+9
View File
@@ -4,3 +4,12 @@
# test_compose_roles_have_local_network). Kept minimal; not used at runtime.
local:
subnet: 192.168.74.0/24
overlay:
modes: [swarm]
topology: shared_net
aliases: [tor]
consumer:
kind: onion_sso
key: sso
provider: web-app-keycloak
provider_key: tor
@@ -1,2 +1,13 @@
<?php
{% if NEXTCLOUD_SSO_ENABLED and (NEXTCLOUD_OIDC_HOST is search('\.onion$')) %}
return [
'proxy' => '{{ NEXTCLOUD_TOR_SOCKS_PROXY }}',
'proxyexclude' => [
{% for host in NEXTCLOUD_PROXY_EXCLUDED_HOSTS %}
'{{ host }}',
{% endfor %}
],
];
{% else %}
return [];
{% endif %}
+9
View File
@@ -14,6 +14,8 @@ NEXTCLOUD_PROTOCOL: "{{ lookup('tls', application_id, 'protocols.web') }}"
NEXTCLOUD_TRUSTED_PROXIES: "{{ networks.internet.values() | select }}"
NEXTCLOUD_SSO_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') | bool }}"
NEXTCLOUD_OIDC_HOST: "{{ lookup('tls', 'web-app-keycloak', 'domain') }}"
NEXTCLOUD_TOR_SOCKS_PROXY: "{{ lookup('tor_socks_proxy') }}"
NEXTCLOUD_PLUGIN_ITEMS: "{{ lookup('config', application_id, 'addons') | dict2items }}"
NEXTCLOUD_PLUGINS_ENABLED: "{{ lookup('config', application_id, 'services.nextcloud.plugins_enabled') }}"
@@ -45,6 +47,13 @@ NEXTCLOUD_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, NEX
NEXTCLOUD_OPENPROJECT_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-openproject', 'web') }}"
NEXTCLOUD_ZAMMAD_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-zammad', 'railsserver') }}"
NEXTCLOUD_PROXY_EXCLUDED_HOSTS:
- apps.nextcloud.com
- github.com
- .githubusercontent.com
- codeberg.org
- gitlab.com
NEXTCLOUD_PROXY_SERVICE: "proxy"
NEXTCLOUD_PROXY_CONTAINER: "{{ lookup('config', application_id, 'services.' ~ NEXTCLOUD_PROXY_SERVICE ~ '.name') }}"
@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from ansible.errors import AnsibleError
from plugins.lookup.tor_socks_proxy import LookupModule, resolve_proxy_url
APPS = {"svc-net-tor": {"services": {"tor": {"ports": {"local": {"socks": 9050}}}}}}
class TestResolveProxyUrl(unittest.TestCase):
def test_swarm_uses_the_shared_overlay_alias(self):
self.assertEqual(resolve_proxy_url(APPS, "swarm"), "socks5h://tor:9050")
def test_compose_uses_the_host_gateway(self):
self.assertEqual(
resolve_proxy_url(APPS, "compose"),
"socks5h://host.docker.internal:9050",
)
def test_unknown_mode_falls_back_to_the_compose_form(self):
for mode in ("", " ", "kubernetes", None):
with self.subTest(mode=mode):
self.assertEqual(
resolve_proxy_url(APPS, mode),
"socks5h://host.docker.internal:9050",
)
def test_port_comes_from_the_single_source_of_truth(self):
apps = {"svc-net-tor": {"services": {"tor": {"ports": {"local": {"socks": 9150}}}}}}
self.assertEqual(resolve_proxy_url(apps, "swarm"), "socks5h://tor:9150")
def test_missing_port_raises(self):
with self.assertRaises(AnsibleError):
resolve_proxy_url({"svc-net-tor": {"services": {"tor": {}}}}, "swarm")
class TestTorSocksProxyLookup(unittest.TestCase):
def test_terms_raise(self):
lookup = LookupModule()
lookup._templar = None
with self.assertRaises(AnsibleError):
lookup.run(["x"], variables={})
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -208,6 +208,66 @@ class TestIsConsumer(unittest.TestCase):
)
class TestIsConsumerOnionSso(unittest.TestCase):
ENTRY = {
"role": "svc-net-tor",
"overlay": {
"consumer": {
"kind": "onion_sso",
"key": "sso",
"provider": "web-app-keycloak",
"provider_key": "tor",
}
},
}
@staticmethod
def _lookup(*, sso, provider_tor):
def _inner(app, path, default):
if app == "web-app-keycloak" and path == "services.tor.enabled":
return provider_tor
if path == "services.sso.enabled":
return sso
return default
return _inner
def _consumer(self, *, sso, provider_tor, entry=None):
return _is_consumer(
entry or self.ENTRY,
"web-app-nextcloud",
self._lookup(sso=sso, provider_tor=provider_tor),
_const_lookup_database(),
)
def test_attaches_only_when_sso_consumer_meets_onion_provider(self):
cases = [
(True, True, True),
(True, False, False),
(False, True, False),
(False, False, False),
]
for sso, provider_tor, expected in cases:
with self.subTest(sso=sso, provider_tor=provider_tor):
self.assertIs(
self._consumer(sso=sso, provider_tor=provider_tor), expected
)
def test_provider_is_mandatory(self):
entry = {"role": "svc-net-tor", "overlay": {"consumer": {"kind": "onion_sso"}}}
self.assertFalse(self._consumer(sso=True, provider_tor=True, entry=entry))
def test_provider_key_defaults_to_tor(self):
entry = {
"role": "svc-net-tor",
"overlay": {
"consumer": {"kind": "onion_sso", "provider": "web-app-keycloak"}
},
}
self.assertTrue(self._consumer(sso=True, provider_tor=True, entry=entry))
self.assertFalse(self._consumer(sso=True, provider_tor=False, entry=entry))
class TestComputeAttachments(unittest.TestCase):
def test_skips_canonical_aliases(self):
registry = {
+13
View File
@@ -38,6 +38,19 @@ def _is_consumer(
):
return False
return True
if kind == "onion_sso":
provider = consumer.get("provider")
if not provider:
return False
key = consumer.get("key") or "sso"
if not _coerce_bool(
lookup_config(application_id, f"services.{key}.enabled", False)
):
return False
provider_key = consumer.get("provider_key") or "tor"
return _coerce_bool(
lookup_config(provider, f"services.{provider_key}.enabled", False)
)
if kind == "web_facing":
return application_id.startswith(("web-app-", "web-svc-"))
return False
+6 -1
View File
@@ -13,9 +13,14 @@ discovered into the service_registry by ``discover_role_services``. Keys:
* ``topology``: ``shared_net`` | ``default_net``. Absent = beacon-only (no attachment)
* ``aliases``: list of DNS aliases. Default: ``[entity_name]`` for shared_net, ``[]`` for default_net
* ``consumer``: optional override
* ``kind``: ``services_flags`` (default) | ``database``
* ``kind``: ``services_flags`` (default) | ``database`` | ``web_facing`` |
``onion_sso``
* ``key``: services.<key>.* lookup base. Default: provides or entity_name
* ``flags``: list of flags to AND. Default: ``[enabled, shared]``
(``services_flags`` only)
* ``provider`` / ``provider_key``: for ``onion_sso``, the app whose
``services.<provider_key>.enabled`` must also hold. Default provider_key
``tor``
* ``proxy_resolvable``: beacon flag - the harvested aliases get attached to
every ``default_net`` provider and every ``collect_proxy_resolvable``
provider in the same mode.