refactor: unify project structure, improve security, and enhance deployment

- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
This commit is contained in:
2026-05-25 00:53:32 +00:00
parent 8829ac579d
commit d1ab717c0f
36 changed files with 857 additions and 626 deletions
+48 -61
View File
@@ -1,13 +1,10 @@
"""
dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
"""Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
@@ -16,6 +13,8 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -46,64 +45,24 @@ DEFAULT_CFG: dict[str, Any] = {
},
}
# ───────── helpers ───────────────────────────────────────────────────
def _ensure_dirs() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR.mkdir(parents=True, exist_ok=True)
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["sudo", *list(cmd)],
capture_output=True,
text=True,
check=True,
)
def _load_json(path: Path) -> dict:
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
def _save_json(path: Path, data: dict) -> None:
_ensure_dirs()
with open(path, "w") as f:
json.dump(data, f, indent=4)
def _deep_merge(base: dict, overrides: dict) -> dict:
result = deepcopy(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = deepcopy(v)
return result
# ───────── config lifecycle ──────────────────────────────────────────
def get_config() -> dict:
def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file."""
_ensure_dirs()
raw = _load_json(CONFIG_PATH)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
return deep_merge(deepcopy(DEFAULT_CFG), raw)
def save_config(cfg: dict) -> None:
def save_config(cfg: dict[str, Any]) -> None:
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
_ensure_dirs()
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
save_json(CONFIG_PATH, merged)
logger.info("dnsmasq config saved")
@@ -112,8 +71,8 @@ def apply_config() -> None:
cfg = get_config()
conf_text = generate_conf(cfg)
_ensure_dirs()
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
@@ -121,14 +80,19 @@ def apply_config() -> None:
text=True,
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
subprocess.run(
["sudo", "systemctl", "reload", "dnsmasq"],
capture_output=True,
text=True,
check=True,
)
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict) -> str:
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
@@ -306,11 +270,16 @@ def _parse_lease_line(line: str) -> dict[str, Any] | None:
}
def get_lease_table() -> list[dict]:
def get_lease_table() -> list[dict[str, Any]]:
"""Read and parse the current dnsmasq lease file."""
leases: list[dict] = []
leases: list[dict[str, Any]] = []
try:
result = _sudo("cat", LEASE_FILE)
result = subprocess.run(
["sudo", "cat", LEASE_FILE],
capture_output=True,
text=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
@@ -341,7 +310,7 @@ def set_domain(domain: str | None) -> None:
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict:
def get_status() -> dict[str, Any]:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
@@ -355,7 +324,7 @@ def get_status() -> dict:
except Exception:
active = False
conf_exists = os.path.isfile(DNSMASQ_CONF)
conf_exists = Path(DNSMASQ_CONF).is_file()
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
@@ -381,3 +350,21 @@ def get_status() -> dict:
"active_leases": len(leases),
"leases": leases,
}
__all__ = [
"add_dns_record",
"add_static_lease",
"apply_config",
"generate_conf",
"get_config",
"get_lease_table",
"get_status",
"remove_dhcp_range",
"remove_dns_record",
"remove_static_lease",
"save_config",
"set_dhcp_range",
"set_domain",
"set_upstreams",
]
+94 -129
View File
@@ -8,23 +8,22 @@ A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the
Flask UI can inspect or restore previous configurations.
"""
import json
import logging
import os
import subprocess
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from lib.common import load_json, run, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: str = str(PROJECT_DIR / "data" / "firewall")
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json"
DATA_DIR: Path = PROJECT_DIR / "data" / "firewall"
RULES_FILE: Path = DATA_DIR / "rules.json"
CONFIG_DIR: Path = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE: Path = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
@@ -34,35 +33,16 @@ DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# ---------------------------------------------------------------------------
def _run(cmd: list[str], check: bool = True) -> str:
"""Run a command via subprocess and return its stdout.
Callers must include ``"sudo"`` as the first argument when the
command requires elevated privileges.
Raises:
RuntimeError: When ``check=True`` and the process exits non-zero.
"""
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
return result.stdout.strip()
def _reload() -> None:
"""Reload firewalld so permanent changes take effect immediately."""
try:
_run(["sudo", "firewall-cmd", "--reload"])
run(["firewall-cmd", "--reload"], sudo=True)
logger.info("firewalld reloaded")
except RuntimeError as exc:
logger.error("firewalld reload failed: %s", exc)
raise
def _ensure_data_dir() -> None:
"""Create the data directory tree if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def _gen_id() -> str:
"""Generate a short unique identifier (8 hex characters)."""
return uuid4().hex[:8]
@@ -75,13 +55,13 @@ def _gen_id() -> str:
def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names."""
output = _run(["sudo", "firewall-cmd", "--get-zones"])
output = run(["firewall-cmd", "--get-zones"], sudo=True)
return output.split()
def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces."""
output = _run(["sudo", "firewall-cmd", "--get-active-zones"])
output = run(["firewall-cmd", "--get-active-zones"], sudo=True)
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
@@ -105,7 +85,7 @@ def get_active_zones() -> dict[str, list[str]]:
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
output = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
@@ -157,19 +137,19 @@ def get_zone_info(zone: str) -> dict[str, Any]:
def get_services() -> list[str]:
"""Return the list of available service names known to firewalld."""
output = _run(["sudo", "firewall-cmd", "--get-services"])
output = run(["firewall-cmd", "--get-services"], sudo=True)
return output.split()
def get_icmp_blocks() -> list[str]:
"""Return the list of available ICMP block names."""
output = _run(["sudo", "firewall-cmd", "--get-icmptypes"])
output = run(["firewall-cmd", "--get-icmptypes"], sudo=True)
return output.split()
def get_interfaces() -> list[str]:
"""Return the list of network interfaces visible via iproute2."""
output = _run(["ip", "-o", "link", "show"])
output = run(["ip", "-o", "link", "show"])
ifaces: list[str] = []
for line in output.splitlines():
if line:
@@ -182,7 +162,7 @@ def get_interfaces() -> list[str]:
def get_rich_rules(zone: str) -> list[str]:
"""Return the rich rules defined for *zone* as a list of raw strings."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-rich-rules"])
output = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True)
output = output.strip()
if not output:
return []
@@ -208,14 +188,14 @@ def get_rich_rules(zone: str) -> list[str]:
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Firewall zone '%s' created (target=%s)", zone, target)
@@ -223,7 +203,7 @@ def create_zone(zone: str, target: str = "default") -> None:
def delete_zone(zone: str) -> None:
"""Delete an existing zone."""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Firewall zone '%s' deleted", zone)
@@ -240,26 +220,26 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
except Exception:
current = []
for iface in current:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in interfaces:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
@@ -267,14 +247,14 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Interface '%s' added to zone '%s'", iface, zone)
@@ -282,14 +262,14 @@ def add_zone_interface(zone: str, iface: str) -> None:
def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Interface '%s' removed from zone '%s'", iface, zone)
@@ -304,26 +284,26 @@ def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services."""
current = get_zone_info(zone).get("services", [])
for svc in current:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
sudo=True,
check=False,
)
for svc in services:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Zone '%s' services set to %s", zone, services)
@@ -331,14 +311,14 @@ def set_zone_services(zone: str, services: list[str]) -> None:
def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Service '%s' added to zone '%s'", service, zone)
@@ -346,14 +326,14 @@ def add_zone_service(zone: str, service: str) -> None:
def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Service '%s' removed from zone '%s'", service, zone)
@@ -366,14 +346,14 @@ def remove_zone_service(zone: str, service: str) -> None:
def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to *zone* and persist to declarative config."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
]
],
sudo=True,
)
_reload()
_persist_rich_rule(zone, rule)
@@ -384,14 +364,14 @@ def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
]
],
sudo=True,
)
_reload()
_unpersist_rich_rule(zone, rule)
@@ -400,7 +380,7 @@ def remove_rich_rule(zone: str, rule: str) -> None:
def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to the declarative config with a generated id."""
cfg = config_get()
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("rich_rules", [])
@@ -408,22 +388,22 @@ def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
rule_id = _gen_id()
entry = {"id": rule_id, "rule": rule}
existing_rules.append(entry)
config_set(cfg)
save_config(cfg)
return entry
def _unpersist_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from the declarative config by rule string."""
cfg = config_get()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
rules = zone_cfg.get("rich_rules", [])
zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule]
config_set(cfg)
save_config(cfg)
def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
"""Look up a rich rule entry in the declarative config."""
cfg = config_get()
cfg = get_config()
for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []):
if r.get("rule") == rule:
return r
@@ -432,7 +412,7 @@ def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
"""Remove a rich rule from *zone* by its config id."""
cfg = config_get()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for r in zone_cfg.get("rich_rules", []):
@@ -453,7 +433,7 @@ def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
def set_masquerade(zone: str, enable: bool) -> None:
"""Enable or disable masquerade (source-NAT) on *zone*."""
action = "--add-masquerade" if enable else "--remove-masquerade"
_run(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"])
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone)
@@ -479,14 +459,14 @@ def add_forward_port(
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
]
],
sudo=True,
)
_reload()
_persist_forward_port(zone, port, protocol, toaddr, toport)
@@ -511,14 +491,14 @@ def remove_forward_port(
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
]
],
sudo=True,
)
_reload()
_unpersist_forward_port(zone, port, protocol)
@@ -533,7 +513,7 @@ def _persist_forward_port(
toport: int | None = None,
) -> dict[str, Any]:
"""Add a forward port to the declarative config with a generated id."""
cfg = config_get()
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("forward_ports", [])
@@ -548,26 +528,24 @@ def _persist_forward_port(
if toport:
entry["toport"] = toport
cfg["zones"][zone]["forward_ports"].append(entry)
config_set(cfg)
save_config(cfg)
return entry
def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from the declarative config by port+proto."""
cfg = config_get()
cfg = get_config()
fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", [])
cfg.setdefault("zones", {}).setdefault(zone, {})
cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol)
]
config_set(cfg)
save_config(cfg)
def _get_forward_port_entry(
zone: str, port: int, protocol: str
) -> dict[str, Any]:
def _get_forward_port_entry(zone: str, port: int, protocol: str) -> dict[str, Any]:
"""Look up a forward port entry in the declarative config."""
cfg = config_get()
cfg = get_config()
for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []):
if fp.get("port") == port and fp.get("proto") == protocol:
return fp
@@ -577,7 +555,7 @@ def _get_forward_port_entry(
def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from *zone* by port+proto (id used by API layer)."""
cfg = config_get()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for fp in zone_cfg.get("forward_ports", []):
@@ -585,9 +563,7 @@ def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
entry = fp
break
if entry is None:
raise ValueError(
f"Forward port {port}/{protocol} not found in zone '{zone}'"
)
raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'")
remove_forward_port(
zone,
port,
@@ -658,19 +634,15 @@ def _now_iso() -> str:
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk."""
_ensure_data_dir()
state = get_state()
with open(RULES_FILE, "w") as fh:
json.dump(state, fh, indent=2, default=str)
save_json(RULES_FILE, state)
logger.info("Firewall state backup saved to %s", RULES_FILE)
return RULES_FILE
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict."""
with open(RULES_FILE) as fh:
state: dict[str, Any] = json.load(fh)
return state
return load_json(RULES_FILE)
def restore_backup(state: dict[str, Any]) -> None:
@@ -700,26 +672,26 @@ def restore_backup(state: dict[str, Any]) -> None:
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
fp_str = "/".join(parts)
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
for rule in zinfo.get("rich-rules", []):
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -734,28 +706,20 @@ def restore_backup(state: dict[str, Any]) -> None:
def _ensure_config_file() -> None:
"""Create config directory and file if they do not exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
with open(CONFIG_FILE, "w") as fh:
json.dump(DEFAULT_CONFIG, fh, indent=2)
fh.write("\n")
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def config_get() -> dict[str, Any]:
def get_config() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
with open(CONFIG_FILE) as fh:
return json.load(fh)
return load_json(CONFIG_FILE)
def config_set(cfg: dict[str, Any]) -> None:
def save_config(cfg: dict[str, Any]) -> None:
"""Write *cfg* to ``config/firewall/config.json`` (atomic replace)."""
_ensure_config_file()
tmp = CONFIG_FILE.with_name(CONFIG_FILE.name + ".tmp")
with open(tmp, "w") as fh:
json.dump(cfg, fh, indent=2)
fh.write("\n")
os.replace(tmp, CONFIG_FILE)
save_json(CONFIG_FILE, cfg, indent=2)
logger.info("Firewall declarative config saved")
@@ -783,7 +747,7 @@ def _live_target_to_config(target: str) -> str:
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff."""
cfg = config_get()
cfg = get_config()
live_state = get_state()
cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {})
@@ -844,9 +808,7 @@ def config_pending() -> dict[str, Any]:
}
)
cfg_rules = {
r.get("rule") for r in zone_cfg.get("rich_rules", [])
}
cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
live_rules = set(live_zone.get("rich-rules", []))
if cfg_rules != live_rules:
changes.append(
@@ -891,7 +853,7 @@ def config_pending() -> dict[str, Any]:
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld."""
cfg = config_get()
cfg = get_config()
cfg_zones = cfg.get("zones", {})
save_backup()
@@ -908,14 +870,14 @@ def config_apply() -> dict[str, Any]:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if desired_target != "default":
with suppress(RuntimeError):
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={desired_target}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -927,16 +889,20 @@ def config_apply() -> dict[str, Any]:
set_masquerade(zone_name, mq)
for rule_entry in zone_cfg.get("rich_rules", []):
rule_str = rule_entry.get("rule", "") if isinstance(rule_entry, dict) else str(rule_entry)
rule_str = (
rule_entry.get("rule", "")
if isinstance(rule_entry, dict)
else str(rule_entry)
)
if rule_str:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule_str}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -950,14 +916,14 @@ def config_apply() -> dict[str, Any]:
if "toport" in fp_entry:
parts.append(f"toport={fp_entry['toport']}")
fp_str = "/".join(parts)
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -981,19 +947,17 @@ __all__ = [
"DEFAULT_CONFIG",
"RULES_FILE",
"_reload",
"_run",
"add_forward_port",
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"config_apply",
"config_get",
"config_pending",
"config_set",
"create_zone",
"delete_zone",
"get_active_zones",
"get_available_zones",
"get_config",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
@@ -1009,6 +973,7 @@ __all__ = [
"remove_zone_service",
"restore_backup",
"save_backup",
"save_config",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",
+102 -75
View File
@@ -1,18 +1,20 @@
"""
Nginx server-block generator for Vacuum Wall SSL proxy firewall.
"""Nginx server-block generator for Vacuum Wall SSL proxy firewall.
Manages per-domain SSL reverse proxy configurations, certificate
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
@@ -31,7 +33,7 @@ ENV = Environment(
trim_blocks=True,
)
DEFAULT_SSL = {
DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
@@ -44,63 +46,35 @@ DEFAULT_SSL = {
"prefer_server_ciphers": False,
}
DEFAULT_CONFIG = {
DEFAULT_CONFIG: dict[str, Any] = {
"domains": {},
"management": None,
"ssl": {**DEFAULT_SSL},
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ensure_dirs():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
SITES_DIR.mkdir(parents=True, exist_ok=True)
def _run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kw)
def _json_load(path):
_ensure_dirs()
if not path.exists():
return DEFAULT_CONFIG.copy()
with open(path) as f:
data = json.load(f)
if "ssl" not in data:
data["ssl"] = DEFAULT_SSL.copy()
return data
def _json_dump(path, data):
_ensure_dirs()
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
f.write("\n")
os.replace(tmp, path)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_config() -> dict:
return _json_load(CONFIG_FILE)
def get_config() -> dict[str, Any]:
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
return raw
def save_config(cfg: dict) -> None:
_json_dump(CONFIG_FILE, cfg)
def save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg)
def get_domains() -> list[dict]:
def get_domains() -> list[dict[str, Any]]:
cfg = get_config()
result = []
result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
result.append(
@@ -120,17 +94,17 @@ def get_domains() -> list[dict]:
def add_domain(
domain,
backend_host,
backend_port,
backend_proto="http",
cert=None,
extra_headers=None,
domain: str,
backend_host: str,
backend_port: int,
backend_proto: str = "http",
cert: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> None:
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
entry = {
entry: dict[str, Any] = {
"backend": {
"host": backend_host,
"port": int(backend_port),
@@ -153,7 +127,7 @@ def add_domain(
)
def remove_domain(domain) -> None:
def remove_domain(domain: str) -> None:
cfg = get_config()
cfg["domains"].pop(domain, None)
save_config(cfg)
@@ -163,7 +137,7 @@ def remove_domain(domain) -> None:
logger.info("Proxy domain '%s' removed", domain)
def update_domain(domain, **kwargs) -> None:
def update_domain(domain: str, **kwargs: Any) -> None:
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
@@ -182,7 +156,7 @@ def update_domain(domain, **kwargs) -> None:
# ------------------------------------------------------------------
def generate_server_conf(domain_cfg: dict) -> str:
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=domain_cfg["domain"],
@@ -194,10 +168,11 @@ def generate_server_conf(domain_cfg: dict) -> str:
is_management=False,
acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
def _generate_management_conf(management: dict) -> str:
def _generate_management_conf(management: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=management.get("domain"),
@@ -211,6 +186,7 @@ def _generate_management_conf(management: dict) -> str:
is_management=True,
acme_home=str(PROJECT_DIR / "data" / "acme"),
certs_dir=str(PROJECT_DIR / "data" / "certs"),
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
@@ -219,8 +195,8 @@ def _generate_management_conf(management: dict) -> str:
# ------------------------------------------------------------------
def write_site(domain, conf_text) -> None:
_ensure_dirs()
def write_site(domain: str, conf_text: str) -> None:
ensure_dirs(SITES_DIR)
path = SITES_DIR / f"{domain}.conf"
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
@@ -230,13 +206,32 @@ def write_site(domain, conf_text) -> None:
os.replace(tmp, path)
def write_acme_challenge() -> None:
"""Write the ACME HTTP-01 challenge catch-all nginx config.
Serves ``/.well-known/acme-challenge/`` on port 80 from the ACME
webroot for any domain not yet covered by a dedicated server block.
"""
tmpl = ENV.get_template("nginx/acme-challenge.conf")
content = tmpl.render(
acme_webroot=str(PROJECT_DIR / "data" / "acme" / "www"),
)
site = SITES_DIR / "_acme-challenge.conf"
tmp = site.with_suffix(".tmp")
with open(tmp, "w") as f:
f.write(content)
f.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, site)
def write_all_sites() -> None:
_ensure_dirs()
ensure_dirs(SITES_DIR)
cfg = get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written = set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
conf = generate_server_conf(dom_copy)
@@ -252,6 +247,7 @@ def write_all_sites() -> None:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
write_acme_challenge()
logger.info("All nginx site configs written (%d sites)", len(written))
@@ -262,15 +258,15 @@ def write_include_file() -> None:
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True)
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True)
subprocess.run(["sudo", "cp", str(tmp), str(INCLUDE_FILE)], check=True)
subprocess.run(["sudo", "chown", "root:root", str(INCLUDE_FILE)], check=True)
tmp.unlink(missing_ok=True)
def write_ssl_snippet() -> None:
cfg = get_config()
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy())
ssl_cfg.setdefault("prefer_server_ciphers", False)
ssl_cfg = cfg.get("ssl", {})
ssl_cfg.setdefault("prefer_server_ciphers", DEFAULT_SSL["prefer_server_ciphers"])
ssl_cfg.setdefault("protocols", DEFAULT_SSL["protocols"])
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
@@ -280,8 +276,8 @@ def write_ssl_snippet() -> None:
with open(tmp, "w") as f:
f.write(content)
os.chmod(tmp, 0o644)
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True)
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True)
subprocess.run(["sudo", "cp", str(tmp), str(SSL_SNIPPET)], check=True)
subprocess.run(["sudo", "chown", "root:root", str(SSL_SNIPPET)], check=True)
tmp.unlink(missing_ok=True)
@@ -291,7 +287,9 @@ def write_ssl_snippet() -> None:
def test_config() -> tuple[bool, str]:
result = _run(["sudo", "nginx", "-t"])
result = subprocess.run(
["sudo", "nginx", "-t"], capture_output=True, text=True, check=False
)
ok = result.returncode == 0
output = (result.stderr or result.stdout or "").strip()
if not output and ok:
@@ -310,8 +308,13 @@ def apply() -> None:
ok, msg = test_config()
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"])
logger.info("nginx configuration applied and reloaded")
result = subprocess.run(
["sudo", "nginx", "-s", "reload"], capture_output=True, text=True, check=False
)
if result.returncode != 0:
logger.error("nginx reload failed: %s", result.stderr.strip())
else:
logger.info("nginx configuration applied and reloaded")
# ------------------------------------------------------------------
@@ -320,10 +323,14 @@ def apply() -> None:
def set_management_proxy(
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None
domain: str,
flask_host: str = "127.0.0.1",
flask_port: int = 9090,
auth_user: str | None = None,
auth_pass: str | None = None,
) -> None:
cfg = get_config()
entry = {
entry: dict[str, Any] = {
"domain": domain,
"backend": {
"host": flask_host,
@@ -348,11 +355,11 @@ def set_management_proxy(
# ------------------------------------------------------------------
def write_htpasswd(user, password) -> None:
def write_htpasswd(user: str, password: str) -> None:
"""Append (or create) an htpasswd entry for *user*."""
_ensure_dirs()
ensure_dirs(DATA_DIR)
hashed = _hash_password(password)
existing = {}
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
for line in f:
@@ -373,7 +380,7 @@ def write_htpasswd(user, password) -> None:
os.replace(tmp, HTPASSWD_FILE)
def _hash_password(password):
def _hash_password(password: str) -> str:
try:
from passlib.hash import apache_passwd
@@ -383,3 +390,23 @@ def _hash_password(password):
salt = os.urandom(16).hex()[:16]
return _crypt.crypt(password, f"$5${salt}")
__all__ = [
"add_domain",
"apply",
"generate_server_conf",
"get_config",
"get_domains",
"remove_domain",
"save_config",
"set_management_proxy",
"test_config",
"update_domain",
"write_acme_challenge",
"write_all_sites",
"write_htpasswd",
"write_include_file",
"write_site",
"write_ssl_snippet",
]
+1
View File
@@ -250,6 +250,7 @@ def add_peer(
save_config(cfg)
peer_out = dict(peer)
peer_out.pop("private_key", None)
return peer_out