From 1f039dd39c1ce2672f4978c8abc6dff862ef1445 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 20:02:29 +0200 Subject: [PATCH] Retain Caddy file capability at ingress boundary --- docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md | 5 +++++ tests/test_deployment_installer.py | 3 +++ tests/test_managed_ingress_drill.py | 1 + tools/checks/managed-ingress-drill.py | 2 ++ tools/deployment/govoplan_deploy/bundle.py | 1 + 5 files changed, 12 insertions(+) diff --git a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md index 7f8cde2..5502316 100644 --- a/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md +++ b/docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md @@ -185,6 +185,11 @@ The dispatch-only `Runtime Ingress Drill` workflow exposes the same bounded check independently so ingress changes can be diagnosed before an immutable runtime publication; it accepts only digest-pinned Caddy, HAProxy, and API images and has no push trigger. +The official Caddy binary carries the `NET_BIND_SERVICE` file capability. The +managed-ingress container therefore drops every capability and adds back only +`NET_BIND_SERVICE`; otherwise Linux rejects the binary at `execve` before its +high-port configuration can start. `no-new-privileges`, a read-only root +filesystem, and non-privileged container ports remain enforced. The bounded setup helper writes only generated public configuration as root so it can initialize a new volume; the actual HAProxy process retains the image's non-root identity and runs read-only with all capabilities dropped. diff --git a/tests/test_deployment_installer.py b/tests/test_deployment_installer.py index 6bfcedb..b790828 100644 --- a/tests/test_deployment_installer.py +++ b/tests/test_deployment_installer.py @@ -551,6 +551,9 @@ class DeploymentInstallerTests(unittest.TestCase): ) self.assertIn("caddy-data:/data", ingress["volumes"]) self.assertIn("caddy-config:/config", ingress["volumes"]) + self.assertEqual(["ALL"], ingress["cap_drop"]) + self.assertEqual(["NET_BIND_SERVICE"], ingress["cap_add"]) + self.assertEqual(["no-new-privileges:true"], ingress["security_opt"]) self.assertIn("reverse_proxy load-balancer:8080", render_caddy_config(spec)) self.assertNotIn("operator@example.test", json.dumps(compose)) diff --git a/tests/test_managed_ingress_drill.py b/tests/test_managed_ingress_drill.py index 0e4a8cf..cf69896 100644 --- a/tests/test_managed_ingress_drill.py +++ b/tests/test_managed_ingress_drill.py @@ -64,6 +64,7 @@ class ManagedIngressDrillTests(unittest.TestCase): self.assertNotIn('"127.0.0.1::8080"', source) self.assertIn("requested_http_port", source) self.assertIn("requested_https_port", source) + self.assertIn('"--cap-add",\n "NET_BIND_SERVICE"', source) def test_published_port_reads_the_docker_mapping(self) -> None: completed = subprocess.CompletedProcess( diff --git a/tools/checks/managed-ingress-drill.py b/tools/checks/managed-ingress-drill.py index d2cccde..04e3f9b 100644 --- a/tools/checks/managed-ingress-drill.py +++ b/tools/checks/managed-ingress-drill.py @@ -388,6 +388,8 @@ def main() -> int: "no-new-privileges", "--cap-drop", "ALL", + "--cap-add", + "NET_BIND_SERVICE", "--publish", f"127.0.0.1:{requested_http_port}:8080/tcp", "--publish", diff --git a/tools/deployment/govoplan_deploy/bundle.py b/tools/deployment/govoplan_deploy/bundle.py index f106373..32defb5 100644 --- a/tools/deployment/govoplan_deploy/bundle.py +++ b/tools/deployment/govoplan_deploy/bundle.py @@ -627,6 +627,7 @@ def render_compose(spec: InstallationSpec) -> dict[str, object]: "tmpfs": ["/tmp:rw,noexec,nosuid,size=64m"], "security_opt": ["no-new-privileges:true"], "cap_drop": ["ALL"], + "cap_add": ["NET_BIND_SERVICE"], "volumes": [ f"./{CADDY_CONFIG_FILENAME}:/etc/caddy/Caddyfile:ro", "caddy-data:/data",