From 975a3b99cfca82a4807b9ef40462daa951e98244 Mon Sep 17 00:00:00 2001 From: Jonatan Date: Mon, 26 Oct 2026 08:49:27 +0100 Subject: [PATCH] oci: fix low-port and generated secret profiles --- oci/catalog/apps/clickhouse.json | 3 +- oci/catalog/apps/mysql.json | 3 +- oci/catalog/apps/pdns-recursor.json | 21 ++++- oci/catalog/apps/typesense.json | 3 +- oci/catalog/curated/clickhouse.json | 3 +- oci/catalog/curated/mysql.json | 3 +- oci/catalog/curated/pdns-recursor.json | 21 ++++- oci/catalog/curated/typesense.json | 3 +- oci/tests/test_pdns_recursor_profile.py | 52 +++++++++++ .../test_required_generated_environment.py | 88 +++++++++++++++++++ 10 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 oci/tests/test_pdns_recursor_profile.py create mode 100644 oci/tests/test_required_generated_environment.py diff --git a/oci/catalog/apps/clickhouse.json b/oci/catalog/apps/clickhouse.json index 3e375783..6c89dc89 100644 --- a/oci/catalog/apps/clickhouse.json +++ b/oci/catalog/apps/clickhouse.json @@ -65,8 +65,7 @@ "example": "${GENERATED_CLICKHOUSE_PASSWORD}", "required": true, "sensitive": true, - "source": "upstream-documentation", - "prompt_user": false + "source": "upstream-documentation" }, { "name": "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT", diff --git a/oci/catalog/apps/mysql.json b/oci/catalog/apps/mysql.json index b95dbfce..938eecff 100644 --- a/oci/catalog/apps/mysql.json +++ b/oci/catalog/apps/mysql.json @@ -65,8 +65,7 @@ "example": "${GENERATED_MYSQL_ROOT_PASSWORD}", "required": true, "sensitive": true, - "source": "upstream-documentation", - "prompt_user": false + "source": "upstream-documentation" } ], "volumes": [ diff --git a/oci/catalog/apps/pdns-recursor.json b/oci/catalog/apps/pdns-recursor.json index 4f90c7df..10974e9d 100644 --- a/oci/catalog/apps/pdns-recursor.json +++ b/oci/catalog/apps/pdns-recursor.json @@ -32,7 +32,7 @@ "documentation": "https://doc.powerdns.com/recursor/", "repository": "https://github.com/PowerDNS/pdns", "tips": [ - "The image uses the upstream default recursor configuration. Keep it on a trusted LAN and review the allowed client networks before changing its configuration." + "The image runs as the non-root pdns user. ProxMenux sets net.ipv4.ip_unprivileged_port_start=53 in this LXC's network namespace so it can listen on DNS port 53. This lets unprivileged processes in this LXC bind ports 53–1023; it does not change the Proxmox host or make the LXC privileged. Keep it on a trusted LAN and review the allowed client networks before changing its configuration." ], "mini_changelog": [], "display_version": null, @@ -188,6 +188,14 @@ "stop_signal": "import-from-oci-image" }, "adaptations": [ + { + "id": "dns-low-ports-in-container-namespace", + "upstream_behavior": "The official image runs as the non-root pdns user and listens on DNS port 53; Docker Engine can provide a per-container NET_BIND_SERVICE capability.", + "native_lxc_behavior": "ProxMenux sets net.ipv4.ip_unprivileged_port_start=53 through this LXC's own network namespace configuration while keeping the LXC unprivileged.", + "reason": "The native OCI-LXC runner does not provide Docker's per-process capability grant. This namespaced sysctl enables the default DNS port without adding a capability or changing a host sysctl.", + "behavioral_impact": "Unprivileged processes in this LXC may bind ports 53 through 1023; the Proxmox host and other guests are unchanged.", + "validation": "pending-live-retest" + }, { "id": "imported-compose-source", "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", @@ -317,7 +325,16 @@ "validation": "not-requested-by-compose" } ], - "installer_profile": {}, + "installer_profile": { + "security": { + "sysctls": [ + { + "name": "net.ipv4.ip_unprivileged_port_start", + "value": "53" + } + ] + } + }, "catalog": { "replaces_discovered_ids": [] } diff --git a/oci/catalog/apps/typesense.json b/oci/catalog/apps/typesense.json index 3fe4d6c4..1570492d 100644 --- a/oci/catalog/apps/typesense.json +++ b/oci/catalog/apps/typesense.json @@ -65,8 +65,7 @@ "example": "${GENERATED_TYPESENSE_API_KEY}", "required": true, "sensitive": true, - "source": "upstream-documentation", - "prompt_user": false + "source": "upstream-documentation" } ], "volumes": [ diff --git a/oci/catalog/curated/clickhouse.json b/oci/catalog/curated/clickhouse.json index 3e375783..6c89dc89 100644 --- a/oci/catalog/curated/clickhouse.json +++ b/oci/catalog/curated/clickhouse.json @@ -65,8 +65,7 @@ "example": "${GENERATED_CLICKHOUSE_PASSWORD}", "required": true, "sensitive": true, - "source": "upstream-documentation", - "prompt_user": false + "source": "upstream-documentation" }, { "name": "CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT", diff --git a/oci/catalog/curated/mysql.json b/oci/catalog/curated/mysql.json index b95dbfce..938eecff 100644 --- a/oci/catalog/curated/mysql.json +++ b/oci/catalog/curated/mysql.json @@ -65,8 +65,7 @@ "example": "${GENERATED_MYSQL_ROOT_PASSWORD}", "required": true, "sensitive": true, - "source": "upstream-documentation", - "prompt_user": false + "source": "upstream-documentation" } ], "volumes": [ diff --git a/oci/catalog/curated/pdns-recursor.json b/oci/catalog/curated/pdns-recursor.json index 4f90c7df..10974e9d 100644 --- a/oci/catalog/curated/pdns-recursor.json +++ b/oci/catalog/curated/pdns-recursor.json @@ -32,7 +32,7 @@ "documentation": "https://doc.powerdns.com/recursor/", "repository": "https://github.com/PowerDNS/pdns", "tips": [ - "The image uses the upstream default recursor configuration. Keep it on a trusted LAN and review the allowed client networks before changing its configuration." + "The image runs as the non-root pdns user. ProxMenux sets net.ipv4.ip_unprivileged_port_start=53 in this LXC's network namespace so it can listen on DNS port 53. This lets unprivileged processes in this LXC bind ports 53–1023; it does not change the Proxmox host or make the LXC privileged. Keep it on a trusted LAN and review the allowed client networks before changing its configuration." ], "mini_changelog": [], "display_version": null, @@ -188,6 +188,14 @@ "stop_signal": "import-from-oci-image" }, "adaptations": [ + { + "id": "dns-low-ports-in-container-namespace", + "upstream_behavior": "The official image runs as the non-root pdns user and listens on DNS port 53; Docker Engine can provide a per-container NET_BIND_SERVICE capability.", + "native_lxc_behavior": "ProxMenux sets net.ipv4.ip_unprivileged_port_start=53 through this LXC's own network namespace configuration while keeping the LXC unprivileged.", + "reason": "The native OCI-LXC runner does not provide Docker's per-process capability grant. This namespaced sysctl enables the default DNS port without adding a capability or changing a host sysctl.", + "behavioral_impact": "Unprivileged processes in this LXC may bind ports 53 through 1023; the Proxmox host and other guests are unchanged.", + "validation": "pending-live-retest" + }, { "id": "imported-compose-source", "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", @@ -317,7 +325,16 @@ "validation": "not-requested-by-compose" } ], - "installer_profile": {}, + "installer_profile": { + "security": { + "sysctls": [ + { + "name": "net.ipv4.ip_unprivileged_port_start", + "value": "53" + } + ] + } + }, "catalog": { "replaces_discovered_ids": [] } diff --git a/oci/catalog/curated/typesense.json b/oci/catalog/curated/typesense.json index 3fe4d6c4..1570492d 100644 --- a/oci/catalog/curated/typesense.json +++ b/oci/catalog/curated/typesense.json @@ -65,8 +65,7 @@ "example": "${GENERATED_TYPESENSE_API_KEY}", "required": true, "sensitive": true, - "source": "upstream-documentation", - "prompt_user": false + "source": "upstream-documentation" } ], "volumes": [ diff --git a/oci/tests/test_pdns_recursor_profile.py b/oci/tests/test_pdns_recursor_profile.py new file mode 100644 index 00000000..b03807ad --- /dev/null +++ b/oci/tests/test_pdns_recursor_profile.py @@ -0,0 +1,52 @@ +import json +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) + +import oci_runtime_settings + + +class PowerDNSRecursorProfileTest(unittest.TestCase): + def test_dns_port_is_available_to_the_non_root_image_only_inside_its_unprivileged_lxc(self): + curated = json.loads((ROOT / "catalog/curated/pdns-recursor.json").read_text()) + app = json.loads((ROOT / "catalog/apps/pdns-recursor.json").read_text()) + + expected_sysctls = [{ + "name": "net.ipv4.ip_unprivileged_port_start", + "value": "53", + }] + self.assertEqual( + curated["proxmox"]["installer_profile"]["security"]["sysctls"], + expected_sysctls, + ) + self.assertEqual( + app["proxmox"]["installer_profile"]["security"]["sysctls"], + expected_sysctls, + ) + self.assertTrue(curated["proxmox"]["defaults"]["unprivileged"]) + self.assertNotIn( + "required_capabilities", + curated["proxmox"]["installer_profile"].get("security", {}), + ) + self.assertEqual( + oci_runtime_settings.sysctl_content({"security": {"sysctls": expected_sysctls}}), + "lxc.sysctl.net.ipv4.ip_unprivileged_port_start = 53\n", + ) + self.assertEqual( + oci_runtime_settings.include_path(111), + Path("/etc/pve/proxmenux/111.sysctls"), + ) + installer = (ROOT / "remote/install_oci.sh").read_text() + self.assertIn('SYSCTL_INCLUDE="/etc/pve/proxmenux/${VMID}.sysctls"', installer) + self.assertIn('set_lxc_directive "lxc.include" "$SYSCTL_INCLUDE"', installer) + self.assertEqual(curated["compatibility"]["validated"], False) + self.assertEqual(curated["validation"]["clean_install"], "pending") + self.assertEqual(curated, app) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_required_generated_environment.py b/oci/tests/test_required_generated_environment.py new file mode 100644 index 00000000..e5afddee --- /dev/null +++ b/oci/tests/test_required_generated_environment.py @@ -0,0 +1,88 @@ +import re +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci.catalog import Catalog +from proxmenux_oci.installer import DEFAULT_MODE, build_deployment + + +class RecordingUI: + def __init__(self): + self.asked = [] + + def ask(self, text, default=None, required=True): + self.asked.append(text) + return default if default not in (None, "") else "fixture-value" + + def password(self, text, required=True): + self.asked.append(text) + return "fixture-password" + + def choose(self, text, options, default=None): + self.asked.append(text) + return default + + def confirm(self, text, default=False): + self.asked.append(text) + return default + + def checklist(self, text, options, default=None): + self.asked.append(text) + return default + + def info(self, text): + pass + + def message(self, text): + pass + + +def _addresses(ui, bridge, names, *args): + return {name: "dhcp" for name in names}, None + + +class RequiredGeneratedEnvironmentTests(unittest.TestCase): + @patch("proxmenux_oci.i18n.language", return_value="en") + @patch("proxmenux_oci.installer.host.storages", return_value=[ + {"storage": "local-lvm", "type": "lvmthin", "avail": 100 * 2**30} + ]) + @patch("proxmenux_oci.installer.host.bridges", return_value=[ + {"iface": "vmbr0", "cidr": "192.0.2.10/24"} + ]) + @patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid") + @patch("proxmenux_oci.installer.host.gpus", return_value={"intel": [], "amd": [], "nvidia": False}) + @patch("proxmenux_oci.installer.access.ask_ipv4", return_value=("dhcp", None)) + @patch("proxmenux_oci.installer.access.ask_addresses", side_effect=_addresses) + @patch("proxmenux_oci.installer.secrets.token_hex", return_value="a1" * 24) + def test_database_secrets_are_generated_without_a_prompt_or_static_value(self, token_hex, *_): + requirements = { + "mysql": "MYSQL_ROOT_PASSWORD", + "clickhouse": "CLICKHOUSE_PASSWORD", + "typesense": "TYPESENSE_API_KEY", + } + catalog = Catalog(ROOT) + + for app, name in requirements.items(): + with self.subTest(app=app): + ui = RecordingUI() + template = catalog.compose(app) + plan = build_deployment(template, ui, DEFAULT_MODE) + variable = next(item for item in plan["environment"] if item["name"] == name) + + self.assertTrue(variable["sensitive"]) + self.assertRegex(variable["value"], re.compile(r"^[0-9a-f]{48}$")) + self.assertNotIn(name, " ".join(ui.asked)) + contract = next(item for item in template["container_contract"]["environment"] + if item["name"] == name) + self.assertNotIn("prompt_user", contract) + token_hex.assert_called_with(24) + token_hex.reset_mock() + + +if __name__ == "__main__": + unittest.main()