mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 13:17:07 +00:00
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>
155 lines
5.6 KiB
Python
155 lines
5.6 KiB
Python
"""Attachment computation for the compose networks renderers.
|
|
|
|
Which provider overlays a role attaches to, and which proxy aliases the
|
|
default network harvests, derived from the service_registry. Kept apart from
|
|
the emitters in :mod:`utils.networks.render` so a change to the consumer rules
|
|
cannot be confused with a change to the YAML they produce.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
def _is_consumer(
|
|
entry: dict[str, Any],
|
|
application_id: str,
|
|
lookup_config: Callable[[str, str, Any], Any],
|
|
lookup_database: Callable[[str, str], Any],
|
|
) -> bool:
|
|
overlay = entry.get("overlay") or {}
|
|
consumer = overlay.get("consumer") or {}
|
|
kind = consumer.get("kind") or "services_flags"
|
|
if kind == "database":
|
|
if not _coerce_bool(lookup_database(application_id, "enabled")):
|
|
return False
|
|
if not _coerce_bool(lookup_database(application_id, "shared")):
|
|
return False
|
|
return lookup_database(application_id, "id") == entry.get("role")
|
|
if kind == "services_flags":
|
|
key = consumer.get("key") or entry.get("provides") or entry.get("entity_name")
|
|
flags = consumer.get("flags") or ["enabled", "shared"]
|
|
for flag in flags:
|
|
if not _coerce_bool(
|
|
lookup_config(application_id, f"services.{key}.{flag}", False)
|
|
):
|
|
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
|
|
|
|
|
|
def _coerce_bool(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in ("true", "1", "yes")
|
|
return bool(value)
|
|
|
|
|
|
def _compute_attachments(
|
|
registry: dict[str, dict[str, Any]],
|
|
application_id: str,
|
|
deployment_mode: str,
|
|
lookup_config: Callable[[str, str, Any], Any],
|
|
lookup_database: Callable[[str, str], Any],
|
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
attachments: list[dict[str, Any]] = []
|
|
default_aliases: list[str] = []
|
|
|
|
for entry in registry.values():
|
|
if "canonical" in entry:
|
|
continue
|
|
overlay = entry.get("overlay")
|
|
if not overlay:
|
|
continue
|
|
if deployment_mode not in overlay.get("modes", []):
|
|
continue
|
|
|
|
is_provider = application_id == entry.get("role")
|
|
topology = overlay.get("topology")
|
|
|
|
if is_provider:
|
|
if topology == "default_net":
|
|
default_aliases.extend(overlay.get("aliases") or [])
|
|
for peer in registry.values():
|
|
peer_overlay = peer.get("overlay")
|
|
if not peer_overlay:
|
|
continue
|
|
if not peer_overlay.get("proxy_resolvable"):
|
|
continue
|
|
if deployment_mode not in peer_overlay.get("modes", []):
|
|
continue
|
|
if "canonical" in peer:
|
|
continue
|
|
if peer.get("role") == entry.get("role"):
|
|
continue
|
|
default_aliases.extend(
|
|
peer_overlay.get("proxy_aliases")
|
|
or peer_overlay.get("aliases")
|
|
or []
|
|
)
|
|
elif topology:
|
|
aliases = list(
|
|
overlay.get("aliases", [entry.get("entity_name")])
|
|
or [entry.get("entity_name")]
|
|
)
|
|
if overlay.get("collect_proxy_resolvable"):
|
|
for peer in registry.values():
|
|
peer_overlay = peer.get("overlay")
|
|
if not peer_overlay:
|
|
continue
|
|
if not peer_overlay.get("proxy_resolvable"):
|
|
continue
|
|
if deployment_mode not in peer_overlay.get("modes", []):
|
|
continue
|
|
if "canonical" in peer:
|
|
continue
|
|
if peer.get("role") == entry.get("role"):
|
|
continue
|
|
aliases.extend(
|
|
peer_overlay.get("proxy_aliases")
|
|
or peer_overlay.get("aliases")
|
|
or []
|
|
)
|
|
attachments.append(
|
|
{
|
|
"role": entry["role"],
|
|
"topology": topology,
|
|
"aliases": aliases,
|
|
"is_provider": True,
|
|
}
|
|
)
|
|
continue
|
|
|
|
if not topology:
|
|
continue
|
|
if _is_consumer(entry, application_id, lookup_config, lookup_database):
|
|
attachments.append(
|
|
{
|
|
"role": entry["role"],
|
|
"topology": topology,
|
|
"aliases": [],
|
|
"is_provider": False,
|
|
}
|
|
)
|
|
|
|
return attachments, default_aliases
|