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:
+48
-61
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -250,6 +250,7 @@ def add_peer(
|
||||
|
||||
save_config(cfg)
|
||||
peer_out = dict(peer)
|
||||
peer_out.pop("private_key", None)
|
||||
return peer_out
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user