firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync
Post-DHCP-incident hardening per HARDEN.md.
- apply guard: refuse (ConflictError, `force` overrides) when a
network-managed interface would end up in no zone; absent
`interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
+ advisory in /api/status/pending; zones.js banner + interfaces-picker
last-zone confirm
- target drift (Option A): absent or default-normalizing target is
unmanaged: not diffed, never re-set by apply; create_zone runs
--new-zone first and sets non-default targets only; importer omits
the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
({timestamp, default_zone, zones, config}); drop the empty post-apply
skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
(get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
867 passing
This commit is contained in:
+135
-58
@@ -34,10 +34,13 @@ from daemon.iface import (
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib import network
|
||||
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
|
||||
from lib.firewall import (
|
||||
_normalize_target,
|
||||
_now_iso,
|
||||
_parse_active_zones,
|
||||
_parse_all_zones_output,
|
||||
_parse_zone_output,
|
||||
fw_change_summary,
|
||||
)
|
||||
@@ -143,8 +146,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
then adding desired values. Reloads firewalld at the end.
|
||||
|
||||
With *force* False (default), a ``ConflictError`` is raised before any
|
||||
mutation if the config would strip both https and ssh from the default
|
||||
zone; pass ``force=True`` to override.
|
||||
mutation in two cases; pass ``force=True`` to override either:
|
||||
|
||||
- the config would strip both https and ssh from the default zone
|
||||
(management lockout);
|
||||
- a network-subsystem-managed interface would end up with no firewall
|
||||
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
|
||||
Zones whose config omits the ``interfaces`` key are left hands-off, so
|
||||
their current live interfaces count as coverage, as do the live
|
||||
interfaces of zones that are live but absent from the config.
|
||||
"""
|
||||
from lib.firewall import get_config as _get_lib_config
|
||||
|
||||
@@ -168,15 +178,52 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
f'to the zone\'s services, or pass {{"force": true}}.'
|
||||
)
|
||||
|
||||
full_state: dict[str, Any] = {
|
||||
"active_zones": {},
|
||||
"interfaces": [],
|
||||
"available_services": [],
|
||||
"zones": {},
|
||||
"rich_rules": {},
|
||||
"timestamp": "",
|
||||
}
|
||||
_save_backup(full_state)
|
||||
# Coverage guard: after apply, every network-managed interface must
|
||||
# belong to a zone or traffic (and DHCP) on that segment is dropped.
|
||||
live_active = _parse_active_zones(
|
||||
run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
)
|
||||
covered: set[str] = set()
|
||||
for zn, zc in cfg_zones.items():
|
||||
if "interfaces" in (zc if isinstance(zc, dict) else {}):
|
||||
covered.update(zc["interfaces"])
|
||||
else:
|
||||
covered.update(live_active.get(zn, []))
|
||||
covered.update(
|
||||
iface
|
||||
for zn, ifaces in live_active.items()
|
||||
if zn not in cfg_zones
|
||||
for iface in ifaces
|
||||
)
|
||||
net_cfg = network.get_config()
|
||||
guarded = [
|
||||
name
|
||||
for name in net_cfg.get("interfaces", {})
|
||||
if name != "lo" and not name.startswith("wg")
|
||||
]
|
||||
uncovered = [name for name in guarded if name not in covered]
|
||||
if uncovered:
|
||||
raise ConflictError(
|
||||
"Refusing to apply: "
|
||||
f"{', '.join(repr(n) for n in uncovered)} "
|
||||
f"would have no firewall zone coverage after apply, so all "
|
||||
f"traffic (including DHCP) from those segments would be "
|
||||
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
|
||||
)
|
||||
|
||||
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
|
||||
# the declarative config, captured before any mutation. The permanent
|
||||
# view is what is reproducible for manual recovery.
|
||||
backup_path = _save_backup(
|
||||
{
|
||||
"timestamp": _now_iso(),
|
||||
"default_zone": _default_zone(),
|
||||
"zones": _parse_all_zones_output(
|
||||
run(["firewall-cmd", "--list-all-zones", "--permanent"], sudo=True)
|
||||
),
|
||||
"config": cfg,
|
||||
}
|
||||
)
|
||||
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
applied: list[str] = []
|
||||
@@ -250,33 +297,38 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
).get("interfaces", [])
|
||||
for iface in current_ifaces:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in zone_cfg.get("interfaces", []):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
# Absent "interfaces" key = hands off (keep the zone's live
|
||||
# interfaces); an explicit empty list = intentional unassign-all.
|
||||
if "interfaces" in zone_cfg:
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
zone_name,
|
||||
run(
|
||||
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
|
||||
),
|
||||
).get("interfaces", [])
|
||||
for iface in current_ifaces:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in zone_cfg.get("interfaces", []):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
||||
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
|
||||
@@ -382,15 +434,6 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
_save_config(cfg)
|
||||
|
||||
_reload()
|
||||
full_state = {
|
||||
"active_zones": {},
|
||||
"interfaces": [],
|
||||
"available_services": [],
|
||||
"zones": {},
|
||||
"rich_rules": {},
|
||||
"timestamp": "",
|
||||
}
|
||||
backup_path = _save_backup(full_state)
|
||||
# Record the applied config snapshot + hash so pending-changes detection
|
||||
# and cancel/revert work like the hash-based subsystems.
|
||||
applied_cfg = _get_config()
|
||||
@@ -612,11 +655,16 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: Optional JSON body; ``{"force": true}`` overrides the
|
||||
management-lockout guard for the default zone.
|
||||
management-lockout guard and the interface-coverage guard.
|
||||
|
||||
Returns:
|
||||
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
||||
and ``synced`` (affected subsystems).
|
||||
|
||||
Raises:
|
||||
ConflictError: If the config would strip both https and ssh from the
|
||||
default zone, or would leave a network-managed interface without
|
||||
zone coverage, and ``force`` is not set.
|
||||
"""
|
||||
force = bool(_body and _body.get("force"))
|
||||
result = _config_apply(force=force)
|
||||
@@ -631,7 +679,11 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
@registry.register(POST_FIREWALL_ZONES_CREATE)
|
||||
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Create new zone via firewall-cmd, emit sync event, refresh state.
|
||||
"""Create a new firewall zone, emit sync event, refresh state.
|
||||
|
||||
Runs ``--new-zone`` first (required before ``--set-target``), then sets
|
||||
the target only when it normalizes to something other than ``default``
|
||||
(the implicit firewalld target is never re-set), then reloads.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
@@ -652,15 +704,21 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
if zone_name in available:
|
||||
raise ValueError(f"Zone '{zone_name}' already exists")
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={target}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
# Create the zone first; --set-target requires the zone to exist.
|
||||
run(["firewall-cmd", f"--new-zone={zone_name}", "--permanent"], sudo=True)
|
||||
# "default" is firewalld's implicit target and cannot be meaningfully
|
||||
# re-set, so only explicit ACCEPT/DROP/REJECT targets are applied.
|
||||
normalized_target = _normalize_target(target)
|
||||
if normalized_target != "default":
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={normalized_target}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
||||
sync_result = bus.emit(
|
||||
@@ -707,6 +765,11 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Replace zone interfaces, reassigning interfaces from old zones.
|
||||
|
||||
When the new selection leaves an interface in no zone at all, a
|
||||
prominent warning is logged (clients on that segment lose connectivity
|
||||
and DHCP); the operation is not blocked since it is a deliberate UI
|
||||
action.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``interfaces`` list.
|
||||
@@ -761,6 +824,20 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Flag interfaces that ended up in no zone at all — clients on those
|
||||
# segments lose connectivity (including DHCP).
|
||||
for iface in set(active.get(zone, [])) - set(interfaces):
|
||||
if not any(
|
||||
iface in az_ifaces for az, az_ifaces in active.items() if az != zone
|
||||
):
|
||||
logger.warning(
|
||||
"Interface '%s' is now in NO firewall zone: clients on that "
|
||||
"segment will lose connectivity and DHCP (zone '%s' no longer "
|
||||
"covers it).",
|
||||
iface,
|
||||
zone,
|
||||
)
|
||||
|
||||
_reload()
|
||||
|
||||
# Update config
|
||||
|
||||
Reference in New Issue
Block a user