ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
This commit is contained in:
@@ -33,7 +33,7 @@ from daemon.iface import (
|
||||
POST_FIREWALL_ZONES_INTERFACES,
|
||||
POST_FIREWALL_ZONES_SERVICES,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib.common import load_json, run, save_json
|
||||
from lib.firewall import (
|
||||
_normalize_target,
|
||||
@@ -85,6 +85,35 @@ def _reload() -> None:
|
||||
run(["firewall-cmd", "--reload"], sudo=True)
|
||||
|
||||
|
||||
def _default_zone() -> str:
|
||||
"""Return the firewalld default zone name.
|
||||
|
||||
The default zone is the catch-all for any interface without an explicit
|
||||
zone assignment (including VPN interfaces), so it normally fronts the WAN.
|
||||
"""
|
||||
return run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
|
||||
|
||||
|
||||
def _would_remove_mgmt(zone: str, services: list[str]) -> bool:
|
||||
"""Return True if *services* lacks both https and ssh on the default zone.
|
||||
|
||||
The default zone fronts unassigned (WAN/VPN) interfaces, so removing both
|
||||
management access (https via nginx) and remote recovery (ssh) from it
|
||||
would leave no path back except a physical console.
|
||||
"""
|
||||
if "https" in services or "ssh" in services:
|
||||
return False
|
||||
try:
|
||||
default = _default_zone()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Could not determine the firewalld default zone; failing closed for %s",
|
||||
zone,
|
||||
)
|
||||
return True
|
||||
return zone == default
|
||||
|
||||
|
||||
def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
|
||||
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||
@@ -106,18 +135,39 @@ def _get_forward_ports(zone_name: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _config_apply() -> dict[str, Any]:
|
||||
def _config_apply(force: bool = False) -> dict[str, Any]:
|
||||
"""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.
|
||||
|
||||
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.
|
||||
"""
|
||||
from lib.firewall import get_config as _get_lib_config
|
||||
|
||||
cfg = _get_lib_config()
|
||||
cfg_zones = cfg.get("zones", {})
|
||||
|
||||
if not force:
|
||||
default_zone = _default_zone()
|
||||
lockout_zones = [
|
||||
zn
|
||||
for zn, zc in cfg_zones.items()
|
||||
if zn == default_zone
|
||||
and "https" not in zc.get("services", [])
|
||||
and "ssh" not in zc.get("services", [])
|
||||
]
|
||||
if lockout_zones:
|
||||
raise ConflictError(
|
||||
f"Refusing to remove both https and ssh from default zone(s) "
|
||||
f"{', '.join(repr(z) for z in lockout_zones)}: management access "
|
||||
f"and remote recovery would be lost. Add at least one of them "
|
||||
f'to the zone\'s services, or pass {{"force": true}}.'
|
||||
)
|
||||
|
||||
full_state: dict[str, Any] = {
|
||||
"active_zones": {},
|
||||
"interfaces": [],
|
||||
@@ -556,13 +606,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
_body: The request body (unused).
|
||||
_body: Optional JSON body; ``{"force": true}`` overrides the
|
||||
management-lockout guard for the default zone.
|
||||
|
||||
Returns:
|
||||
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
|
||||
and ``synced`` (affected subsystems).
|
||||
"""
|
||||
result = _config_apply()
|
||||
force = bool(_body and _body.get("force"))
|
||||
result = _config_apply(force=force)
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
|
||||
@@ -736,18 +788,21 @@ 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.
|
||||
"""Replace zone services, persist to declarative config, and refresh state.
|
||||
|
||||
Args:
|
||||
_request: The incoming HTTP request (unused).
|
||||
body: JSON body with ``zone`` and ``services`` list.
|
||||
body: JSON body with ``zone`` and ``services`` list; optional
|
||||
``force`` (bool) overrides the management-lockout guard.
|
||||
|
||||
Returns:
|
||||
Dict with ``zone`` and ``services`` keys.
|
||||
|
||||
Raises:
|
||||
ValueError: If body is missing ``zone``.
|
||||
NotFoundError: If the zone does not exist.
|
||||
NotFoundError: If the specified zone does not exist.
|
||||
ConflictError: If the change would strip both https and ssh from the
|
||||
firewalld default zone and ``force`` is not set.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -757,6 +812,12 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
raise ValueError("'zone' is required")
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
if not body.get("force") and _would_remove_mgmt(zone, list(services)):
|
||||
raise ConflictError(
|
||||
f"Refusing to remove both https and ssh from default zone '{zone}': "
|
||||
f"management access and remote recovery would be lost. Add at least "
|
||||
f'one of them back, or send "force": true to override.'
|
||||
)
|
||||
current = _parse_zone_output(
|
||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
).get("services", [])
|
||||
@@ -782,6 +843,13 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
|
||||
# Keep the declarative config in sync so the next apply does not
|
||||
# reconcile the live services back to the stale config value.
|
||||
cfg = _get_config()
|
||||
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
|
||||
_save_config(cfg)
|
||||
logger.info("Zone '%s' services set to %s", zone, services)
|
||||
sync_result = bus.emit(
|
||||
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user