From 1f8fc336e645d8c52a214b892c83be5a59a6426c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 28 Oct 2026 21:38:38 +0100 Subject: [PATCH 01/10] feat(proxy): per-app nosniff, Referrer-Policy, Permissions-Policy and HSTS directives #1115 raised HSTS to one year but set no X-Content-Type-Options, Referrer-Policy, Permissions-Policy or HSTS includeSubDomains/preload, because none of them is safe globally: nosniff lets ORB block no-cors responses typed text/html, includeSubDomains on the apex covers hosts the platform does not serve, and a server-level add_header is dropped in every location that sets its own. WEBSERVER_SECURITY_HEADERS (group_vars/all/04_webserver.yml) now holds the platform values, all opt-in: nosniff false, no Referrer-Policy, no Permissions-Policy, HSTS max-age one year without includeSubDomains or preload. A role overrides any key under security_headers in its meta/server.yml, an inventory under applications..server.security_headers. The new security_headers filter merges both, rejects unknown keys, wrong types, unknown Referrer-Policy tokens, non-printable header values and preload without includeSubDomains or below a one-year max-age, and composes the HSTS value. - nosniff, Referrer-Policy and Permissions-Policy are set in the Lua header filter of content_security_policy.conf.j2, next to CSP, so a location's own add_header cannot drop them. Referrer-Policy is only set when the upstream sends none; Permissions-Policy only on HTML documents. - HSTS stays the single server-level add_header in nginx_ssl_header.j2, now with the per-app value. A second emitter in Lua would duplicate the header. It stays inside the TLS branch, which onion vhosts never enter, so an onion vhost gets no HSTS even when its app opts in. No role opts in yet, so rendered vhosts are unchanged. Closes #1123 Co-Authored-By: Claude Opus 5.5 --- group_vars/all/04_webserver.yml | 10 ++ plugins/filter/security_headers.py | 10 ++ .../templates/nginx_ssl_header.j2 | 2 +- roles/sys-svc-proxy/README.md | 26 ++- .../headers/content_security_policy.conf.j2 | 2 + .../templates/headers/security_headers.lua.j2 | 14 ++ .../plugins/filter/test_security_headers.py | 153 ++++++++++++++++ .../python/roles/sys-front-tls/__init__.py | 3 + .../roles/sys-front-tls/templates/__init__.py | 5 + .../templates/test_nginx_ssl_header.py | 103 +++++++++++ .../templates/test_security_headers_lua.py | 117 ++++++++++++ utils/roles/applications/security_headers.py | 168 ++++++++++++++++++ 12 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 plugins/filter/security_headers.py create mode 100644 roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 create mode 100644 tests/unit/python/plugins/filter/test_security_headers.py create mode 100644 tests/unit/python/roles/sys-front-tls/__init__.py create mode 100644 tests/unit/python/roles/sys-front-tls/templates/__init__.py create mode 100644 tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py create mode 100644 tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py create mode 100644 utils/roles/applications/security_headers.py diff --git a/group_vars/all/04_webserver.yml b/group_vars/all/04_webserver.yml index b0af51f9eb..5168689cb9 100644 --- a/group_vars/all/04_webserver.yml +++ b/group_vars/all/04_webserver.yml @@ -24,3 +24,13 @@ WEBSERVER_WORKER_CONNECTIONS: >- (lookup('applications') | resource_filter(application_id, 'pids_limit', service_name | default(''), RESOURCE_PIDS_LIMIT)) | int ] | min }} + +# Response security headers of every vhost; an app overrides any key under `security_headers` in its meta/server.yml. +WEBSERVER_SECURITY_HEADERS: + nosniff: false # X-Content-Type-Options: nosniff + referrer_policy: "" # Referrer-Policy, sent only when the upstream sends none; "" sends none + permissions_policy: "" # Permissions-Policy on HTML documents; "" sends none + hsts: + max_age: 31536000 # Strict-Transport-Security max-age in seconds + include_subdomains: false # adds includeSubDomains, which covers every subdomain of the vhost + preload: false # adds preload; requires include_subdomains and a max_age of one year diff --git a/plugins/filter/security_headers.py b/plugins/filter/security_headers.py new file mode 100644 index 0000000000..78af696964 --- /dev/null +++ b/plugins/filter/security_headers.py @@ -0,0 +1,10 @@ +"""Expose the response security headers of one application to templates.""" + +from __future__ import annotations + +from utils.roles.applications.security_headers import security_headers + + +class FilterModule: + def filters(self): + return {"security_headers": security_headers} diff --git a/roles/sys-front-tls/templates/nginx_ssl_header.j2 b/roles/sys-front-tls/templates/nginx_ssl_header.j2 index 6fc4b4f1a7..00526b0318 100644 --- a/roles/sys-front-tls/templates/nginx_ssl_header.j2 +++ b/roles/sys-front-tls/templates/nginx_ssl_header.j2 @@ -11,7 +11,7 @@ ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets on; -add_header Strict-Transport-Security max-age=31536000 always; +add_header Strict-Transport-Security "{{ (lookup('applications') | security_headers(application_id, WEBSERVER_SECURITY_HEADERS)).hsts }}" always; ssl_stapling on; ssl_stapling_verify on; diff --git a/roles/sys-svc-proxy/README.md b/roles/sys-svc-proxy/README.md index 65120388ba..d284cf5588 100644 --- a/roles/sys-svc-proxy/README.md +++ b/roles/sys-svc-proxy/README.md @@ -31,11 +31,35 @@ The goal of this role is to deliver a **hassle-free, production-ready reverse pr - **Automatic TLS & HSTS**: integrates with the *sys-svc-webserver-https* role for certificate management. - **Flexible vHost templates**: *basic* and *ws_generic* flavours cover standard HTTP and WebSocket applications. -- **Security headers**: sensible defaults plus optional X-Frame-Options / CSP based on application settings. +- **Security headers**: CSP from each role's `meta/csp.yml`, plus the per-role headers described in [Security Headers](#security-headers). - **WebSocket & HTTP/2 aware**: upgrades, keep-alive tuning, and gzip already configured. - **OAuth2 gating**: drop-in support when *web-app-keycloak's SSO-proxy sidecar* is present. - **Modular includes**: headers, locations, and global snippets are factored for easy extension. +## Security Headers + +`WEBSERVER_SECURITY_HEADERS` in [04_webserver.yml](../../group_vars/all/04_webserver.yml) holds the platform values. A role overrides any key under `security_headers` in its `meta/server.yml`; an inventory overrides it at `applications..server.security_headers`. + +```yaml +# roles//meta/server.yml +security_headers: + nosniff: true + referrer_policy: strict-origin-when-cross-origin + permissions_policy: 'camera=(self "https://meet.example.org"), microphone=(self)' + hsts: + include_subdomains: true + preload: true +``` + +| Key | Header | Where it is rendered | +|---|---|---| +| `nosniff` | `X-Content-Type-Options: nosniff` | every response of a location that includes [content_security_policy.conf.j2](templates/headers/content_security_policy.conf.j2) | +| `referrer_policy` | `Referrer-Policy` | the same locations, only when the upstream sends none | +| `permissions_policy` | `Permissions-Policy` | HTML documents of the same locations | +| `hsts.*` | `Strict-Transport-Security` | the server block of every TLS vhost, via [nginx_ssl_header.j2](../sys-front-tls/templates/nginx_ssl_header.j2); an onion vhost has no TLS and gets none | + +The `security_headers` filter rejects unknown keys, wrong types, unknown `Referrer-Policy` tokens, and `preload` without `include_subdomains` or below a one-year `max_age`. + ## Credits Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**. diff --git a/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 b/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 index 80d3b05a73..e1aeca87e3 100644 --- a/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 +++ b/roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2 @@ -19,6 +19,8 @@ header_filter_by_lua_block { ngx.header["Content-Security-Policy-Report-Only"] = nil end +{% include 'roles/sys-svc-proxy/templates/headers/security_headers.lua.j2' %} + -- If you'll modify the body later, drop Content-Length on HTML if is_html then ngx.ctx.is_html = true diff --git a/roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 b/roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 new file mode 100644 index 0000000000..58dd96ed17 --- /dev/null +++ b/roles/sys-svc-proxy/templates/headers/security_headers.lua.j2 @@ -0,0 +1,14 @@ +{% set _security_headers = lookup('applications') | security_headers(application_id, WEBSERVER_SECURITY_HEADERS) %} +{% if _security_headers.nosniff %} + ngx.header["X-Content-Type-Options"] = "nosniff" +{% endif %} +{% if _security_headers.referrer_policy %} + if not ngx.header["Referrer-Policy"] then + ngx.header["Referrer-Policy"] = {{ _security_headers.referrer_policy | to_json }} + end +{% endif %} +{% if _security_headers.permissions_policy %} + if is_html then + ngx.header["Permissions-Policy"] = {{ _security_headers.permissions_policy | to_json }} + end +{% endif %} diff --git a/tests/unit/python/plugins/filter/test_security_headers.py b/tests/unit/python/plugins/filter/test_security_headers.py new file mode 100644 index 0000000000..797f9e5f88 --- /dev/null +++ b/tests/unit/python/plugins/filter/test_security_headers.py @@ -0,0 +1,153 @@ +import copy +import unittest + +from plugins.filter.security_headers import FilterModule +from utils.cache.yaml import load_yaml +from utils.roles.applications.security_headers import security_headers + +from . import PROJECT_ROOT + +PLATFORM = { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": {"max_age": 31536000, "include_subdomains": False, "preload": False}, +} + + +def apps_with(override): + """Return an applications map whose one app declares *override*. + + Args: + override: the app's ``server.security_headers`` block. + """ + return {"web-app-x": {"server": {"security_headers": override}}} + + +class TestPlatformValues(unittest.TestCase): + def test_an_app_without_a_block_gets_the_platform_values(self): + apps = {"web-app-plain": {"server": {"status_codes": {"default": [200]}}}} + self.assertEqual( + security_headers(apps, "web-app-plain", PLATFORM), + { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": "max-age=31536000", + }, + ) + + def test_a_redirect_vhost_passes_its_domain_and_gets_the_platform_values(self): + result = security_headers(apps_with({"nosniff": True}), "old.example", PLATFORM) + self.assertFalse(result["nosniff"]) + self.assertEqual(result["hsts"], "max-age=31536000") + + def test_the_repository_platform_values_are_valid(self): + platform = load_yaml(PROJECT_ROOT / "group_vars/all/04_webserver.yml")[ + "WEBSERVER_SECURITY_HEADERS" + ] + self.assertEqual( + security_headers({}, "web-app-plain", platform)["hsts"], + "max-age=31536000", + ) + + def test_an_incomplete_platform_block_fails(self): + platform = copy.deepcopy(PLATFORM) + del platform["hsts"]["preload"] + with self.assertRaisesRegex(ValueError, "preload"): + security_headers({}, "web-app-plain", platform) + + def test_the_platform_block_is_not_mutated(self): + platform = copy.deepcopy(PLATFORM) + security_headers( + apps_with({"hsts": {"include_subdomains": True}}), "web-app-x", platform + ) + self.assertEqual(platform, PLATFORM) + + +class TestOverrides(unittest.TestCase): + def test_an_override_replaces_only_its_own_keys(self): + result = security_headers( + apps_with({"nosniff": True, "hsts": {"include_subdomains": True}}), + "web-app-x", + PLATFORM, + ) + self.assertTrue(result["nosniff"]) + self.assertEqual(result["referrer_policy"], "") + self.assertEqual(result["hsts"], "max-age=31536000; includeSubDomains") + + def test_preload_follows_include_subdomains(self): + result = security_headers( + apps_with({"hsts": {"include_subdomains": True, "preload": True}}), + "web-app-x", + PLATFORM, + ) + self.assertEqual(result["hsts"], "max-age=31536000; includeSubDomains; preload") + + def test_a_referrer_policy_list_is_accepted(self): + value = "no-referrer, strict-origin-when-cross-origin" + result = security_headers( + apps_with({"referrer_policy": value}), "web-app-x", PLATFORM + ) + self.assertEqual(result["referrer_policy"], value) + + def test_a_permissions_policy_keeps_its_quoted_origins(self): + value = 'camera=(self "https://meet.example.org"), microphone=(self)' + result = security_headers( + apps_with({"permissions_policy": value}), "web-app-x", PLATFORM + ) + self.assertEqual(result["permissions_policy"], value) + + +class TestRejections(unittest.TestCase): + def assert_rejected(self, override, pattern, error=ValueError): + with self.assertRaisesRegex(error, pattern): + security_headers(apps_with(override), "web-app-x", PLATFORM) + + def test_an_unknown_key_fails(self): + self.assert_rejected({"nosnif": True}, "unknown keys") + + def test_an_unknown_hsts_key_fails(self): + self.assert_rejected({"hsts": {"subdomains": True}}, "unknown keys") + + def test_a_non_mapping_block_fails(self): + self.assert_rejected(None, "must be a mapping", TypeError) + + def test_a_string_flag_fails(self): + self.assert_rejected({"nosniff": "yes"}, "must be bool", TypeError) + + def test_a_boolean_max_age_fails(self): + self.assert_rejected({"hsts": {"max_age": True}}, "must be int", TypeError) + + def test_a_negative_max_age_fails(self): + self.assert_rejected({"hsts": {"max_age": -1}}, "negative") + + def test_preload_without_include_subdomains_fails(self): + self.assert_rejected({"hsts": {"preload": True}}, "requires include_subdomains") + + def test_preload_with_a_short_max_age_fails(self): + self.assert_rejected( + {"hsts": {"max_age": 86400, "include_subdomains": True, "preload": True}}, + "at least 31536000", + ) + + def test_an_unknown_referrer_policy_token_fails(self): + self.assert_rejected( + {"referrer_policy": "strict-origin-when-cross-orgin"}, + "unknown policy tokens", + ) + + def test_a_line_break_in_a_header_value_fails(self): + self.assert_rejected( + {"permissions_policy": "camera=()\r\nSet-Cookie: x=1"}, + "printable ASCII", + ) + + +class TestFilterModule(unittest.TestCase): + def test_filter_is_registered(self): + self.assertIs(FilterModule().filters()["security_headers"], security_headers) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/sys-front-tls/__init__.py b/tests/unit/python/roles/sys-front-tls/__init__.py new file mode 100644 index 0000000000..8ee16d7567 --- /dev/null +++ b/tests/unit/python/roles/sys-front-tls/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/python/roles/sys-front-tls/templates/__init__.py b/tests/unit/python/roles/sys-front-tls/templates/__init__.py new file mode 100644 index 0000000000..087c384b57 --- /dev/null +++ b/tests/unit/python/roles/sys-front-tls/templates/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[6] + +__all__ = ["PROJECT_ROOT"] diff --git a/tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py b/tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py new file mode 100644 index 0000000000..851cbed1b7 --- /dev/null +++ b/tests/unit/python/roles/sys-front-tls/templates/test_nginx_ssl_header.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import re +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from plugins.filter.security_headers import FilterModule +from utils.tls_common import resolve_enabled + +from . import PROJECT_ROOT + +TEMPLATE = "roles/sys-front-tls/templates/nginx_ssl_header.j2" + +PLATFORM = { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": {"max_age": 31536000, "include_subdomains": False, "preload": False}, +} + +PRIMARY_DOMAINS = { + "web-app-x": "x.example.org", + "web-app-onion": "x.abcdefghijklmnop.onion", + "old.example.org": "old.example.org", +} + + +def render(application_id: str, override: dict) -> str: + """Return the TLS server header of one vhost. + + Args: + application_id: the app the vhost belongs to, or a redirect domain. + override: the app's ``server.security_headers`` block. + """ + applications = { + app: {"server": {"security_headers": override}} + for app in ("web-app-x", "web-app-onion") + } + + def lookup(kind, *args, **_kw): + if kind == "applications": + return applications + if kind == "tls": + term, want = args + enabled = resolve_enabled( + applications.get(term, {}), True, primary_domain=PRIMARY_DOMAINS[term] + ) + return {"enabled": enabled, "ports.web": 443 if enabled else 80}[want] + if kind == "cert": + return f"/etc/certs/{args[0]}/{args[1]}" + raise AssertionError(f"unexpected lookup {kind!r}") + + env = Environment( + loader=FileSystemLoader(str(PROJECT_ROOT)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters.update(FilterModule().filters()) + env.filters["bool"] = bool + return env.get_template(TEMPLATE).render( + application_id=application_id, + lookup=lookup, + WEBSERVER_SECURITY_HEADERS=PLATFORM, + ) + + +def hsts(rendered: str) -> list[str]: + """Return every Strict-Transport-Security value the header adds. + + Args: + rendered: the rendered TLS server header. + """ + return re.findall( + r'^add_header Strict-Transport-Security "([^"]*)" always;$', + rendered, + re.MULTILINE, + ) + + +class TestHsts(unittest.TestCase): + def test_the_platform_value_is_one_year_without_subdomains(self): + self.assertEqual(hsts(render("web-app-x", {})), ["max-age=31536000"]) + + def test_include_subdomains_and_preload_are_opt_in_per_app(self): + rendered = render( + "web-app-x", {"hsts": {"include_subdomains": True, "preload": True}} + ) + self.assertEqual( + hsts(rendered), ["max-age=31536000; includeSubDomains; preload"] + ) + + def test_a_redirect_vhost_sends_the_platform_value(self): + self.assertEqual(hsts(render("old.example.org", {})), ["max-age=31536000"]) + + def test_an_onion_vhost_sends_no_hsts_even_when_opted_in(self): + rendered = render("web-app-onion", {"hsts": {"include_subdomains": True}}) + self.assertNotIn("Strict-Transport-Security", rendered) + self.assertNotIn("ssl", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py b/tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py new file mode 100644 index 0000000000..3baee51efc --- /dev/null +++ b/tests/unit/python/roles/sys-svc-proxy/templates/test_security_headers_lua.py @@ -0,0 +1,117 @@ +"""Exception: a location with its own ``add_header`` drops every server-level one, +so these headers live in the Lua header filter of ``content_security_policy.conf.j2``. +""" + +from __future__ import annotations + +import json +import re +import unittest + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from plugins.filter.security_headers import FilterModule +from utils.cache.files import read_text + +from . import PROJECT_ROOT + +SNIPPET = "roles/sys-svc-proxy/templates/headers/security_headers.lua.j2" +CSP_TEMPLATE = "roles/sys-svc-proxy/templates/headers/content_security_policy.conf.j2" + +PLATFORM = { + "nosniff": False, + "referrer_policy": "", + "permissions_policy": "", + "hsts": {"max_age": 31536000, "include_subdomains": False, "preload": False}, +} + + +def render(override: dict) -> str: + """Return the Lua snippet for an app declaring *override*. + + Args: + override: the app's ``server.security_headers`` block. + """ + applications = {"web-app-x": {"server": {"security_headers": override}}} + + def lookup(kind, *_args, **_kw): + if kind == "applications": + return applications + raise AssertionError(f"unexpected lookup {kind!r}") + + env = Environment( + loader=FileSystemLoader(str(PROJECT_ROOT)), + undefined=StrictUndefined, + autoescape=select_autoescape(), + ) + env.filters.update(FilterModule().filters()) + env.filters["to_json"] = json.dumps + return env.get_template(SNIPPET).render( + application_id="web-app-x", + lookup=lookup, + WEBSERVER_SECURITY_HEADERS=PLATFORM, + ) + + +def assignment(rendered: str, header: str) -> str: + """Return the Lua literal assigned to *header*, or an empty string. + + Args: + rendered: the rendered snippet. + header: the response header name. + """ + match = re.search( + rf'ngx\.header\["{re.escape(header)}"\] = (".*")$', rendered, re.MULTILINE + ) + return match.group(1) if match else "" + + +class TestSecurityHeadersSnippet(unittest.TestCase): + def test_the_platform_values_send_nothing(self): + self.assertNotIn("ngx.header", render({})) + + def test_nosniff_is_sent_when_opted_in(self): + rendered = render({"nosniff": True}) + self.assertEqual( + json.loads(assignment(rendered, "X-Content-Type-Options")), "nosniff" + ) + + def test_the_referrer_policy_never_replaces_the_upstream_one(self): + rendered = render({"referrer_policy": "strict-origin-when-cross-origin"}) + self.assertRegex( + rendered, + r'if not ngx\.header\["Referrer-Policy"\] then\s+' + r'ngx\.header\["Referrer-Policy"\] = "strict-origin-when-cross-origin"\s+end', + ) + + def test_the_permissions_policy_is_sent_on_documents_only(self): + rendered = render({"permissions_policy": "camera=(self)"}) + self.assertRegex( + rendered, + r'if is_html then\s+ngx\.header\["Permissions-Policy"\] = "camera=\(self\)"\s+end', + ) + + def test_quoted_origins_stay_one_lua_string(self): + value = 'camera=(self "https://meet.example.org"), microphone=(self)' + rendered = render({"permissions_policy": value}) + self.assertEqual(json.loads(assignment(rendered, "Permissions-Policy")), value) + + def test_hsts_is_left_to_the_server_level_header(self): + rendered = render({"hsts": {"include_subdomains": True}}) + self.assertNotIn("Strict-Transport-Security", rendered) + + +class TestSnippetPlacement(unittest.TestCase): + def test_the_snippet_runs_inside_the_csp_header_filter_after_is_html(self): + text = read_text(str(PROJECT_ROOT / CSP_TEMPLATE)) + block = text.index("header_filter_by_lua_block {") + is_html = text.index("local is_html") + include = text.index(f"{{% include '{SNIPPET}' %}}") + block_end = text.index("\n}\n", block) + self.assertLess(block, is_html) + self.assertLess(is_html, include) + self.assertLess(include, block_end) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/roles/applications/security_headers.py b/utils/roles/applications/security_headers.py new file mode 100644 index 0000000000..f350bcc626 --- /dev/null +++ b/utils/roles/applications/security_headers.py @@ -0,0 +1,168 @@ +"""Response security headers the front proxy adds for one application. + +``WEBSERVER_SECURITY_HEADERS`` holds the platform values. An application +overrides any of them under ``security_headers`` in its ``meta/server.yml``. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from utils.roles.applications.config import get + +HSTS_PRELOAD_MIN_MAX_AGE = 31536000 + +REFERRER_POLICY_TOKENS = frozenset( + { + "no-referrer", + "no-referrer-when-downgrade", + "origin", + "origin-when-cross-origin", + "same-origin", + "strict-origin", + "strict-origin-when-cross-origin", + "unsafe-url", + } +) + +SCHEMA: dict[str, Any] = { + "nosniff": bool, + "referrer_policy": str, + "permissions_policy": str, + "hsts": { + "max_age": int, + "include_subdomains": bool, + "preload": bool, + }, +} + +_FIELD_VALUE = re.compile(r"[\x20-\x7e]*") + + +def _check_type(value: Any, expected: type, where: str) -> None: + if expected is int: + valid = isinstance(value, int) and not isinstance(value, bool) + else: + valid = isinstance(value, expected) + if not valid: + raise TypeError( + f"{where} must be {expected.__name__}, got {type(value).__name__} {value!r}" + ) + + +def _resolve( + schema: Mapping[str, Any], base: Any, override: Any, where: str +) -> dict[str, Any]: + """Validate *override* against *schema* and lay it over *base*. + + Args: + schema: the expected keys and their types. + base: the complete mapping the override applies to, ``None`` when + *override* is the complete mapping itself. + override: the mapping to validate; partial when *base* is given. + where: dotted origin of *override*, for error messages. + + Returns: + A complete mapping with every key of *schema*. + """ + if not isinstance(override, Mapping): + raise TypeError(f"{where} must be a mapping, got {override!r}") + unknown = sorted(set(override) - set(schema)) + if unknown: + raise ValueError(f"{where} has unknown keys {unknown}, known: {sorted(schema)}") + if base is None: + missing = sorted(set(schema) - set(override)) + if missing: + raise ValueError(f"{where} lacks the keys {missing}") + + resolved: dict[str, Any] = {} + for key, expected in schema.items(): + path = f"{where}.{key}" + if key not in override: + resolved[key] = base[key] + elif isinstance(expected, Mapping): + resolved[key] = _resolve( + expected, None if base is None else base[key], override[key], path + ) + else: + _check_type(override[key], expected, path) + resolved[key] = override[key] + return resolved + + +def _field_value(value: str, where: str) -> str: + if not _FIELD_VALUE.fullmatch(value): + raise ValueError(f"{where} must be printable ASCII, got {value!r}") + return value + + +def _referrer_policy(value: str, where: str) -> str: + _field_value(value, where) + if not value: + return value + unknown = [ + token + for token in (part.strip() for part in value.split(",")) + if token not in REFERRER_POLICY_TOKENS + ] + if unknown: + raise ValueError(f"{where} has unknown policy tokens {unknown}") + return value + + +def _hsts(hsts: Mapping[str, Any], where: str) -> str: + if hsts["max_age"] < 0: + raise ValueError(f"{where}.max_age must not be negative") + if hsts["preload"] and not hsts["include_subdomains"]: + raise ValueError(f"{where}.preload requires include_subdomains") + if hsts["preload"] and hsts["max_age"] < HSTS_PRELOAD_MIN_MAX_AGE: + raise ValueError( + f"{where}.preload requires a max_age of at least {HSTS_PRELOAD_MIN_MAX_AGE}" + ) + directives = [f"max-age={hsts['max_age']}"] + if hsts["include_subdomains"]: + directives.append("includeSubDomains") + if hsts["preload"]: + directives.append("preload") + return "; ".join(directives) + + +def security_headers( + applications: Mapping[str, Any], application_id: str, platform: Mapping[str, Any] +) -> dict[str, Any]: + """Return the security header values the proxy sends for *application_id*. + + Args: + applications: the applications SPOT. + application_id: the role whose vhost is rendered; a redirect vhost + passes its domain, which owns no override. + platform: ``WEBSERVER_SECURITY_HEADERS``. + + Returns: + ``nosniff`` as a bool, ``referrer_policy`` and ``permissions_policy`` + as header values (empty when not sent) and ``hsts`` as the complete + ``Strict-Transport-Security`` value. + """ + base = _resolve(SCHEMA, None, platform, "WEBSERVER_SECURITY_HEADERS") + where = f"applications[{application_id!r}].server.security_headers" + override = get( + applications, + application_id, + "server.security_headers", + strict=False, + default={}, + skip_missing_app=True, + ) + resolved = _resolve(SCHEMA, base, override, where) + return { + "nosniff": resolved["nosniff"], + "referrer_policy": _referrer_policy( + resolved["referrer_policy"], f"{where}.referrer_policy" + ), + "permissions_policy": _field_value( + resolved["permissions_policy"], f"{where}.permissions_policy" + ), + "hsts": _hsts(resolved["hsts"], f"{where}.hsts"), + } From 033401c5844918e6a40b402bf6415be9f5f638ce Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:19:52 +0000 Subject: [PATCH 02/10] update(images): update Docker image versions (web-app-socialhome) --- roles/web-app-socialhome/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-socialhome/meta/services.yml b/roles/web-app-socialhome/meta/services.yml index 2d1238bd0b..4f9f1999bd 100644 --- a/roles/web-app-socialhome/meta/services.yml +++ b/roles/web-app-socialhome/meta/services.yml @@ -13,7 +13,7 @@ socialhome: # nocheck: default-placement-manager embedded sqlite in a node-local volume; no clustered storage placement: manager image: ghcr.io/social-home-io/socialhome - version: "2026.10.27" + version: "2026.10.29" ports: local: http: 8089 From 6ec97a09b4dfc8a7d202b06212631adfceb713f3 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:56:32 +0000 Subject: [PATCH 03/10] update(images): update Docker image versions (web-app-gitlab) --- roles/web-app-gitlab/meta/services.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/roles/web-app-gitlab/meta/services.yml b/roles/web-app-gitlab/meta/services.yml index 9c7cd70687..111272749a 100644 --- a/roles/web-app-gitlab/meta/services.yml +++ b/roles/web-app-gitlab/meta/services.yml @@ -76,7 +76,7 @@ gitlab: lifecycle: beta webservice: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-webservice-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-webservice healthcheck: test: ["CMD", "/scripts/healthcheck"] @@ -95,7 +95,7 @@ sidekiq: flavor: tcp path: liveness image: registry.gitlab.com/gitlab-org/build/cng/gitlab-sidekiq-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-sidekiq cpus: "1.0" mem_reservation: 2g @@ -106,7 +106,7 @@ sidekiq: http: 3808 workhorse: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-workhorse-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-workhorse healthcheck: test: ["CMD", "/scripts/healthcheck"] @@ -118,7 +118,7 @@ gitaly: # nocheck: single-replica replicas: 1 image: registry.gitlab.com/gitlab-org/build/cng/gitaly - version: v19.5.1 + version: v19.5.3 healthcheck: test: ["CMD", "/scripts/healthcheck"] name: gitlab-gitaly @@ -131,7 +131,7 @@ gitaly: pids_limit: 1024 shell: image: registry.gitlab.com/gitlab-org/build/cng/gitlab-shell - version: v19.5.1 + version: v19.5.3 name: gitlab-shell ports: internal: @@ -144,7 +144,7 @@ migrations: # nocheck: single-replica replicas: 1 image: registry.gitlab.com/gitlab-org/build/cng/gitlab-rails-ce - version: v19.5.1 + version: v19.5.3 name: gitlab-migrations cpus: "1.0" mem_reservation: 1g From bbd54e0077f561892371efdafd2fec829fdad286 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:56:37 +0000 Subject: [PATCH 04/10] update(images): update Docker image versions (web-app-pgadmin) --- roles/web-app-pgadmin/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-pgadmin/meta/services.yml b/roles/web-app-pgadmin/meta/services.yml index 4184791bf4..263a62428e 100644 --- a/roles/web-app-pgadmin/meta/services.yml +++ b/roles/web-app-pgadmin/meta/services.yml @@ -11,7 +11,7 @@ pgadmin: # nocheck: default-placement-manager embedded sqlite config db; node-local in swarm placement: manager image: dpage/pgadmin4 - version: "9.18" + version: "9.19" min_storage: 378MB server_mode: false master_password_required: true From 3bbc7ca205087def2a7c048c8352dd4289429e97 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:56:47 +0000 Subject: [PATCH 05/10] update(images): update Docker image versions (web-app-prometheus) --- roles/web-app-prometheus/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-prometheus/meta/services.yml b/roles/web-app-prometheus/meta/services.yml index fd8f536f5b..3efbe50131 100644 --- a/roles/web-app-prometheus/meta/services.yml +++ b/roles/web-app-prometheus/meta/services.yml @@ -79,7 +79,7 @@ alertmanager: flavor: wget path: -/healthy image: prom/alertmanager # nocheck: volumes-path-single-spot: the repository path of an image name, not a mount target - version: v0.34.1 + version: v0.34.2 name: prometheus-alertmanager cpus: "0.25" mem_reservation: 64m From eebb50d82a8f445be046487a673c556b511e6566 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:56:53 +0000 Subject: [PATCH 06/10] update(images): update Docker image versions (web-app-wazuh) --- roles/web-app-wazuh/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-wazuh/meta/services.yml b/roles/web-app-wazuh/meta/services.yml index d38e9054c9..224974902e 100644 --- a/roles/web-app-wazuh/meta/services.yml +++ b/roles/web-app-wazuh/meta/services.yml @@ -60,7 +60,7 @@ wazuh: lifecycle: beta certs_generator: image: wazuh/wazuh-certs-generator - version: "0.0.4" + version: "0.0.5" name: wazuh-certs-generator cpus: "0.2" mem_reservation: 64m From 8d4c03b863ea062e9aadd036101217b5cfbd77ad Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:43:06 +0000 Subject: [PATCH 07/10] update(repos): bump git refs to latest semver tags (pkgmgr) --- roles/pkgmgr/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/pkgmgr/meta/services.yml b/roles/pkgmgr/meta/services.yml index 079f44d0e8..ea3a8c7739 100644 --- a/roles/pkgmgr/meta/services.yml +++ b/roles/pkgmgr/meta/services.yml @@ -3,4 +3,4 @@ pkgmgr: lifecycle: beta repository: https://github.com/kevinveenbirkenbach/package-manager.git - ref: v2.0.4 + ref: v2.0.5 From 2517b20f15fee0f07fff9b6e08a0e4a389d2d565 Mon Sep 17 00:00:00 2001 From: "infinito-nexus-updater[bot]" <286144613+infinito-nexus-updater[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:43:11 +0000 Subject: [PATCH 08/10] update(repos): bump git refs to latest semver tags (web-app-bookwyrm) --- roles/web-app-bookwyrm/meta/services.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/web-app-bookwyrm/meta/services.yml b/roles/web-app-bookwyrm/meta/services.yml index fcee76585f..544d79cf7c 100644 --- a/roles/web-app-bookwyrm/meta/services.yml +++ b/roles/web-app-bookwyrm/meta/services.yml @@ -62,7 +62,7 @@ bookwyrm: version: 3.11-slim custom: true repository: https://github.com/bookwyrm-social/bookwyrm.git - ref: v0.9.3 + ref: v0.9.4 min_storage: 579MB registration_open: false allow_invite_request: false From 6d44e1e28ac9f838dbb4ff1c63a1d65cc818d198 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 29 Oct 2026 22:10:45 +0100 Subject: [PATCH 09/10] fix(ci): check the PR head against the main it was tested with The Validation step "Assert the base branch is fully merged into the PR head" ran main_merged.sh, which fetches the live tip of main when the job starts. PR runs wait in the queue for hours while image-update PRs land on main several times an hour, so nearly every queued run failed Validation on start and had to be re-pushed after another main merge (PR #1265: behind_by=0 at push, behind_by=9 while its run still queued). main_merged.sh takes a new INFINITO_MAIN_COMMIT override: when set, it fetches that full commit SHA from the remote and requires HEAD to contain it instead of the live tip; a set but malformed or empty value exits 2. The Validation step passes github.event.pull_request.base.sha, the main tip at event time, which the step's pull_request-only guard always provides. The run tests the merge against that base, so the gate still guarantees the tested tree contains it. The local pre-push hook (make test-main-merged) leaves the override unset and keeps checking the live tip: a push happens right now, so lagging the current main is the real defect there. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/call-validation.yml | 3 +- scripts/git/assert/main_merged.sh | 24 +++- tests/unit/python/scripts/git/__init__.py | 0 .../python/scripts/git/assert/__init__.py | 0 .../scripts/git/assert/test_main_merged.py | 134 ++++++++++++++++++ 5 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 tests/unit/python/scripts/git/__init__.py create mode 100644 tests/unit/python/scripts/git/assert/__init__.py create mode 100644 tests/unit/python/scripts/git/assert/test_main_merged.py diff --git a/.github/workflows/call-validation.yml b/.github/workflows/call-validation.yml index 05e395339b..79155629e2 100644 --- a/.github/workflows/call-validation.yml +++ b/.github/workflows/call-validation.yml @@ -109,12 +109,13 @@ jobs: PR_SCOPE: ${{ inputs.pr_scope }} run: ./scripts/meta/resolve/pr/branch_prefix.sh - - name: 🔀 Assert the base branch is fully merged into the PR head + - name: 🔀 Assert the event's base commit is merged into the PR head if: ${{ github.event_name == 'pull_request' && inputs.pr_scope != '' }} working-directory: pr-head env: INFINITO_MAIN_REMOTE: origin INFINITO_MAIN_BRANCH: ${{ github.event.pull_request.base.ref }} + INFINITO_MAIN_COMMIT: ${{ github.event.pull_request.base.sha }} run: bash "${GITHUB_WORKSPACE}/.base-tooling/scripts/git/assert/main_merged.sh" - name: 🔧 Set up Python diff --git a/scripts/git/assert/main_merged.sh b/scripts/git/assert/main_merged.sh index bb63e43b46..ac7471884d 100755 --- a/scripts/git/assert/main_merged.sh +++ b/scripts/git/assert/main_merged.sh @@ -4,23 +4,37 @@ # from the current tip, so a branch can never be pushed while it lags main. # # Env overrides: INFINITO_MAIN_REMOTE (default origin), INFINITO_MAIN_BRANCH -# (default main). +# (default main), INFINITO_MAIN_COMMIT (unset: check the live branch tip; set: +# fetch and check this full commit SHA of that branch instead, e.g. the base +# SHA a CI event carries). set -euo pipefail remote="${INFINITO_MAIN_REMOTE:-origin}" # nocheck: git maintainer tooling knob, not a deployment env key branch="${INFINITO_MAIN_BRANCH:-main}" # nocheck: git maintainer tooling knob, not a deployment env key +if [[ -v INFINITO_MAIN_COMMIT ]]; then + target="${INFINITO_MAIN_COMMIT}" # nocheck: git maintainer tooling knob, not a deployment env key + if [[ ! "$target" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; then + echo "❌ assert-main-merged: INFINITO_MAIN_COMMIT must be a full commit SHA, got '${target}'." >&2 + exit 2 + fi + label="${remote}/${branch}@${target}" +else + target="$branch" + label="${remote}/${branch}" +fi + cd "$(git rev-parse --show-toplevel)" -echo "assert-main-merged: fetching ${remote}/${branch} ..." >&2 -git fetch --quiet "$remote" "$branch" +echo "assert-main-merged: fetching ${label} ..." >&2 +git fetch --quiet "$remote" "$target" if git merge-base --is-ancestor FETCH_HEAD HEAD; then - echo "✅ assert-main-merged: ${remote}/${branch} is fully merged into HEAD." >&2 + echo "✅ assert-main-merged: ${label} is fully merged into HEAD." >&2 exit 0 fi behind="$(git rev-list --count HEAD..FETCH_HEAD)" -echo "❌ assert-main-merged: ${remote}/${branch} has ${behind} commit(s) not merged into HEAD." >&2 +echo "❌ assert-main-merged: ${label} has ${behind} commit(s) not merged into HEAD." >&2 echo " Merge upstream main first, e.g.: git pull ${remote} ${branch}" >&2 exit 1 diff --git a/tests/unit/python/scripts/git/__init__.py b/tests/unit/python/scripts/git/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/git/assert/__init__.py b/tests/unit/python/scripts/git/assert/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/git/assert/test_main_merged.py b/tests/unit/python/scripts/git/assert/test_main_merged.py new file mode 100644 index 0000000000..5a41470cb1 --- /dev/null +++ b/tests/unit/python/scripts/git/assert/test_main_merged.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from utils.cache.files import PROJECT_ROOT + +SCRIPT = PROJECT_ROOT / "scripts" / "git" / "assert" / "main_merged.sh" +MAIN_COMMIT_ENV = "INFINITO_MAIN_COMMIT" # nocheck: git maintainer tooling knob, not a deployment env key + + +class MainMergedFixture: + """A bare ``origin`` whose ``main`` moved on after ``head`` branched off it. + + Args: + tmp: directory that holds the bare remote and its two clones. + """ + + def __init__(self, tmp: str) -> None: + root = Path(tmp) + inherited = { + k: v + for k, v in os.environ.items() + if not k.startswith(("GIT_", "INFINITO_")) + } + self.env = { + **inherited, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + } + origin = root / "origin.git" + maintainer = root / "maintainer" + self.head = root / "head" + self.git(root, "init", "--bare", "-b", "main", str(origin)) + self.git(root, "init", "-b", "main", str(maintainer)) + self.git(maintainer, "remote", "add", "origin", str(origin)) + self.base = self.commit(maintainer, "base") + self.git(maintainer, "push", "origin", "main") + self.git(root, "clone", str(origin), str(self.head)) + self.git(self.head, "switch", "-c", "feature") + self.commit(self.head, "feature") + self.main_tip = self.commit(maintainer, "main moved on") + self.git(maintainer, "push", "origin", "main") + + def git(self, cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, + env=self.env, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + def commit(self, repo: Path, message: str) -> str: + self.git(repo, "commit", "--allow-empty", "-m", message) + return self.git(repo, "rev-parse", "HEAD") + + def merge_main(self) -> None: + self.git(self.head, "pull", "--no-rebase", "--no-edit", "origin", "main") + + def run_script(self, main_commit: str | None) -> subprocess.CompletedProcess: + """Run the script in ``head``. + + Args: + main_commit: value of the main-commit override; ``None`` leaves it unset. + """ + env = dict(self.env) + if main_commit is not None: + env[MAIN_COMMIT_ENV] = main_commit + return subprocess.run( + ["bash", str(SCRIPT)], + cwd=self.head, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +class TestMainMergedPinnedCommit(unittest.TestCase): + def test_contained_commit_passes_after_main_moved_on(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(fixture.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"origin/main@{fixture.base} is fully merged", result.stderr) + + def test_commit_missing_from_head_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(fixture.main_tip) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn( + f"origin/main@{fixture.main_tip} has 1 commit(s) not merged into HEAD", + result.stderr, + ) + + def test_empty_commit_fails_loudly(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script("") + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("must be a full commit SHA", result.stderr) + + +class TestMainMergedLiveTip(unittest.TestCase): + def test_live_tip_missing_from_head_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(None) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn( + "origin/main has 1 commit(s) not merged into HEAD", result.stderr + ) + + def test_merged_live_tip_passes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + fixture.merge_main() + result = fixture.run_script(None) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("origin/main is fully merged into HEAD", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 7a3f27fc6392fa0aee473c505de42b9df8837146 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Thu, 29 Oct 2026 22:10:46 +0100 Subject: [PATCH 10/10] style(lint): apply ruff format to the worktree swarm lab subnet lint tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py landed on main with lines ruff format wraps, so every `make autoformat` rewrites it and leaves an unstaged change behind. Formatter output only. Co-Authored-By: Claude Opus 5.5 --- .../filesystem/env/test_worktree_swarm_lab_subnet.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py b/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py index 16c42ed62b..7395b5f012 100644 --- a/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py +++ b/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py @@ -71,10 +71,16 @@ class TestWorktreeSwarmLabSubnet(unittest.TestCase): with self.subTest(slot=slot): subnet = self._subnet(slot) clashes = sorted( - name for name, network in self.reserved.items() if subnet.overlaps(network) + name + for name, network in self.reserved.items() + if subnet.overlaps(network) + ) + self.assertEqual( + clashes, [], f"slot {slot} lab {subnet} overlaps {clashes}" + ) + self.assertNotIn( + subnet, seen, f"slot {slot} repeats slot {seen.get(subnet)}" ) - self.assertEqual(clashes, [], f"slot {slot} lab {subnet} overlaps {clashes}") - self.assertNotIn(subnet, seen, f"slot {slot} repeats slot {seen.get(subnet)}") seen[subnet] = slot