mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
1 parent
c924b535ab
commit
20ddda8c1c
8 files changed
+207
-1
No files matched your search
@@ -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"))
|
||||
]
|
||||
@@ -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 %}
|
||||
@@ -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()
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user