mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(tor): stop rounds from claiming a surface the onion cannot reach
A variant that pins `services.tor.enabled: true` while enabling a role whose
own `meta/services.yml` pins `tor.enabled: false` declares a combination the
deployment cannot hold. The role rides no node onion, so on that round its
surface lives at a clearnet host no onion browser reaches, and the dashboard's
`frame-src 'self' http://*.<onion>` forbids embedding it outright. Run
36651124458 job 109692287333 shows the result: five specs fail at once,
dashboard tile, matomo tracker, css injection, logout and the coturn metric
label, all on hosts that are the only non-onion ones in the whole job.
web-app-nextcloud variant 11 was such a round: bigbluebutton on, tor on, while
bigbluebutton declares no onion. Variant 8 already answers the identical
question for PeerTube (65538c40da, "clearnet, because the OIDC plugin rejects a
plaintext onion issuer"), so variant 11 now does the same. With that,
bigbluebutton never meets a tor node and needs no consumer gates of its own.
Two roles keep a gate, for reasons that differ from each other:
web-app-seaweedfs is not clearnet-only. It deploys on tor as the object store
other roles reach over the internal network, and only its browser frontend is
off there, which `frontend.enabled` on main already says. Its logout, dashboard
and prometheus declarations claimed a surface that round does not deploy, so
they take the same gate the file's own frontend and sso blocks use.
web-svc-coturn belongs on a tor node and cannot serve one: STUN/TURN runs on
UDP plus a 20000-port relay range that no onion descriptor can publish, and its
only HTTP is a gateway answering a plaintext OK. Deploying it is right,
advertising it as a prometheus consumer is not, because that scrape goes
through the browser path. It is exempt from the rule itself, since it claims no
visitable surface, and its `dynamic-flag` markers now carry the reasons the
rule's own message asks for; one of the four was a duplicate of the line below
it and is gone.
The lint states the rule. It found these five pairings on its own, matching a
hand count made before it existed, and reports none after the variant fix.
`test_sso_variant_requires_tor` covers the complementary half, every variant of
a role that has an onion must pin it on, and exempts the clearnet-only role
itself; this rule covers the consumers that bind one. The suppression marker
belongs on the bound role when the reason is that role's own shape, because a
consumer-side marker would also silence every other clearnet role in the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
69f2233530
commit
312d09e290
4 files changed
+159
-14
No files matched your search
@@ -1097,7 +1097,7 @@
|
||||
enabled: false
|
||||
shared: false
|
||||
addons: *addons_static_off
|
||||
# Variant 11: bigbluebutton
|
||||
# Variant 11: bigbluebutton - clearnet, because bigbluebutton rides no node onion
|
||||
- services:
|
||||
container_backup:
|
||||
enabled: false
|
||||
@@ -1183,7 +1183,7 @@
|
||||
enabled: false
|
||||
shared: false # nocheck: variants-static-override backup shared stays literally true
|
||||
tor:
|
||||
enabled: true
|
||||
enabled: false
|
||||
hermes:
|
||||
enabled: false
|
||||
shared: false
|
||||
|
||||
@@ -29,16 +29,16 @@ ldap:
|
||||
shared: false
|
||||
logout:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-svc-logout' in group_names }}"
|
||||
shared: "{{ 'web-svc-logout' in group_names }}"
|
||||
enabled: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
shared: "{{ ('web-svc-logout' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
dashboard:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-dashboard' in group_names }}"
|
||||
shared: "{{ 'web-app-dashboard' in group_names }}"
|
||||
enabled: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
shared: "{{ ('web-app-dashboard' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
prometheus:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-prometheus' in group_names }}"
|
||||
shared: "{{ 'web-app-prometheus' in group_names }}"
|
||||
enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
shared: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
# nocheck: playwright-service-flag — central object-store provider entity
|
||||
seaweedfs:
|
||||
healthcheck:
|
||||
|
||||
@@ -36,7 +36,7 @@ gateway:
|
||||
mem_reservation: 256m
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: dynamic-flag coturn runs with user_auth_enabled false, so it keeps no credential store to back; the entry stays literal until auth is switched on and no template references redis today.
|
||||
redis:
|
||||
backup:
|
||||
disabled: true
|
||||
@@ -44,13 +44,13 @@ redis:
|
||||
shared: "{{ 'svc-db-redis' in group_names }}"
|
||||
prometheus:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-prometheus' in group_names }}"
|
||||
shared: "{{ 'web-app-prometheus' in group_names }}"
|
||||
enabled: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
shared: "{{ ('web-app-prometheus' in group_names) and ('svc-net-tor' not in group_names) }}"
|
||||
|
||||
# nocheck: clearnet-variant-tor — STUN/TURN on UDP plus a 20000-port relay range, which no onion descriptor can publish; the only HTTP is a gateway returning a plaintext OK. No dashboard, logout, css or matomo block and no Playwright spec, so an onion round claims no visitable surface for it. Its one consumer declaration, prometheus, carries the tor gate.
|
||||
tor:
|
||||
enabled: false # nocheck: dynamic-flag
|
||||
shared: false # nocheck: dynamic-flag
|
||||
# nocheck: dynamic-flag
|
||||
enabled: false # nocheck: dynamic-flag no group can turn this on: see the clearnet-variant-tor reason above
|
||||
shared: false # nocheck: dynamic-flag same
|
||||
# nocheck: playwright-service-flag, dynamic-flag infrastructure backup consumer
|
||||
container_backup: # nocheck: bond-baseline backup host joins outside the variant topology
|
||||
bond: 1
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Lint guard: a variant binding a clearnet-only role must not pin the onion on.
|
||||
|
||||
A role whose ``meta/services.yml`` pins ``tor.enabled`` to a literal false rides
|
||||
no node onion, so on a tor deployment its surface lives at a clearnet host that
|
||||
no onion browser reaches, and the dashboard's own
|
||||
``frame-src 'self' http://*.<onion>`` forbids embedding it. A variant that
|
||||
enables such a role and pins ``services.tor.enabled: true`` therefore declares a
|
||||
combination the deployment cannot hold: the round advertises a tile, a tracker,
|
||||
a stylesheet and a scrape target for a surface that is not there.
|
||||
|
||||
``test_sso_variant_requires_tor`` states the complementary half - every variant
|
||||
of a role that has an onion MUST pin it on - and exempts the clearnet-only role
|
||||
itself. This rule covers the consumers it does not: the variants that bind one.
|
||||
|
||||
The project already applies the rule by hand. ``web-app-nextcloud``'s variants
|
||||
head carries "variant 8 pairs PeerTube, which is clearnet-only since 8ffb1f567,
|
||||
so that round cannot pin the onion on".
|
||||
|
||||
Suppression (see ``docs/contributing/actions/testing/suppression.md``):
|
||||
|
||||
* ``# nocheck: clearnet-variant-tor`` in the CLEARNET role's
|
||||
``meta/services.yml``, which exempts it everywhere: a relay with no browser
|
||||
surface of its own carries no tile and no injected asset, so an onion round
|
||||
may deploy it without claiming anything about it.
|
||||
* the same marker in a consumer's ``meta/variants.yml`` for a case that is
|
||||
sound only there.
|
||||
|
||||
The marker belongs on the bound role rather than the consumer whenever the
|
||||
reason is the role's own shape, because a consumer-side marker would also
|
||||
silence the pairings of every other clearnet role in the same file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from utils.annotations.suppress import line_has_rule
|
||||
from utils.cache.files import read_text
|
||||
from utils.cache.yaml import load_yaml_str
|
||||
from utils.roles.entity.name import get_entity_name
|
||||
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_META_VARIANTS
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
ROLES_DIR = PROJECT_ROOT / "roles"
|
||||
_RULE = "clearnet-variant-tor"
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Any:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
text = read_text(str(path))
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
if not text.strip():
|
||||
return None
|
||||
try:
|
||||
return load_yaml_str(text)
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
|
||||
|
||||
def _suppressed(paths: tuple[Path, ...]) -> bool:
|
||||
for path in paths:
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(
|
||||
line_has_rule(line, _RULE) for line in read_text(str(path)).splitlines()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _flag(variant: Any, service: str) -> Any:
|
||||
services = variant.get("services") if isinstance(variant, dict) else None
|
||||
entry = services.get(service) if isinstance(services, dict) else None
|
||||
return entry.get("enabled") if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def clearnet_only_keys() -> dict[str, str]:
|
||||
"""Service key to role id, for every clearnet-only role the rule covers.
|
||||
|
||||
Returns:
|
||||
The key a consumer's variant would bind it under, mapped to the role
|
||||
that declares itself clearnet-only. A role carrying the marker in its
|
||||
own ``meta/services.yml`` is left out, so it is exempt everywhere.
|
||||
"""
|
||||
found: dict[str, str] = {}
|
||||
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
|
||||
services_path = role_dir / ROLE_FILE_META_SERVICES
|
||||
services = _load_yaml(services_path)
|
||||
tor = services.get("tor") if isinstance(services, dict) else None
|
||||
if not isinstance(tor, dict) or tor.get("enabled") is not False:
|
||||
continue
|
||||
if _suppressed((services_path,)):
|
||||
continue
|
||||
key = get_entity_name(role_dir.name)
|
||||
if key:
|
||||
found[key] = role_dir.name
|
||||
return found
|
||||
|
||||
|
||||
class TestClearnetRoleForbidsVariantTor(unittest.TestCase):
|
||||
def test_no_variant_pins_the_onion_while_binding_a_clearnet_role(self) -> None:
|
||||
clearnet = clearnet_only_keys()
|
||||
offenders: list[str] = []
|
||||
|
||||
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
|
||||
variants_path = role_dir / ROLE_FILE_META_VARIANTS
|
||||
variants = _load_yaml(variants_path)
|
||||
if not isinstance(variants, list) or not variants:
|
||||
continue
|
||||
if _suppressed((variants_path,)):
|
||||
continue
|
||||
|
||||
for index, variant in enumerate(variants):
|
||||
if _flag(variant, "tor") is not True:
|
||||
continue
|
||||
for key, bound_role in sorted(clearnet.items()):
|
||||
if _flag(variant, key) is True:
|
||||
offenders.append(
|
||||
f"{role_dir.name} variant {index}: pins "
|
||||
f"services.tor.enabled true while enabling {key!r} "
|
||||
f"({bound_role}), which pins tor.enabled false"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
"A variant may not pin the onion on while binding a role that rides "
|
||||
"none; that round advertises a surface no onion browser can reach. "
|
||||
f"Turn tor off for the variant, or suppress with '# nocheck: {_RULE}' "
|
||||
"naming why the pairing is sound:\n " + "\n ".join(offenders),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user