Files
computer-playbook/utils/networks/render.py
T
kevinveenbirkenbachandClaude Opus 5 20ddda8c1c 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>
2026-08-10 00:50:48 +02:00

248 lines
9.1 KiB
Python

"""Pure-Python rendering of compose networks blocks fed by the service_registry.
Two callable surfaces, mirroring what `roles/sys-svc-compose/templates/networks.yml.j2`
and `roles/sys-svc-container/templates/networks.yml.j2` used to emit:
* :func:`render_compose_networks` -> top-level ``networks:`` block (column 0)
* :func:`render_container_networks` -> per-service ``networks:`` attachment (4-space indent)
The schema lives at ``meta/networks.yml.overlay`` per provider role,
discovered into the service_registry by ``discover_role_services``. Keys:
* ``modes``: list of DEPLOYMENT_MODE values where this overlay applies
* ``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`` | ``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.
* ``proxy_aliases``: aliases the beacon exposes for harvesting; falls back
to ``aliases``. Set it when the overlay also has a topology whose own
``aliases`` (e.g. the entity name) must NOT land on the proxy.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from utils.networks.attachments import (
_coerce_bool,
_compute_attachments,
_is_consumer,
)
__all__ = [
"_coerce_bool",
"_compute_attachments",
"_is_consumer",
"compute_external_network_roles",
"render_compose_networks",
"render_container_networks",
"shared_network_compose_key",
]
def _suppress_default(application_id: str) -> bool:
return application_id.startswith(("svc-db-", "svc-ai-"))
def _own_shared_net_provider(
attachments: list[dict[str, Any]],
own_entity: str,
get_entity_name: Callable[[str], str],
) -> bool:
return any(
att["is_provider"]
and att["topology"] == "shared_net"
and get_entity_name(att["role"]) == own_entity
for att in attachments
)
def _shared_network_key(
attachments: list[dict[str, Any]],
own_entity: str,
get_entity_name: Callable[[str], str],
) -> str:
if _own_shared_net_provider(attachments, own_entity, get_entity_name):
return own_entity
return "default"
def shared_network_compose_key(
*,
application_id: str,
deployment_mode: str,
registry: dict[str, dict[str, Any]],
get_entity_name: Callable[[str], str],
lookup_config: Callable[[str, str, Any], Any],
lookup_database: Callable[[str, str], Any],
node_local: bool = False,
) -> str:
"""Key under which the pre-created ``<entity>`` network appears in the
rendered compose file, which is what docker compose stores in the
``com.docker.compose.network`` label. An app that provides its own
``shared_net`` gets that network under its entity key and renders a separate
unnamed ``default``; every other app renders ``default`` with the entity as
its name. :func:`render_compose_networks` derives its own branch from this
same call, so the label a caller stamps cannot drift from what is rendered.
Args:
application_id: role the network belongs to.
deployment_mode: compose or swarm.
registry: service registry built from the merged applications.
get_entity_name: role id to entity name.
lookup_config: config value accessor.
lookup_database: database value accessor.
node_local: render as compose regardless of deployment_mode.
"""
if node_local:
deployment_mode = "compose"
attachments, _ = _compute_attachments(
registry, application_id, deployment_mode, lookup_config, lookup_database
)
return _shared_network_key(
attachments, get_entity_name(application_id), get_entity_name
)
def compute_external_network_roles(
*,
application_id: str,
deployment_mode: str,
registry: dict[str, dict[str, Any]],
lookup_config: Callable[[str, str, Any], Any],
lookup_database: Callable[[str, str], Any],
) -> list[str]:
"""Provider role names whose overlay ``render_compose_networks`` emits as
``external: true`` for ``application_id``. Mirrors the attachment filter in
:func:`render_compose_networks` (every attachment except a ``default_net``
the app provides itself). Used to pre-create those swarm overlays before
``docker stack deploy``: a consumer can reference a shared provider's
network without that provider role having run in the same play, so the
overlay would otherwise be missing at deploy time.
"""
attachments, _ = _compute_attachments(
registry, application_id, deployment_mode, lookup_config, lookup_database
)
roles: list[str] = []
for att in attachments:
if att["is_provider"] and att["topology"] == "default_net":
continue
role = att["role"]
if role not in roles:
roles.append(role)
return roles
def render_compose_networks(
*,
application_id: str,
deployment_mode: str,
registry: dict[str, dict[str, Any]],
get_entity_name: Callable[[str], str],
lookup_config: Callable[[str, str, Any], Any],
lookup_database: Callable[[str, str], Any],
swarm_encrypted: bool = True,
node_local: bool = False,
) -> str:
if node_local:
deployment_mode = "compose"
attachments, _ = _compute_attachments(
registry, application_id, deployment_mode, lookup_config, lookup_database
)
lines: list[str] = ["networks:"]
for att in attachments:
if att["is_provider"] and att["topology"] == "default_net":
continue
lines.append(f" {get_entity_name(att['role'])}:")
lines.append(" external: true")
own_entity = get_entity_name(application_id)
is_own_shared_net_provider = (
_shared_network_key(attachments, own_entity, get_entity_name) == own_entity
)
if not _suppress_default(application_id):
lines.append(" default:")
if deployment_mode == "swarm":
if not is_own_shared_net_provider and own_entity:
lines.append(f" name: {own_entity}")
lines.append(" driver: overlay")
lines.append(" attachable: true")
lines.append(" driver_opts:")
lines.append(f' encrypted: "{"true" if swarm_encrypted else "false"}"')
if not is_own_shared_net_provider:
subnet = lookup_config(application_id, "networks.local.subnet", "")
if subnet:
lines.append(" ipam:")
lines.append(" driver: default")
lines.append(" config:")
lines.append(f" - subnet: {subnet}")
elif is_own_shared_net_provider:
lines.append(" driver: bridge")
else:
subnet = lookup_config(application_id, "networks.local.subnet", "")
if subnet:
if own_entity:
lines.append(f" name: {own_entity}")
lines.append(" driver: bridge")
lines.append(" ipam:")
lines.append(" driver: default")
lines.append(" config:")
lines.append(f" - subnet: {subnet}")
return "\n".join(lines) + "\n"
def render_container_networks(
*,
application_id: str,
deployment_mode: str,
registry: dict[str, dict[str, Any]],
get_entity_name: Callable[[str], str],
lookup_config: Callable[[str, str, Any], Any],
lookup_database: Callable[[str, str], Any],
provider_self_alias: bool = True,
node_local: bool = False,
) -> str:
if node_local:
deployment_mode = "compose"
attachments, default_aliases = _compute_attachments(
registry, application_id, deployment_mode, lookup_config, lookup_database
)
lines: list[str] = ["networks:"]
for att in attachments:
if att["is_provider"] and att["topology"] == "default_net":
continue
lines.append(f" {get_entity_name(att['role'])}:")
aliases = att["aliases"]
if att["is_provider"] and not provider_self_alias:
aliases = []
if aliases:
lines.append(" aliases:")
lines.extend(f" - {alias}" for alias in aliases)
else:
lines.append(" {}")
if not _suppress_default(application_id):
if default_aliases:
lines.append(" default:")
lines.append(" aliases:")
lines.extend(f" - {alias}" for alias in default_aliases)
else:
lines.append(" default:")
return "\n" + "\n".join(lines)