Files
computer-playbook/plugins/lookup/proxy_excluded_hosts.py
T
kevinveenbirkenbachandClaude Opus 5 9e7c62a119 fix(network): let every proxied role reach its object store directly
A role that exports an outbound proxy for one purpose applies it to every
HTTP call the app makes, so an internal dependency reached by name is proxied
too. web-app-pixelfed exported the Tor SOCKS proxy container-wide for its
onion OIDC issuer and its S3 client then failed with

    cURL error 97: cannot complete SOCKS5 connection to seaweedfs

for six minutes per attempt, until sixty retries had consumed the whole
suite budget and the job was killed. web-app-espocrm ships a byte-identical
proxy block and configures its S3 endpoint from the same lookup.
web-app-nextcloud sets the proxy globally in proxy.config.php and listed two
internal hosts in proxyexclude, neither of them the object store it reaches
by hostname.

9d91fbea83 had tried to retire the per-app proxies in favour of onion routing
over DNS, and 20ddda8c1c restored them for the OIDC issuer, so a broad proxy
is the current design and the exemption list is load-bearing.

lookup('proxy_excluded_hosts', application_id) is now that list: the loopback
forms plus the object store host when the role stores objects. It is a lookup
and not a group_vars value because no group_vars file resolves
application_id, so a global would have depended on which role happened to be
running. Nextcloud keeps its hand-written list of public hosts; only the
internal half is derived.

The lint gained a second rule for the class rather than the symptom: the
nextcloud list named no loopback literal at all, so the literal rule could
never have fired on it. A variable whose name marks it a proxy exclusion list
must render the lookup, unless it only composes other exclusion variables, and
a ..._PUBLIC_... list is exempt because it names no internal service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 17:12:24 +02:00

82 lines
2.9 KiB
Python

from __future__ import annotations
import contextlib
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
def merge_excluded(loopback: list[str], objstore_host: str) -> list[str]:
"""Return the hosts a proxied client must reach directly, in order.
Args:
loopback: the loopback spellings, from ``NETWORK_LOOPBACK_HOSTS``.
objstore_host: object store host, empty when the role stores no
objects or no engine is active.
Returns:
Loopback first, then the object store, without duplicates.
"""
hosts = list(loopback)
if objstore_host and objstore_host not in hosts:
hosts.append(objstore_host)
return hosts
class LookupModule(LookupBase):
"""
Usage:
{{ lookup('proxy_excluded_hosts', application_id) }}
-> ['127.0.0.1', '::1', 'localhost', 'seaweedfs']
Single spot for the internal hosts a role's HTTP client must reach without
its proxy. A role that exports an outbound proxy for one purpose applies it
to every HTTP call the app makes, so an internal dependency reached by name
is proxied too and fails; enumerating the exemptions per role is what left
the object store out of them.
"""
def run(
self,
terms: list[Any] | None,
variables: dict[str, Any] | None = None,
**kwargs: Any,
) -> list[list[str]]:
if not terms or len(terms) != 1:
raise AnsibleError(
"lookup('proxy_excluded_hosts') expects exactly one term: "
"the application_id."
)
application_id = str(terms[0]).strip()
variables = variables or getattr(self._templar, "available_variables", {}) or {}
loopback = variables.get("NETWORK_LOOPBACK_HOSTS")
templar = getattr(self, "_templar", None)
if templar is not None and isinstance(loopback, str):
with contextlib.suppress(Exception):
loopback = templar.template(loopback)
if not isinstance(loopback, list) or not loopback:
raise AnsibleError(
"lookup('proxy_excluded_hosts') needs NETWORK_LOOPBACK_HOSTS "
"from group_vars/all/08_networks.yml; it resolved to "
f"{loopback!r}."
)
objstore = lookup_loader.get(
"objstore",
loader=getattr(self, "_loader", None),
templar=templar,
)
enabled = objstore.run(
[application_id, "enabled"], variables=variables, **kwargs
)[0]
host = ""
if str(enabled).strip().lower() in {"true", "yes", "on", "1"}:
host = str(
objstore.run([application_id, "host"], variables=variables, **kwargs)[0]
).strip()
return [merge_excluded([str(entry) for entry in loopback], host)]