Files
OPNexus/backend/app/services/policy_hierarchy_deploy_service.py
T
Julian Boldt e26b26eca6
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release v1.10.1-rc2
Siehe CHANGELOG.md
2026-10-07 09:34:26 +02:00

379 lines
16 KiB
Python

"""1:1 port of rule_deploy_service.py for PolicyHierarchyRule, plus the
positioning step this entity needs and plain rules don't: OPNsense has no
concept of "this rule belongs to hierarchy group X" — every rule from
every level of the tree lands in the same flat per-interface rule list on
each host. Since every OPNexus-pushed rule sets "quick": "1" (strict
first-match), the actual ORDER those rules end up in on the firewall is
what makes the inheritance real, not just cosmetic — see
policy_hierarchy_service.ordered_hierarchy_rules() for the target order,
and reassert_ordering() below for how it's actually established via
`moveRuleBefore`.
Deploy lockout protection is identical to rule_deploy_service.py: a
successful deploy goes to "pending" with a revert_deadline, confirm_rule()
is the human "I still have access" signal, and
sweep_expired_hierarchy_deployments() (called from main.py's background
loop) reverts it if that doesn't happen in time.
One thing this entity does differently: if the rule content itself lands
successfully (addRule/setRule + apply all succeed) but the follow-up
`moveRuleBefore` positioning fails, the deployment goes straight to
"error", not "pending" — the rule is live on the firewall but not
necessarily in the position needed for other "quick" rules on that
interface to still mean what they're supposed to mean. That's a
security-relevant fact, not "unconfirmed", so it must not be reported as a
mere pending state.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.models.host import OpnsenseHost
from app.models.policy_hierarchy_group import PolicyHierarchyGroup
from app.models.policy_hierarchy_rule import PolicyHierarchyRule
from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment
from app.services.audit_service import tracked_rollback
from app.services import opnsense_client
from app.services.opnsense_client import OpnsenseAPIError
from app.services.policy_hierarchy_service import ancestor_chain, descendants, ordered_hierarchy_rules
def _now() -> datetime:
return datetime.now(timezone.utc)
def _build_payload(rule: PolicyHierarchyRule) -> dict:
return {
"enabled": "1" if rule.enabled else "0",
"action": rule.action,
"quick": "1",
"interface": rule.interface,
"direction": rule.direction,
"ipprotocol": rule.ipprotocol,
"protocol": rule.protocol,
"source_net": rule.source_net,
"source_port": rule.source_port or "",
"destination_net": rule.destination_net,
"destination_port": rule.destination_port or "",
"description": rule.description,
"log": "0",
}
async def _load_all_groups(db: AsyncSession) -> list[PolicyHierarchyGroup]:
return list((await db.scalars(
select(PolicyHierarchyGroup).options(selectinload(PolicyHierarchyGroup.hosts))
)).all())
async def _load_all_rules(db: AsyncSession) -> list[PolicyHierarchyRule]:
return list((await db.scalars(select(PolicyHierarchyRule))).all())
async def reassert_ordering(db: AsyncSession, host: OpnsenseHost, interface: str) -> None:
"""Re-establishes the correct pre- AND post-rulebase order for every
currently-deployed hierarchy rule this host inherits on `interface`.
Raises OpnsenseAPIError (uncaught) on the first failed move — callers
either propagate that as a deploy error (see _deploy_to_host) or
collect it per-host (see the group-wide reassert-ordering endpoint).
Uses backward chaining, not forward: moveRuleBefore(selected, target)
is a pairwise primitive with no "move to start/end" sentinel. Given a
target order [r1..rN], moving forward (r1 before r2, then r2 before
r3, ...) re-disturbs pairs already fixed by an earlier move. Moving
backward — for i from N down to 2, moveRuleBefore(r[i-2], r[i-1]) —
each move only ever repositions a rule relative to one that is already
in its final place and never touched again.
"""
all_groups = await _load_all_groups(db)
all_rules = await _load_all_rules(db)
deployments_by_rule = {
d.policy_hierarchy_id: d
for d in (await db.scalars(
select(PolicyHierarchyRuleDeployment).where(PolicyHierarchyRuleDeployment.host_id == host.id)
)).all()
}
desired_by_rulebase: dict[str, list[str]] = {}
for rulebase in ("pre", "post"):
desired_by_rulebase[rulebase] = []
for rule in ordered_hierarchy_rules(host, interface, rulebase, all_groups, all_rules):
dep = deployments_by_rule.get(rule.id)
if dep and dep.opnsense_policy_hierarchy_uuid and dep.status in ("pending", "success", "reverted"):
desired_by_rulebase[rulebase].append(dep.opnsense_policy_hierarchy_uuid)
hierarchy_uuids = set(desired_by_rulebase["pre"] + desired_by_rulebase["post"])
if not hierarchy_uuids:
return
# searchRule is sorted by OPNsense's sort_order and returns MVC rules
# with their prio_group. Preserve every non-hierarchy MVC rule in that
# live order, then bracket it with inherited pre/post rules. Legacy and
# automatic rules are not movable through moveRuleBefore and are left
# untouched. Verified against FilterController::searchRuleAction and
# moveRuleBeforeAction in opnsense/core.
live = await opnsense_client.search_rules(host, all_rows=True)
rows = live.get("rows", []) if isinstance(live, dict) else []
uuid_to_group = {
row.get("uuid"): row.get("prio_group")
for row in rows
if row.get("uuid") in hierarchy_uuids and row.get("prio_group")
}
# Compatibility fallback for an incomplete/older search response. It
# still restores hierarchy-to-hierarchy ordering and avoids guessing
# about external anchors that were not returned by the firewall.
if set(uuid_to_group) != hierarchy_uuids:
for anchors in desired_by_rulebase.values():
for i in range(len(anchors), 1, -1):
await opnsense_client.move_rule_before(host, anchors[i - 2], anchors[i - 1])
return
for prio_group in sorted(set(uuid_to_group.values())):
pre = [uuid for uuid in desired_by_rulebase["pre"] if uuid_to_group.get(uuid) == prio_group]
post = [uuid for uuid in desired_by_rulebase["post"] if uuid_to_group.get(uuid) == prio_group]
other = [
row["uuid"] for row in rows
if row.get("prio_group") == prio_group
and row.get("uuid")
and row.get("uuid") not in hierarchy_uuids
and not row.get("legacy")
]
target = pre + other + post
for i in range(len(target), 1, -1):
await opnsense_client.move_rule_before(host, target[i - 2], target[i - 1])
async def reassert_after_managed_change(
db: AsyncSession,
host: OpnsenseHost,
interfaces: set[str] | None = None,
) -> None:
"""Repair hierarchy ordering after another managed feature reloads a host.
Aliases can cause a broad filter reconfigure, so ``interfaces=None``
discovers every inherited hierarchy interface. Rule and NAT deployers
pass the affected interface to avoid unnecessary API calls.
"""
if host.policy_hierarchy_group_id is None:
return
if interfaces is None:
all_groups = await _load_all_groups(db)
all_rules = await _load_all_rules(db)
chain_ids = {group.id for group in ancestor_chain(host, all_groups)}
interfaces = {
rule.interface for rule in all_rules
if rule.hierarchy_group_id in chain_ids and rule.enabled
}
for interface in sorted(interfaces):
await reassert_ordering(db, host, interface)
async def reassert_group_ordering(db: AsyncSession, group: PolicyHierarchyGroup) -> list[str]:
"""Explicit "Reassert ordering" action for a group. Reasserts every host in the group's own subtree,
for every interface any rule in that host's ancestor chain uses.
Returns a list of human-readable per-host/interface error strings
(empty means every reassert succeeded)."""
all_groups = await _load_all_groups(db)
all_rules = await _load_all_rules(db)
hosts = {}
for g in descendants(group.id, all_groups):
for h in g.hosts:
hosts[h.id] = h
errors: list[str] = []
for host in sorted(hosts.values(), key=lambda h: (h.name, str(h.id))):
if not host.enabled:
continue
chain_ids = {g.id for g in ancestor_chain(host, all_groups)}
interfaces = sorted({r.interface for r in all_rules if r.hierarchy_group_id in chain_ids})
for interface in interfaces:
try:
await reassert_ordering(db, host, interface)
except OpnsenseAPIError as exc:
errors.append(f"{host.name} ({interface}): {exc}")
await db.commit()
return errors
async def _deploy_to_host(db: AsyncSession, rule: PolicyHierarchyRule, host: OpnsenseHost) -> PolicyHierarchyRuleDeployment:
payload = _build_payload(rule)
existing = await db.scalar(
select(PolicyHierarchyRuleDeployment).where(
PolicyHierarchyRuleDeployment.policy_hierarchy_id == rule.id,
PolicyHierarchyRuleDeployment.host_id == host.id,
)
)
if existing and existing.status in ("success", "reverted") and existing.last_payload == payload:
existing.status = "success"
existing.message = None
existing.revert_deadline = None
existing.deployed_at = _now()
return existing
if existing and existing.status in ("pending", "revert_failed"):
return existing
prior_uuid = existing.opnsense_policy_hierarchy_uuid if existing else None
prior_live_payload = existing.last_payload if existing else None
new_uuid = prior_uuid
try:
if prior_uuid:
await opnsense_client.set_rule(host, prior_uuid, payload)
else:
result = await opnsense_client.add_rule(host, payload)
new_uuid = result.get("uuid") if isinstance(result, dict) else None
if not new_uuid:
raise OpnsenseAPIError("Firewall hat keine Regel-UUID zurückgegeben; Stand manuell prüfen")
await opnsense_client.apply(host)
except OpnsenseAPIError as exc:
if existing:
existing.status = "error"
existing.opnsense_policy_hierarchy_uuid = new_uuid
existing.message = str(exc)
existing.revert_deadline = None
existing.deployed_at = _now()
return existing
deployment = PolicyHierarchyRuleDeployment(
policy_hierarchy_id=rule.id, host_id=host.id, status="error", message=str(exc),
opnsense_policy_hierarchy_uuid=new_uuid, deployed_at=_now(),
)
db.add(deployment)
return deployment
# Rule content is live. Persist the uuid now (flushed) so the
# positioning pass below sees this deployment as an anchor too.
if existing:
deployment = existing
deployment.opnsense_policy_hierarchy_uuid = new_uuid
else:
deployment = PolicyHierarchyRuleDeployment(
policy_hierarchy_id=rule.id, host_id=host.id, status="pending",
opnsense_policy_hierarchy_uuid=new_uuid, deployed_at=_now(),
)
db.add(deployment)
await db.flush()
try:
await reassert_ordering(db, host, rule.interface)
except OpnsenseAPIError as exc:
# Live but not provably in the right position — this must not read
# as a merely-unconfirmed pending change (see module docstring).
deployment.status = "error"
deployment.message = f"Regel wurde übernommen, Positionierung ('quick') aber nicht bestätigt: {exc}"
deployment.revert_deadline = None
deployment.deployed_at = _now()
return deployment
deadline = _now() + timedelta(seconds=settings.deploy_confirm_window_seconds)
deployment.status = "pending"
deployment.message = None
deployment.deployed_at = _now()
deployment.revert_deadline = deadline
deployment.previous_payload = prior_live_payload
deployment.last_payload = payload
return deployment
async def deploy_hierarchy_rule(db: AsyncSession, rule: PolicyHierarchyRule, hosts: list[OpnsenseHost]) -> list[PolicyHierarchyRuleDeployment]:
from app.services.host_maintenance_service import ensure_no_maintenance
await ensure_no_maintenance(db, hosts)
deployments = [await _deploy_to_host(db, rule, host) for host in hosts]
await db.commit()
return deployments
async def confirm_hierarchy_rule(db: AsyncSession, rule: PolicyHierarchyRule) -> list[PolicyHierarchyRuleDeployment]:
result = await db.execute(
select(PolicyHierarchyRuleDeployment).where(
PolicyHierarchyRuleDeployment.policy_hierarchy_id == rule.id,
PolicyHierarchyRuleDeployment.status == "pending",
PolicyHierarchyRuleDeployment.revert_deadline > _now(),
)
)
deployments = list(result.scalars().all())
for deployment in deployments:
deployment.status = "success"
deployment.revert_deadline = None
await db.commit()
return deployments
async def sweep_expired_hierarchy_deployments(db: AsyncSession) -> list[PolicyHierarchyRuleDeployment]:
"""Called periodically from main.py's background task — see
rule_deploy_service.sweep_expired_deployments, which this mirrors."""
from app.services.policy_service import policy_lock
await policy_lock(db)
result = await db.execute(
select(PolicyHierarchyRuleDeployment).where(
PolicyHierarchyRuleDeployment.status == "pending",
PolicyHierarchyRuleDeployment.revert_deadline <= _now(),
)
)
expired = list(result.scalars().all())
touched: list[PolicyHierarchyRuleDeployment] = []
for deployment in expired:
await revert_hierarchy_deployment(db, deployment)
touched.append(deployment)
await db.commit()
return touched
async def undeploy_hierarchy_rule(db: AsyncSession, rule: PolicyHierarchyRule) -> None:
failures = []
result = await db.execute(select(PolicyHierarchyRuleDeployment).where(PolicyHierarchyRuleDeployment.policy_hierarchy_id == rule.id))
for deployment in result.scalars().all():
host = await db.get(OpnsenseHost, deployment.host_id)
if host and deployment.opnsense_policy_hierarchy_uuid:
try:
await opnsense_client.del_rule(host, deployment.opnsense_policy_hierarchy_uuid)
await opnsense_client.apply(host)
except OpnsenseAPIError as exc:
deployment.status = "error"
deployment.message = str(exc)
failures.append(host.name)
continue
await db.delete(deployment)
await db.flush()
if failures:
await db.commit()
raise OpnsenseAPIError("Regel konnte nicht von allen Hosts entfernt werden: " + ", ".join(failures))
@tracked_rollback("policy_hierarchy")
async def revert_hierarchy_deployment(db: AsyncSession, deployment: PolicyHierarchyRuleDeployment) -> bool:
"""Retryable rollback. Keep the original payload on failures. Restoring
a previous payload (or deleting the rule) does not re-run positioning —
moveRuleBefore only affects ordering, and OPNsense doesn't move rules
around a firewall's rule list on its own as a side effect of
setRule/delRule, so whatever order was last established stays valid
for the rules that remain."""
host = await db.get(OpnsenseHost, deployment.host_id)
if host is None:
await db.delete(deployment)
return True
try:
if deployment.previous_payload is None:
if deployment.opnsense_policy_hierarchy_uuid:
await opnsense_client.del_rule(host, deployment.opnsense_policy_hierarchy_uuid)
await opnsense_client.apply(host)
await db.delete(deployment)
else:
await opnsense_client.set_rule(host, deployment.opnsense_policy_hierarchy_uuid, deployment.previous_payload)
await opnsense_client.apply(host)
deployment.last_payload = deployment.previous_payload
deployment.status = "reverted"
deployment.message = None
deployment.revert_deadline = None
return True
except OpnsenseAPIError as exc:
deployment.status = "revert_failed"
deployment.message = str(exc)
deployment.revert_deadline = None
return False