Files
NexaFabric/backend/app/services/providers/proxmox.py
T
nessi da60155710 feat: add firewall enforcement status checking to policy apply with datacenter/node/guest/interface validation and warning collection
Add cluster_firewall_options_url and node_firewall_options_url helpers to build firewall options endpoint paths, implement config_interface_status to parse network interface firewall flags from VM/LXC config with bridge/firewall/raw fields, add firewall_enforcement_status to check enable status across datacenter/node/guest levels and validate interface firewall flags with warning collection for disabled settings, extend apply_rules response with enforcement_
2026-07-09 21:16:13 +02:00

353 lines
16 KiB
Python

from typing import Any
import httpx
from app.services.providers.base import Provider, ProviderConnection
class ProxmoxProvider(Provider):
name = "proxmox"
ignored_guest_interface_prefixes = (
"br-",
"cali",
"cni",
"docker",
"flannel",
"kube",
"lo",
"veth",
"virbr",
)
def auth_header(self, token: str) -> str:
token = token.strip()
if token.startswith("PVEAPIToken="):
return token
return f"PVEAPIToken={token}"
async def test_connection(self, connection: ProviderConnection) -> dict[str, Any]:
headers = {"Authorization": self.auth_header(connection.token)}
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
response = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/version",
headers=headers,
)
response.raise_for_status()
return response.json().get("data", {})
async def sync_inventory(self, connection: ProviderConnection) -> dict[str, list[dict[str, Any]]]:
base_url = connection.api_url.rstrip("/")
headers = {"Authorization": self.auth_header(connection.token)}
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
resources = await client.get(
f"{base_url}/api2/json/cluster/resources",
headers=headers,
)
resources.raise_for_status()
data = resources.json().get("data", [])
nodes = [item for item in data if item.get("type") == "node"]
workloads = [item for item in data if item.get("type") in {"qemu", "lxc"}]
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=8) as client:
for workload in workloads:
await self.enrich_workload_ips(client, base_url, headers, workload)
networks = await self.list_networks(connection)
return {"nodes": nodes, "workloads": workloads, "networks": networks}
async def enrich_workload_ips(
self,
client: httpx.AsyncClient,
base_url: str,
headers: dict[str, str],
workload: dict[str, Any],
) -> None:
node = workload.get("node")
vmid = workload.get("vmid")
kind = workload.get("type")
workload["ip_addresses"] = []
if not node or not vmid:
return
if kind == "qemu":
try:
response = await client.get(
f"{base_url}/api2/json/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces",
headers=headers,
)
if response.status_code >= 400:
return
interfaces = response.json().get("data", {}).get("result", [])
for interface in interfaces:
interface_name = str(interface.get("name") or "").lower()
if interface_name.startswith(self.ignored_guest_interface_prefixes):
continue
for address in interface.get("ip-addresses", []):
ip_address = address.get("ip-address")
prefix = address.get("prefix")
if ip_address and ":" not in ip_address and prefix is not None:
workload["ip_addresses"].append(f"{ip_address}/{prefix}")
except httpx.HTTPError:
return
if kind == "lxc":
try:
response = await client.get(
f"{base_url}/api2/json/nodes/{node}/lxc/{vmid}/config",
headers=headers,
)
if response.status_code >= 400:
return
config = response.json().get("data", {})
for key, value in config.items():
if key.startswith("net") and isinstance(value, str):
for part in value.split(","):
if part.startswith("ip="):
ip_address = part.removeprefix("ip=")
if ip_address != "dhcp" and ":" not in ip_address:
workload["ip_addresses"].append(ip_address)
except httpx.HTTPError:
return
async def list_networks(self, connection: ProviderConnection) -> list[dict[str, Any]]:
headers = {"Authorization": self.auth_header(connection.token)}
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
resources = await client.get(
f"{connection.api_url.rstrip('/')}/api2/json/cluster/resources",
headers=headers,
)
resources.raise_for_status()
data = resources.json().get("data", [])
return [item for item in data if item.get("type") in {"network", "sdn"}]
async def preview_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
return {
"provider": self.name,
"read_only": connection.read_only,
"generated": rules,
"warnings": ["Preview only. No Proxmox firewall changes were sent."],
}
async def list_firewall_rules(self, connection: ProviderConnection, target: dict[str, Any]) -> list[dict[str, Any]]:
headers = {"Authorization": self.auth_header(connection.token)}
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
response = await client.get(self.firewall_rules_url(connection, target), headers=headers)
response.raise_for_status()
return response.json().get("data", [])
def firewall_rules_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
node = target["node"]
vmid = target["vmid"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/rules"
def firewall_options_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
node = target["node"]
vmid = target["vmid"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/options"
def workload_config_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
node = target["node"]
vmid = target["vmid"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/config"
def cluster_firewall_options_url(self, connection: ProviderConnection) -> str:
return f"{connection.api_url.rstrip('/')}/api2/json/cluster/firewall/options"
def node_firewall_options_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
node = target["node"]
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/firewall/options"
def policy_marker(self, rule: dict[str, Any]) -> str:
return f"NexaFabric policy={rule.get('policy_id')}"
def network_firewall_enabled_value(self, value: str) -> str:
parts = [part for part in value.split(",") if part]
found = False
updated = []
for part in parts:
if part.startswith("firewall="):
updated.append("firewall=1")
found = True
else:
updated.append(part)
if not found:
updated.append("firewall=1")
return ",".join(updated)
async def delete_existing_policy_rules(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
rules_url: str,
marker: str,
) -> list[dict[str, Any]]:
existing_response = await client.get(rules_url, headers=headers)
existing_response.raise_for_status()
existing_rules = existing_response.json().get("data", [])
deletions = []
for existing_rule in sorted(existing_rules, key=lambda item: int(item.get("pos", 0)), reverse=True):
comment = str(existing_rule.get("comment") or "")
pos = existing_rule.get("pos")
if marker in comment and pos is not None:
delete_response = await client.delete(f"{rules_url}/{pos}", headers=headers)
delete_response.raise_for_status()
deletions.append({"pos": pos, "comment": comment})
return deletions
async def enable_guest_firewall(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
connection: ProviderConnection,
target: dict[str, Any],
) -> dict[str, Any]:
response = await client.put(self.firewall_options_url(connection, target), headers=headers, data={"enable": 1})
response.raise_for_status()
return response.json().get("data")
async def enable_guest_firewall_interfaces(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
connection: ProviderConnection,
target: dict[str, Any],
) -> list[dict[str, Any]]:
config_url = self.workload_config_url(connection, target)
response = await client.get(config_url, headers=headers)
response.raise_for_status()
config = response.json().get("data", {})
changes: dict[str, str] = {}
for key, value in config.items():
if not key.startswith("net") or not isinstance(value, str):
continue
enabled_value = self.network_firewall_enabled_value(value)
if enabled_value != value:
changes[key] = enabled_value
if changes:
update_response = await client.put(config_url, headers=headers, data=changes)
update_response.raise_for_status()
return [{"interface": key, "firewall": 1} for key in sorted(changes)]
def config_interface_status(self, config: dict[str, Any]) -> list[dict[str, Any]]:
interfaces = []
for key, value in config.items():
if not key.startswith("net") or not isinstance(value, str):
continue
parts = {part.split("=", 1)[0]: part.split("=", 1)[1] for part in value.split(",") if "=" in part}
interfaces.append(
{
"interface": key,
"bridge": parts.get("bridge"),
"firewall": parts.get("firewall") == "1",
"raw": value,
}
)
return interfaces
async def firewall_enforcement_status(
self,
client: httpx.AsyncClient,
headers: dict[str, str],
connection: ProviderConnection,
target: dict[str, Any],
) -> dict[str, Any]:
status: dict[str, Any] = {"target": target, "warnings": []}
checks = [
("datacenter", self.cluster_firewall_options_url(connection)),
("node", self.node_firewall_options_url(connection, target)),
("guest", self.firewall_options_url(connection, target)),
("config", self.workload_config_url(connection, target)),
]
for name, url in checks:
try:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json().get("data", {})
except Exception as exc:
status[name] = {"error": str(exc)}
status["warnings"].append(f"Unable to read {name} firewall status: {exc}")
continue
if name == "config":
interfaces = self.config_interface_status(data)
status["interfaces"] = interfaces
disabled = [interface["interface"] for interface in interfaces if not interface.get("firewall")]
if disabled:
status["warnings"].append(f"VM/LXC network firewall flag is disabled on: {', '.join(disabled)}")
continue
status[name] = data
enabled = data.get("enable")
if str(enabled) not in {"1", "True", "true"}:
label = {"datacenter": "Datacenter", "node": "Node", "guest": "VM/LXC"}.get(name, name)
status["warnings"].append(f"{label} firewall enable option is not active.")
return status
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
if connection.read_only:
return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
missing_mapping = [rule for rule in rules if "provider_target" not in rule]
if missing_mapping:
return {
"applied": False,
"reason": "Live apply requires every rule to resolve to a concrete Proxmox VM/LXC target.",
"rules": missing_mapping,
}
headers = {"Authorization": self.auth_header(connection.token)}
grouped: dict[tuple[str, str, str, str], list[dict[str, Any]]] = {}
for rule in rules:
target = rule["provider_target"]
marker = self.policy_marker(rule)
key = (str(target["node"]), str(target["kind"]), str(target["vmid"]), marker)
grouped.setdefault(key, []).append(rule)
applied_rules = []
deleted_rules = []
enabled_targets = []
enabled_interfaces = []
enforcement_status = []
audit_only_rules = []
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
for target_rules in grouped.values():
target = target_rules[0]["provider_target"]
rules_url = self.firewall_rules_url(connection, target)
marker = self.policy_marker(target_rules[0])
deleted_rules.extend(await self.delete_existing_policy_rules(client, headers, rules_url, marker))
if any(not rule.get("audit_only") for rule in target_rules):
enabled_targets.append({"target": target, "result": await self.enable_guest_firewall(client, headers, connection, target)})
enabled_interfaces.append({"target": target, "interfaces": await self.enable_guest_firewall_interfaces(client, headers, connection, target)})
for rule in target_rules:
if rule.get("audit_only"):
audit_only_rules.append(
{
"policy_id": rule.get("policy_id"),
"target": target,
"reason": "Audit mode does not enforce or write blocking Proxmox rules.",
}
)
continue
provider_rule = rule.get("provider_rule")
if not provider_rule:
return {
"applied": False,
"reason": "Resolved rule is missing provider_rule payload.",
"rule": rule,
}
create_response = await client.post(rules_url, headers=headers, data=provider_rule)
create_response.raise_for_status()
applied_rules.append({"target": target, "rule": provider_rule, "result": create_response.json().get("data")})
enforcement_status.append(await self.firewall_enforcement_status(client, headers, connection, target))
return {
"applied": True,
"rules_written": len(applied_rules),
"rules_deleted": len(deleted_rules),
"firewall_enabled": enabled_targets,
"interfaces_enabled": enabled_interfaces,
"enforcement_status": enforcement_status,
"audit_only": audit_only_rules,
"rules": applied_rules,
}