fix(oci): confirm scoped host-monitor firewall access

This commit is contained in:
VAIO73
2026-09-27 13:28:53 +02:00
parent ca9dbba65b
commit 884817f05c
8 changed files with 250 additions and 1 deletions
+8
View File
@@ -291,6 +291,14 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
network = plan.get("network", {})
if plan.get("host_monitor"):
row(translate("Network"), translate("IP address and firewall of the host"))
firewall = plan.get("host_firewall")
if firewall:
row(translate("Host firewall"),
translate("allow TCP {port} from {subnet} via {bridge}").format(
port=firewall["port"], subnet=firewall["source"], bridge=firewall["bridge"]
))
else:
row(translate("Host firewall"), translate("not changed"))
elif "frontend_bridge" in network:
addresses = [network[key] for key in ("frontend_ipv4", "machine_learning_frontend_ipv4") if network.get(key)]
addresses += [service["frontend_ipv4"] for service in plan.get("services", []) if service.get("frontend_ipv4")]
+17
View File
@@ -61,6 +61,23 @@ def default_bridge(preferred: str) -> str:
return names[0]
def ipv4_subnet(bridge: str) -> str | None:
"""The IPv4 subnet configured on ``bridge``, if it has one.
This is deliberately taken from the node's bridge configuration instead
of guessing from a container address. A host-monitor shares the host
network namespace, so its firewall source scope must be the selected
host bridge's network.
"""
row = next((item for item in bridges(include_private=True)
if item.get("iface") == bridge), None)
try:
interface = ipaddress.ip_interface(str((row or {}).get("cidr") or ""))
except ValueError:
return None
return str(interface.network) if interface.version == 4 else None
def timezone() -> str:
try:
value = Path("/etc/timezone").read_text(encoding="utf-8").strip()
+42
View File
@@ -111,6 +111,45 @@ def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str:
return selected
def host_monitor_firewall_plan(template: dict[str, Any], bridge: str) -> dict[str, Any] | None:
"""Build the narrow firewall change a host-monitor profile explicitly declares.
Profiles without this opt-in declaration never propose a host firewall
change. The source network comes from the selected Proxmox bridge, not
from a catalog constant or the address of a particular test lab.
"""
profile = template.get("proxmox", {}).get("installer_profile", {})
declared = profile.get("host_monitor_firewall")
if declared is None:
return None
if not isinstance(declared, dict) or declared.get("protocol") != "tcp":
raise InstallError(translate("The host-monitor firewall declaration is invalid"))
port = declared.get("web_port")
if not isinstance(port, int) or not 1 <= port <= 65535:
raise InstallError(translate("The host-monitor firewall port is invalid"))
subnet = host.ipv4_subnet(bridge)
if not subnet:
raise InstallError(translate("The selected bridge has no IPv4 subnet for the host-monitor firewall"))
return {"bridge": bridge, "source": subnet, "protocol": "tcp", "port": port,
"confirmed": True}
def confirm_host_monitor_firewall(ui, template: dict[str, Any], bridge: str) -> dict[str, Any] | None:
"""Ask separately before allowing a narrowly-scoped host firewall rule."""
plan = host_monitor_firewall_plan(template, bridge)
if plan is None:
return None
summary = translate("The host-monitor web interface uses the host network.")
question = translate("Allow TCP port {port} from {subnet} through the host firewall? Existing firewall rules are not changed.").format(
port=plan["port"], subnet=plan["source"]
)
if ui.confirm(f"{summary}\n\n{translate('Selected bridge:')} {plan['bridge']}\n"
f"{translate('Allowed source subnet:')} {plan['source']}\n"
f"{translate('Allowed web port:')} TCP {plan['port']}\n\n{question}", False):
return plan
return None
def _confirm_warning(ui, warning: str | None, fallback: str, question: str) -> bool:
return ui.confirm(f"{translate(warning) if warning else translate(fallback)}\n\n{translate(question)}", False)
@@ -258,6 +297,8 @@ def build_deployment(
onboot = bool(defaults["onboot"])
start_after = True
host_firewall = confirm_host_monitor_firewall(ui, template, bridge) if host_monitor else None
environment: list[dict[str, str]] = []
for item in template["container_contract"]["environment"]:
name = item["name"]
@@ -423,6 +464,7 @@ def build_deployment(
return {
"host_monitor": host_monitor,
"monitor_scope": monitor_scope,
"host_firewall": host_firewall,
"vmid": int(vmid_text) if vmid_text else None,
"ostype": defaults.get("ostype", "auto-from-image"),
"hostname": hostname,