docs: document Proxmox write permissions and firewall apply scope, add live apply implementation with rule resolution and provider integration
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 28s

Add minimum write privileges section covering VM.Audit and VM.Config.Network requirements for firewall orchestration, document NexaFabric comment marker approach for safe rule replacement, clarify that only VM/LXC-level rules with concrete workload targets are supported for live apply while security groups remain preview-only, add firewall interface checkbox requirement for enforcement, document
This commit is contained in:
2026-07-09 13:43:08 +02:00
parent 7fa4bcaad1
commit 1ada59d3c7
4 changed files with 324 additions and 17 deletions
+139 -12
View File
@@ -1,7 +1,7 @@
from datetime import datetime
import csv
import io
from ipaddress import ip_interface
from ipaddress import ip_address, ip_interface, ip_network
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
@@ -139,6 +139,18 @@ def ip_address_payload(db: Session, address: IpAddress) -> dict:
}
def is_ip_or_cidr(value: str) -> bool:
try:
ip_network(value, strict=False)
return True
except ValueError:
try:
ip_address(value)
return True
except ValueError:
return False
def cleanup_discovered_container_networks(db: Session) -> int:
removed = 0
discovered_networks = db.scalars(select(Network).where(Network.name == "discovered-ipam")).all()
@@ -181,6 +193,107 @@ def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addr
return imported
def workload_provider_target(db: Session, cluster: Cluster, ref: str) -> tuple[dict, Workload] | None:
if not ref.startswith("workload:"):
return None
workload_id = ref.removeprefix("workload:")
workload = db.get(Workload, workload_id)
if not workload or workload.cluster_id != cluster.id:
return None
node = db.get(Node, workload.node_id)
if not node:
return None
kind = "lxc" if workload.kind == "lxc" else "qemu"
return (
{
"node": node.name,
"kind": kind,
"vmid": workload.external_id,
"workload_id": workload.id,
"workload_name": workload.name,
},
workload,
)
def endpoint_values(db: Session, cluster: Cluster, ref: str) -> tuple[list[str | None], list[str]]:
if ref == "any":
return [None], []
resolved = workload_provider_target(db, cluster, ref)
if resolved:
_, workload = resolved
addresses = db.scalars(select(IpAddress).where(IpAddress.workload_id == workload.id).order_by(IpAddress.address)).all()
values = [address.address for address in addresses if address.address]
if values:
return values, []
return [], [f"Workload {workload.name} has no assigned IP address for provider-side source/destination matching."]
if is_ip_or_cidr(ref):
return [ref], []
return [], [f"Endpoint {ref} is not yet resolvable to a Proxmox firewall matcher."]
def proxmox_action(action: str) -> str:
return {"allow": "ACCEPT", "deny": "DROP", "reject": "REJECT"}.get(action, "ACCEPT")
def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPreview) -> FirewallPreview:
warnings = list(preview.warnings)
conflicts = list(preview.conflicts)
generated_rules: list[dict] = []
for rule_index, rule in enumerate(preview.generated_rules, start=1):
mapped = dict(rule)
direction = str(rule.get("direction", "ingress"))
target_ref = str(rule.get("destination") if direction == "ingress" else rule.get("source"))
target = workload_provider_target(db, cluster, target_ref)
if not target:
conflicts.append(
f"Rule {rule_index} needs a concrete {'destination' if direction == 'ingress' else 'source'} workload for Proxmox live apply."
)
generated_rules.append(mapped)
continue
provider_target, target_workload = target
remote_ref = str(rule.get("source") if direction == "ingress" else rule.get("destination"))
remote_values, endpoint_warnings = endpoint_values(db, cluster, remote_ref)
warnings.extend(endpoint_warnings)
if not remote_values:
conflicts.append(f"Rule {rule_index} cannot resolve {remote_ref} to a Proxmox firewall source/destination matcher.")
generated_rules.append({**mapped, "provider_target": provider_target})
continue
ports = str(rule.get("ports", "any"))
protocol = str(rule.get("protocol", "any"))
for remote_value in remote_values:
provider_rule = {
"type": "in" if direction == "ingress" else "out",
"action": proxmox_action(str(rule.get("action", "allow"))),
"enable": 1,
"comment": (
f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} "
f"rule={rule_index} target={target_workload.name}"
),
}
if protocol != "any":
provider_rule["proto"] = protocol
if ports != "any":
provider_rule["dport"] = ports
if remote_value:
provider_rule["source" if direction == "ingress" else "dest"] = remote_value
if rule.get("logging"):
provider_rule["log"] = "info"
mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule}
generated_rules.append(mapped_rule)
return FirewallPreview(
policy_id=preview.policy_id,
dry_run=preview.dry_run,
generated_rules=generated_rules,
warnings=warnings,
conflicts=conflicts,
)
@api_router.get("/setup/status", response_model=SetupStatus)
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
setting = setup_setting(db)
@@ -757,7 +870,7 @@ async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depe
cluster = db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
if not policy or not cluster:
raise HTTPException(status_code=404, detail="Policy or cluster not found")
preview = await FirewallOrchestrator().preview(cluster, policy)
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
write_audit(db, action="firewall.preview", object_type="policy", object_id=policy.id, user_id=user.id, new_values=preview.model_dump())
return preview
@@ -770,7 +883,7 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S
cluster = db.get(Cluster, payload.cluster_id) if payload.cluster_id else db.scalar(select(Cluster).order_by(Cluster.name).limit(1))
if not policy or not cluster:
raise HTTPException(status_code=404, detail="Policy or cluster not found")
preview = await FirewallOrchestrator().preview(cluster, policy)
preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy))
if payload.dry_run:
result = {
"applied": False,
@@ -778,17 +891,31 @@ async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: S
"reason": "Dry run completed. No firewall rules were applied.",
"rules": preview.generated_rules,
}
elif preview.conflicts:
result = {
"applied": False,
"reason": "Live apply stopped because the preview has unresolved conflicts.",
"conflicts": preview.conflicts,
"rules": preview.generated_rules,
}
else:
provider = get_provider(cluster.provider)
result = await provider.apply_rules(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
),
preview.generated_rules,
)
try:
result = await provider.apply_rules(
ProviderConnection(
api_url=cluster.api_url,
token=cluster.token_ref or "",
verify_tls=cluster.verify_tls,
read_only=cluster.mode == "read_only",
),
preview.generated_rules,
)
except Exception as exc:
result = {
"applied": False,
"reason": f"Provider apply failed: {exc}",
"rules": preview.generated_rules,
}
applied = bool(result.get("applied"))
operation_success = applied or payload.dry_run
job = Job(