feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)

- Add lib/state.py: in-memory state store with subsystem collectors
  (firewall, dnsmasq, nginx, acme, wireguard)
- Refactor all handlers: read from state on GET, call refresh_state()
  after mutations instead of invoking subprocesses per request
- daemon/server.py: add refresh_state(), /status/all, /status/refresh;
  populate state at startup
- webui/api/certs.py: async step-by-step ACME issuance (validate,
  issue with request_id, poll status) replacing blocking endpoint
- webui/server.py: render pages from state instead of direct lib calls
- Update templates, JS for async cert issuance with polling UI
- Update tests for state-based mocking; add test_state.py
- Fix SIM105 lint issue (contextlib.suppress)
- Add TODO.md with certificate issuance issue tracking

Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
2026-05-30 05:45:40 +00:00
parent c091063248
commit dc96e15643
19 changed files with 1960 additions and 986 deletions
+106 -217
View File
@@ -1,28 +1,21 @@
"""Firewall daemon handler.
Executes firewall-cmd and ip commands with sudo, returns structured results.
Parsing helpers are imported from lib.firewall.
Reads from the pre-computed state for status endpoints. Executes
firewall-cmd with sudo for mutations. Refers state after each mutation.
"""
import logging
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.server import NotFoundError, registry
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json
from lib.firewall import (
_normalize_target,
_parse_active_zones,
_parse_zone_output,
)
from lib.firewall import (
config_pending as _config_pending,
)
from lib.firewall import (
get_config as _get_lib_config,
)
from lib.firewall import (
save_backup as _save_backup,
)
@@ -30,16 +23,16 @@ from lib.firewall import (
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
DATA_DIR = PROJECT_DIR / "data" / "firewall"
RULES_FILE = DATA_DIR / "rules.json"
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_CONFIG = {"zones": {}}
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def _get_state() -> dict[str, Any] | None:
"""Return the current firewall state from the state store."""
from lib.state import state as state_store
return state_store.get("firewall")
def _ensure_config_file() -> None:
@@ -62,10 +55,6 @@ def _reload() -> None:
run(["firewall-cmd", "--reload"], sudo=True)
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _fp_to_str(fp: dict[str, Any]) -> str:
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
@@ -85,92 +74,22 @@ def _get_forward_ports(zone_name: str) -> list[str]:
return []
def _get_state() -> dict[str, Any]:
"""Return the complete current state of firewalld."""
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
link_out = run(["ip", "-o", "link", "show"], sudo=True)
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
iface_map: dict[str, dict[str, Any]] = {}
for line in link_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":")
state = "UNKNOWN"
mtu = None
mac = None
for i, p in enumerate(parts):
if p == "state" and i + 1 < len(parts):
state = parts[i + 1]
if p == "mtu" and i + 1 < len(parts):
mtu = int(parts[i + 1])
if p.startswith("link/ether") and i + 1 < len(parts):
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"display_name": raw_name.partition("@")[0],
"mac": mac,
"state": state,
"mtu": mtu,
"ips": [],
"ipv6": [],
"zone": None,
}
for line in addr_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
addr_name = parts[1]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["display_name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
clean = raw_if.partition("@")[0]
for entry in iface_map.values():
if entry["display_name"] == clean or entry["name"] == raw_if:
entry["zone"] = zone_name
break
ifaces = list(iface_map.values())
zones: dict[str, dict[str, Any]] = {}
for zn in zone_names:
try:
zones[zn] = _parse_zone_output(
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
)
except Exception:
continue
return {
"active_zones": active,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
"timestamp": _now_iso(),
}
def _config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld."""
from lib.firewall import get_config as _get_lib_config
cfg = _get_lib_config()
cfg_zones = cfg.get("zones", {})
_save_backup(_get_state())
full_state: dict[str, Any] = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
_save_backup(full_state)
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
applied: list[str] = []
@@ -314,7 +233,15 @@ def _config_apply() -> dict[str, Any]:
applied.append(zone_name)
_reload()
backup_path = _save_backup(_get_state())
full_state = {
"active_zones": {},
"interfaces": [],
"available_services": [],
"zones": {},
"rich_rules": {},
"timestamp": "",
}
backup_path = _save_backup(full_state)
logger.info("Firewall config applied to %d zones", len(applied))
return {
"applied_zones": applied,
@@ -323,114 +250,67 @@ def _config_apply() -> dict[str, Any]:
# ---------------------------------------------------------------------------
# Routes
# Routes — GET endpoints read from state, mutations call refresh_state
# ---------------------------------------------------------------------------
_READ_TAGS = {"firewall", "interfaces", "zones"}
def _get_fw_state() -> dict[str, Any]:
fw = _get_state()
if fw is None:
return {}
return fw
@registry.register("GET", "/firewall/interfaces", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/interfaces")
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
link_out = run(["ip", "-o", "link", "show"], sudo=True)
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
zones_out = run(["firewall-cmd", "--get-active-zones"], sudo=True)
iface_map: dict[str, dict[str, Any]] = {}
for line in link_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":")
state = "UNKNOWN"
mtu = None
mac = None
for i, p in enumerate(parts):
if p == "state" and i + 1 < len(parts):
state = parts[i + 1]
if p == "mtu" and i + 1 < len(parts):
mtu = int(parts[i + 1])
if p.startswith("link/ether") and i + 1 < len(parts):
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"display_name": raw_name.partition("@")[0],
"mac": mac,
"state": state,
"mtu": mtu,
"ips": [],
"ipv6": [],
"zone": None,
}
for line in addr_out.splitlines():
if not line:
continue
parts = line.split()
if len(parts) < 4:
continue
addr_name = parts[1]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["display_name"] == addr_name:
entry[addr_key].append(parts[3])
break
active = _parse_active_zones(zones_out)
for zone_name, ifaces in active.items():
for raw_if in ifaces:
clean = raw_if.partition("@")[0]
for entry in iface_map.values():
if entry["display_name"] == clean or entry["name"] == raw_if:
entry["zone"] = zone_name
break
return list(iface_map.values())
fw = _get_fw_state()
return fw.get("interfaces", [])
@registry.register("GET", "/firewall/zones", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/zones")
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True))
return {"active": active, "available": available}
fw = _get_fw_state()
active = fw.get("active_zones", {})
zones = fw.get("zones", {})
return {"active": active, "available": list(zones.keys())}
@registry.register("GET", "/firewall/zones/info", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/zones/info")
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
zone = body["zone"]
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
fw = _get_fw_state()
zones = fw.get("zones", {})
if zone not in zones:
raise NotFoundError(f"Zone '{zone}' does not exist")
raw = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
return _parse_zone_output(zone, raw)
return zones[zone]
@registry.register("GET", "/firewall/zones/all", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/zones/all")
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True))
fw = _get_fw_state()
active = fw.get("active_zones", {})
zones = fw.get("zones", {})
result: list[dict[str, Any]] = []
for zone_name in active:
try:
raw = run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True)
result.append(_parse_zone_output(zone_name, raw))
except Exception:
continue
if zone_name in zones:
result.append(zones[zone_name])
return result
@registry.register("GET", "/firewall/services", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/services")
def get_services(_request: Any, _body: Any) -> list[str]:
return run(["firewall-cmd", "--get-services"], sudo=True).split()
fw = _get_fw_state()
return fw.get("available_services", [])
@registry.register("GET", "/firewall/config", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/config")
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
@registry.register("POST", "/firewall/config", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/config")
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
@@ -438,10 +318,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
raise ValueError("'zones' must be a dict")
_save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
refresh_state(["firewall"])
return {"config_saved": True}
@registry.register("PATCH", "/firewall/config", invalidate=_READ_TAGS)
@registry.register("PATCH", "/firewall/config")
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body must be a JSON object")
@@ -451,22 +332,25 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
merged = deep_merge(current, body)
_save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys()))
refresh_state(["firewall"])
return {"config_saved": True}
@registry.register("GET", "/firewall/config/pending", cache_tags=_READ_TAGS)
def config_pending(_request: Any, _body: Any) -> dict[str, Any]:
return _config_pending(_get_state())
@registry.register("GET", "/firewall/config/pending")
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
fw = _get_fw_state()
return fw.get("pending", {})
@registry.register("POST", "/firewall/config/apply", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/config/apply")
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
refresh_state(["firewall"])
return result
@registry.register("POST", "/firewall/zones/create", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/zones/create")
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -488,10 +372,11 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
refresh_state(["firewall"])
return {"zone": zone_name}
@registry.register("DELETE", "/firewall/zones/delete", invalidate=_READ_TAGS)
@registry.register("DELETE", "/firewall/zones/delete")
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
@@ -502,10 +387,11 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Zone '%s' deleted", zone)
refresh_state(["firewall"])
return {"zone": zone}
@registry.register("POST", "/firewall/zones/interfaces", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/zones/interfaces")
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -544,10 +430,11 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
)
_reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
refresh_state(["firewall"])
return {"zone": zone, "interfaces": interfaces}
@registry.register("POST", "/firewall/zones/services", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/zones/services")
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -582,10 +469,11 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
sudo=True,
)
_reload()
refresh_state(["firewall"])
return {"zone": zone, "services": services}
@registry.register("POST", "/firewall/rich-rules/add", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/rich-rules/add")
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -595,7 +483,6 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise ValueError("'zone' and 'rule' are required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
raise NotFoundError(f"Zone '{zone}' does not exist")
from uuid import uuid4
run(
[
@@ -613,10 +500,11 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry)
_save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "id": rule_id, "rule": rule}
@registry.register("DELETE", "/firewall/rich-rules/remove", invalidate=_READ_TAGS)
@registry.register("DELETE", "/firewall/rich-rules/remove")
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -650,34 +538,22 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
]
_save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "id": rule_id}
@registry.register("GET", "/firewall/rich-rules", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/rich-rules")
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
if not body or "zone" not in body:
raise ValueError("'zone' is required")
zone = body["zone"]
raw = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True)
raw = raw.strip()
if not raw:
return []
rules: list[str] = []
current: list[str] = []
for line in raw.splitlines():
r = line.rstrip()
if not r.endswith(";"):
current.append(r)
else:
current.append(r)
rules.append(" ".join(current))
current = []
if current:
rules.append(" ".join(current))
fw = _get_fw_state()
rich_rules = fw.get("rich_rules", {})
cfg = _get_config()
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
result: list[dict[str, Any]] = []
for rule_str in rules:
zone_rules = rich_rules.get(zone, [])
for rule_str in zone_rules:
matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
if matched:
result.append({"id": matched["id"], "rule": rule_str})
@@ -686,7 +562,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
return result
@registry.register("POST", "/firewall/masquerade", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/masquerade")
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -697,10 +573,11 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
refresh_state(["firewall"])
return {"zone": zone, "masquerade": bool(enable)}
@registry.register("POST", "/firewall/forward-port/add", invalidate=_READ_TAGS)
@registry.register("POST", "/firewall/forward-port/add")
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -711,7 +588,6 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
toport = body.get("toport")
if not zone or port is None or not proto:
raise ValueError("'zone', 'port', and 'proto' are required")
from uuid import uuid4
fwd = f"port={port}/proto={proto}"
if toaddr and toport:
@@ -740,10 +616,11 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry)
_save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@registry.register("DELETE", "/firewall/forward-port/remove", invalidate=_READ_TAGS)
@registry.register("DELETE", "/firewall/forward-port/remove")
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not body:
raise ValueError("Request body required")
@@ -752,7 +629,8 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
proto = body.get("proto", "").strip()
if not zone or port is None or not proto:
raise ValueError("'zone', 'port', and 'proto' are required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
if zone not in available:
raise NotFoundError(f"Zone '{zone}' does not exist")
fwd = f"port={port}/proto={proto}"
cfg = _get_config()
@@ -785,9 +663,20 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
]
_save_config(cfg)
refresh_state(["firewall"])
return {"zone": zone, "port": int(port), "proto": proto}
@registry.register("GET", "/firewall/state", cache_tags=_READ_TAGS)
@registry.register("GET", "/firewall/state")
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
return _get_state()
fw = _get_state()
if fw is None:
return {}
return {
"active_zones": fw.get("active_zones", {}),
"interfaces": fw.get("interfaces", []),
"available_services": fw.get("available_services", []),
"zones": fw.get("zones", {}),
"rich_rules": fw.get("rich_rules", {}),
"timestamp": fw.get("timestamp", ""),
}