docs: add comprehensive docstrings and inline comments
Add docstrings to all handler functions in daemon/handlers/firewall.py, covering params, return values, and raised exceptions. Add inline comments to _config_apply() reconciliation steps and the request body merge order. Add docstrings across lib/ modules for emit helpers (_emit_str, _emit_int, etc.), volatile stripping logic, two-layer diff strategy, sync event dispatch, and all cross-subsystem sync subscribers (DnsToFirewall, WgToFirewall, FirewallToDhcp, NetworkToAllSync). Document WireGuard/networkd config parsers and key-value mappers in system_import.py. Add docstrings to _ep(), Registry.decorator, setup_logging, and _replace helper across daemon/ and lib/.
This commit is contained in:
@@ -94,6 +94,15 @@ def _format_path(path: str, params: dict[str, Any] | None) -> str:
|
||||
return path
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
"""Regex callback that replaces ``<key>`` segments with URL-encoded values.
|
||||
|
||||
Args:
|
||||
m: Match object containing the parameter name.
|
||||
|
||||
Returns:
|
||||
URL-encoded value from params dict, or original text if key
|
||||
not found.
|
||||
"""
|
||||
key = m.group(1)
|
||||
if key in params:
|
||||
return urllib.parse.quote(str(params[key]), safe="")
|
||||
|
||||
+263
-1
@@ -107,7 +107,12 @@ def _get_forward_ports(zone_name: str) -> list[str]:
|
||||
|
||||
|
||||
def _config_apply() -> dict[str, Any]:
|
||||
"""Apply the declarative config to live firewalld."""
|
||||
"""Apply saved declarative config to live firewalld.
|
||||
|
||||
For each zone in the config, reconciles interfaces, services, target,
|
||||
masquerade, rich rules, and forward ports by removing old values first,
|
||||
then adding desired values. Reloads firewalld at the end.
|
||||
"""
|
||||
from lib.firewall import get_config as _get_lib_config
|
||||
|
||||
cfg = _get_lib_config()
|
||||
@@ -126,6 +131,8 @@ def _config_apply() -> dict[str, Any]:
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
applied: list[str] = []
|
||||
for zone_name, zone_cfg in cfg_zones.items():
|
||||
# Two-step reconciliation: remove current values, then add desired values.
|
||||
# This ensures idempotency — running apply twice produces the same result.
|
||||
need_create = zone_name not in available
|
||||
|
||||
if need_create:
|
||||
@@ -155,6 +162,9 @@ def _config_apply() -> dict[str, Any]:
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Step 2: Reconcile services — remove all current, add desired list.
|
||||
# Firewall-cmd doesn't have a "set-services" bulk operation, so we
|
||||
# remove each existing service and then add each desired service.
|
||||
current_svcs: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_svcs = _parse_zone_output(
|
||||
@@ -183,6 +193,7 @@ def _config_apply() -> dict[str, Any]:
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 3: Reconcile interfaces — same remove-then-add pattern.
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
@@ -211,6 +222,7 @@ def _config_apply() -> dict[str, Any]:
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
|
||||
mq = zone_cfg.get("masquerade", False)
|
||||
if mq is not None:
|
||||
action = "--add-masquerade" if mq else "--remove-masquerade"
|
||||
@@ -219,6 +231,9 @@ def _config_apply() -> dict[str, Any]:
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
# Step 5: Reconcile rich rules — remove all current, add desired.
|
||||
# Note: firewall-cmd doesn't track rule IDs for rich rules, so we
|
||||
# must remove by full rule string match.
|
||||
current_rules = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
@@ -253,6 +268,7 @@ def _config_apply() -> dict[str, Any]:
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Step 6: Reconcile forward ports — same pattern.
|
||||
current_fps = _get_forward_ports(zone_name)
|
||||
for fp_str in current_fps:
|
||||
run(
|
||||
@@ -319,6 +335,16 @@ def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES)
|
||||
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return active + available zones from state store.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``active`` (zone→interfaces mapping) and
|
||||
``available`` (list of all zone names).
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
zones = fw.get("zones", {})
|
||||
@@ -327,6 +353,19 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES_INFO)
|
||||
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Return zone config by name; ``NotFoundError`` if absent.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` key (zone name).
|
||||
|
||||
Returns:
|
||||
Zone config dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``zone`` key is missing from body.
|
||||
NotFoundError: If the specified zone does not exist.
|
||||
"""
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
@@ -339,6 +378,15 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@registry.register(GET_FIREWALL_ZONES_ALL)
|
||||
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
"""Return list of all active zone configs.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
List of zone config dicts for all active zones.
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
zones = fw.get("zones", {})
|
||||
@@ -351,17 +399,53 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
|
||||
@registry.register(GET_FIREWALL_SERVICES)
|
||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||
"""Return list of available firewall services.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
List of service names available in firewalld.
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
return fw.get("available_services", [])
|
||||
|
||||
|
||||
@registry.register(GET_FIREWALL_CONFIG)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return declarative config from JSON store.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Full firewall config dict.
|
||||
"""
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register(POST_FIREWALL_CONFIG)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Save the full declarative firewall configuration.
|
||||
|
||||
Validates that the request body contains a ``zones`` dict, persists it
|
||||
to the JSON store, emits a ``config_saved`` sync event to the
|
||||
cross-subsystem sync bus, and refreshes the state store.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with a ``zones`` dict mapping zone names to
|
||||
zone configurations.
|
||||
|
||||
Returns:
|
||||
Dict with ``config_saved`` flag set to ``True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty, missing ``zones`` key,
|
||||
or ``zones`` is not a dict.
|
||||
"""
|
||||
if not body or "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
if not isinstance(body["zones"], dict):
|
||||
@@ -377,6 +461,18 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
|
||||
@registry.register(PATCH_FIREWALL_CONFIG)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Deep-merge body into current config, save, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with partial config to merge.
|
||||
|
||||
Returns:
|
||||
Dict with ``config_saved`` flag set to ``True``.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
from lib.common import deep_merge
|
||||
@@ -394,6 +490,19 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@registry.register(GET_FIREWALL_CONFIG_PENDING)
|
||||
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return pending changes with human-readable summaries.
|
||||
|
||||
Compares saved config against live firewalld state and returns a list
|
||||
of differences.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``pending`` (list of change dicts) and ``pending_summary``
|
||||
(human-readable strings).
|
||||
"""
|
||||
fw = _get_fw_state()
|
||||
pending = fw.get("pending", {})
|
||||
|
||||
@@ -408,6 +517,16 @@ def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
@registry.register(POST_FIREWALL_CONFIG_APPLY)
|
||||
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Apply pending config to live firewalld, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
||||
and ``synced`` (affected subsystems).
|
||||
"""
|
||||
result = _config_apply()
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
sync_result = bus.emit(
|
||||
@@ -420,6 +539,18 @@ 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.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``name`` (zone name) and optional ``target``.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` key set to the zone name.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty, missing name, or zone already exists.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone_name = body.get("name", "").strip()
|
||||
@@ -451,6 +582,19 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
|
||||
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Delete zone via firewall-cmd, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` (zone name).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` key set to the zone name.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is empty or missing ``zone``.
|
||||
NotFoundError: If the zone does not exist.
|
||||
"""
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
@@ -469,6 +613,19 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Replace zone interfaces, reassigning interfaces from old zones.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``interfaces`` list.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``interfaces`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone``.
|
||||
NotFoundError: If the zone does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -544,6 +701,19 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
|
||||
@registry.register(POST_FIREWALL_ZONES_SERVICES)
|
||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Replace zone services, emitting sync event and refreshing state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``services`` list.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``services`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone``.
|
||||
NotFoundError: If the zone does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -586,6 +756,20 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
|
||||
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
|
||||
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Add rich rule with auto-generated ID from uuid4.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` (zone name) and ``rule``
|
||||
(rich rule string).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone``, ``id`` (generated UUID), and ``rule`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone`` or ``rule``.
|
||||
NotFoundError: If the specified zone does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -622,6 +806,19 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
|
||||
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Remove rich rule by ID, emit sync event, refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``id`` (rule ID).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``id`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone`` or ``id``.
|
||||
NotFoundError: If the zone or rule does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -665,6 +862,21 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
|
||||
@registry.register(GET_FIREWALL_RICH_RULES)
|
||||
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Return rich rules list for a zone from state store, matched with config IDs.
|
||||
|
||||
Rules from the live firewall that match a config entry get their ID
|
||||
included; rules without a config entry are returned without an ID.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` key.
|
||||
|
||||
Returns:
|
||||
List of dicts with ``id`` (if available) and ``rule`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``zone`` key is missing from body.
|
||||
"""
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
@@ -685,6 +897,19 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
|
||||
|
||||
@registry.register(POST_FIREWALL_MASQUERADE)
|
||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Enable/disable masquerade on a zone.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``enable`` (boolean).
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``masquerade`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone`` or ``enable``, or if
|
||||
attempting to enable masquerade on the ``public`` zone.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -709,6 +934,19 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
|
||||
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
|
||||
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Add port forwarding rule with auto-generated ID.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone``, ``port``, ``proto``, and optionally
|
||||
``toaddr`` and ``toport``.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone``, ``id``, ``port``, and ``proto`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing required fields.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -757,6 +995,19 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
|
||||
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
|
||||
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Remove port forwarding rule.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone``, ``port``, and ``proto``.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone``, ``port``, and ``proto`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing required fields.
|
||||
NotFoundError: If the zone or forward port does not exist.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
@@ -809,6 +1060,17 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
|
||||
@registry.register(GET_FIREWALL_STATE)
|
||||
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""Return complete firewall state snapshot.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
|
||||
Returns:
|
||||
Dict with ``active_zones``, ``interfaces``, ``available_services``,
|
||||
``zones``, ``rich_rules``, and ``timestamp`` keys. Returns empty
|
||||
dict if state is not populated yet.
|
||||
"""
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
return {}
|
||||
|
||||
@@ -29,6 +29,15 @@ PathLike = str | Endpoint
|
||||
|
||||
|
||||
def _ep(method: str, path: str) -> Endpoint:
|
||||
"""Construct a frozen (method, path) endpoint tuple.
|
||||
|
||||
Args:
|
||||
method: HTTP method string (e.g. ``'GET'``).
|
||||
path: URL path pattern.
|
||||
|
||||
Returns:
|
||||
Endpoint tuple suitable for ``registry.register()`` and client calls.
|
||||
"""
|
||||
return (method, path)
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -102,6 +102,7 @@ class Registry:
|
||||
method = ep_method
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
"""Inner decorator that stores *fn* in the registry and attaches Handler metadata."""
|
||||
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
|
||||
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
|
||||
return fn
|
||||
@@ -236,7 +237,8 @@ async def _handle_request(request: web.Request) -> web.Response:
|
||||
|
||||
# Build body — merge order (highest wins): path params > JSON body > query params.
|
||||
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
|
||||
# be overridable by body or query parameters.
|
||||
# be overridable by body or query parameters. This prevents callers from
|
||||
# spoofing path-scoped parameters via request body.
|
||||
body: dict[str, Any] | None = pat_params if pat_params else None
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user