refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry - Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard) - Add system/acme-deploy.py, vacuum-walld sudoers and systemd service - Update API routes to use daemon client instead of lib/ directly - Update lib/, tests/, and webui/ for new architecture - Update docs and deployment scripts
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from daemon.server import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Sync daemon client for the web UI.
|
||||
|
||||
Communicates with vacuum-walld over a Unix socket using requests-unixsocket.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import requests_unixsocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotFound(Exception):
|
||||
"""Raised when the daemon returns HTTP 404."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BadRequest(Exception):
|
||||
"""Raised when the daemon returns HTTP 400."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_DEFAULT_SOCKET = None
|
||||
|
||||
|
||||
def _get_socket_path() -> str:
|
||||
global _DEFAULT_SOCKET
|
||||
if _DEFAULT_SOCKET is None:
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
project_dir = Path(__file__).resolve().parent.parent
|
||||
socket_env = os.environ.get(
|
||||
"VACUUM_WALLD_SOCKET", str(project_dir / "data" / "daemon.sock")
|
||||
)
|
||||
_DEFAULT_SOCKET = socket_env
|
||||
return _DEFAULT_SOCKET
|
||||
|
||||
|
||||
def set_socket_path(path: str) -> None:
|
||||
global _DEFAULT_SOCKET
|
||||
_DEFAULT_SOCKET = path
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
query_params: dict[str, Any] | None = None,
|
||||
socket_path: str | None = None,
|
||||
timeout: float = 30,
|
||||
) -> dict[str, Any]:
|
||||
"""Make a request to the daemon and return the parsed response body.
|
||||
|
||||
For GET requests, query_params are sent as URL query parameters instead
|
||||
of a JSON body. For other methods, json_body is sent as JSON.
|
||||
|
||||
Raises RuntimeError on non-2xx responses or connection errors.
|
||||
Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400.
|
||||
"""
|
||||
sp = socket_path or _get_socket_path()
|
||||
url = f"http://localhost{path}"
|
||||
sess = requests_unixsocket.Session()
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"timeout": timeout,
|
||||
"unix_socket": sp,
|
||||
}
|
||||
if method == "GET":
|
||||
if query_params:
|
||||
kwargs["params"] = query_params
|
||||
else:
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
resp = sess.request(
|
||||
method,
|
||||
url,
|
||||
**kwargs,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
data = resp.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"Daemon returned non-JSON response ({resp.status_code}): {exc}"
|
||||
) from exc
|
||||
except requests.ConnectionError as exc:
|
||||
raise RuntimeError(f"Cannot connect to daemon at {sp}: {exc}") from exc
|
||||
except requests.Timeout as exc:
|
||||
raise RuntimeError(f"Daemon request timed out: {method} {path}") from exc
|
||||
except requests.HTTPError as exc:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"ok": False, "error": resp.text}
|
||||
if not data.get("ok"):
|
||||
if resp.status_code == 404:
|
||||
raise NotFound(data.get("error", str(exc))) from exc
|
||||
if resp.status_code == 400:
|
||||
raise BadRequest(data.get("error", str(exc))) from exc
|
||||
raise RuntimeError(data.get("error", str(exc))) from exc
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(data.get("error", "Unknown error"))
|
||||
return data.get("data")
|
||||
|
||||
|
||||
def get(path: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""GET request to daemon. Params are sent as URL query parameters."""
|
||||
return request("GET", path, query_params=params, **kwargs)
|
||||
|
||||
|
||||
def post(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""POST request to daemon."""
|
||||
return request("POST", path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def patch(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""PATCH request to daemon."""
|
||||
return request("PATCH", path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def delete(path: str, body: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
||||
"""DELETE request to daemon."""
|
||||
return request("DELETE", path, json_body=body, **kwargs)
|
||||
|
||||
|
||||
def batch(ops: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]:
|
||||
"""Batch request to daemon.
|
||||
|
||||
Each op is a dict with 'id', 'method', 'path', and optionally 'body'.
|
||||
Returns a dict mapping each id to its result.
|
||||
"""
|
||||
return post("/batch", {"ops": ops}, **kwargs)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""ACME certificate daemon handler."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
||||
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
|
||||
|
||||
_ACME_ENVIRON = {
|
||||
"HOME": str(PROJECT_DIR),
|
||||
"PATH": os.environ.get(
|
||||
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
),
|
||||
}
|
||||
|
||||
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
||||
|
||||
_ACME_TAGS = {"acme"}
|
||||
|
||||
|
||||
def _find_acme() -> str:
|
||||
candidates = [_ACME_HOME / "acme.sh", Path("/usr/local/bin/acme.sh")]
|
||||
for path in candidates:
|
||||
if path.is_file() and os.access(path, os.X_OK):
|
||||
return str(path)
|
||||
acme = shutil.which("acme.sh")
|
||||
if acme:
|
||||
return acme
|
||||
raise FileNotFoundError("acme.sh not found")
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
acme_bin = _find_acme()
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env={**os.environ, **_ACME_ENVIRON},
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output = output + result.stderr if output else result.stderr
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}")
|
||||
return output
|
||||
|
||||
|
||||
def _days_until(date_str: str) -> int | None:
|
||||
if not date_str:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
|
||||
return (dt - datetime.now(UTC)).days
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC)
|
||||
return (dt - datetime.now(UTC)).days
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_list_output(raw: str) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for line in raw.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
entry: dict[str, str] = {}
|
||||
for token in line.split():
|
||||
if ":" not in token:
|
||||
continue
|
||||
key, _, value = token.partition(":")
|
||||
entry[key.lower()] = value
|
||||
if entry:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _has_auto_renew(domain: str) -> bool:
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
return bool(Path(acme_home_env) / f"{domain}.conf")
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/list", cache_tags=_ACME_TAGS)
|
||||
def list_certs(_request: Any, _body: Any) -> list[dict]:
|
||||
raw = _run_acme(["--list"])
|
||||
certs: list[dict] = []
|
||||
entries = _parse_list_output(raw)
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
acme_home = Path(acme_home_env)
|
||||
for entry in entries:
|
||||
main = entry["main_domain"]
|
||||
if not main:
|
||||
continue
|
||||
san_domains = [
|
||||
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
||||
]
|
||||
cert_dir = acme_home / main
|
||||
days = _days_until(entry.get("certificate_expires", ""))
|
||||
certs.append(
|
||||
{
|
||||
"domain": main,
|
||||
"issuer": entry.get("CA", ""),
|
||||
"expiry": entry.get("certificate_expires", ""),
|
||||
"days_remaining": days,
|
||||
"expired": days is not None and days <= 0,
|
||||
"cert_path": str(cert_dir / "fullchain.cer"),
|
||||
"key_path": str(cert_dir / f"{main}.key"),
|
||||
"ca_path": str(cert_dir / "ca.cer"),
|
||||
"issued_at": entry.get("certificate_date", ""),
|
||||
"expires_at": entry.get("certificate_expires", ""),
|
||||
"days_until_expiry": days,
|
||||
"auto_renew": _has_auto_renew(main),
|
||||
"san_domains": san_domains,
|
||||
}
|
||||
)
|
||||
return certs
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/info", cache_tags=_ACME_TAGS)
|
||||
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||
if not body or "domain" not in body:
|
||||
raise ValueError("'domain' is required")
|
||||
domain = body["domain"]
|
||||
certs = list_certs(None, None)
|
||||
for c in certs:
|
||||
if c["domain"] == domain or domain in c["san_domains"]:
|
||||
return c
|
||||
raise NotFoundError(f"No certificate found for domain: {domain}")
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/issue", invalidate=_ACME_TAGS | {"nginx"})
|
||||
def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
webroot = body.get("webroot")
|
||||
email = body.get("email", "").strip() or None
|
||||
args: list[str] = ["--issue", "-d", domain]
|
||||
args.extend(["--webroot", webroot or str(_WEBROOT)])
|
||||
contact = email
|
||||
if not contact:
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
contact = match.group(1).strip().strip("'\"")
|
||||
except OSError:
|
||||
pass
|
||||
if contact:
|
||||
args.extend(["-m", contact])
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
logger.info("Certificate for %s issued", domain)
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/renew", invalidate=_ACME_TAGS | {"nginx"})
|
||||
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
force = body.get("force", False)
|
||||
args: list[str] = ["--renew", "-d", domain]
|
||||
if force:
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
logger.info("Certificate for %s renewed", domain)
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/acme/remove", invalidate=_ACME_TAGS)
|
||||
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
_run_acme(["--remove", "-d", domain])
|
||||
logger.info("Certificate for %s removed", domain)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/email", invalidate=_ACME_TAGS)
|
||||
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
raise ValueError("'email' is required")
|
||||
_run_acme(["--register-account", "-m", email])
|
||||
logger.info("ACME email set to %s", email)
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/email", cache_tags=_ACME_TAGS)
|
||||
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return {"email": match.group(1).strip().strip("'\"")}
|
||||
except OSError:
|
||||
pass
|
||||
return {"email": ""}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/paths", cache_tags=_ACME_TAGS)
|
||||
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
|
||||
if not body or "domain" not in body:
|
||||
raise ValueError("'domain' is required")
|
||||
domain = body["domain"]
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||
acme_home = str(Path(acme_home_env) / domain)
|
||||
return {
|
||||
"cert": f"{acme_home}/{domain}.cert",
|
||||
"key": f"{acme_home}/{domain}.key",
|
||||
"ca": f"{acme_home}/ca.cer",
|
||||
"fullchain": f"{acme_home}/fullchain.cer",
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Dnsmasq daemon handler."""
|
||||
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_CFG: dict[str, Any] = {
|
||||
"dhcp": {"ranges": [], "static_leases": []},
|
||||
"dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []},
|
||||
}
|
||||
|
||||
_DNSMASQ_TAGS = {"dnsmasq"}
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
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)
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
merged = deep_merge(deepcopy(DEFAULT_CFG), cfg)
|
||||
save_json(CONFIG_PATH, merged)
|
||||
|
||||
|
||||
def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
dhcp_cfg = cfg.get("dhcp", {})
|
||||
dns_cfg = cfg.get("dns", {})
|
||||
interfaces = [
|
||||
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
|
||||
]
|
||||
tmpl = ENV.get_template("dnsmasq.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interfaces=interfaces,
|
||||
dhcp=dhcp_cfg,
|
||||
dns=dns_cfg,
|
||||
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
|
||||
)
|
||||
|
||||
|
||||
def _parse_lease_line(line: str) -> dict[str, Any] | None:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
ts = None
|
||||
return {
|
||||
"expires_at": ts,
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if len(parts) > 3 else "",
|
||||
"interface": parts[4] if len(parts) > 4 else "",
|
||||
}
|
||||
|
||||
|
||||
def _get_lease_table() -> list[dict[str, Any]]:
|
||||
leases: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = run_proc(
|
||||
["cat", LEASE_FILE],
|
||||
sudo=True,
|
||||
check=True,
|
||||
)
|
||||
for entry in map(_parse_lease_line, result.stdout.splitlines()):
|
||||
if entry is not None:
|
||||
leases.append(entry)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return leases
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/config", cache_tags=_DNSMASQ_TAGS)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/apply", invalidate=_DNSMASQ_TAGS)
|
||||
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
conf_text = _generate_conf(cfg)
|
||||
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
|
||||
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
|
||||
run_proc(
|
||||
["tee", DNSMASQ_CONF, "--"],
|
||||
sudo=True,
|
||||
check=True,
|
||||
input=conf_text,
|
||||
)
|
||||
run(["systemctl", "reload", "dnsmasq"], sudo=True)
|
||||
logger.info("dnsmasq config written and reloaded")
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/status", cache_tags=_DNSMASQ_TAGS)
|
||||
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
try:
|
||||
proc = run_proc(
|
||||
["systemctl", "is-active", "dnsmasq"], sudo=True
|
||||
)
|
||||
active = proc.stdout.strip() == "active"
|
||||
except Exception:
|
||||
active = False
|
||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||
conf_on_disk = ""
|
||||
if conf_exists:
|
||||
try:
|
||||
with open(DNSMASQ_CONF) as f:
|
||||
conf_on_disk = f.read()
|
||||
except PermissionError:
|
||||
pass
|
||||
expected = _generate_conf(cfg)
|
||||
leases = _get_lease_table()
|
||||
return {
|
||||
"service_active": active,
|
||||
"config_file_exists": conf_exists,
|
||||
"config_in_sync": conf_on_disk == expected,
|
||||
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
|
||||
"static_leases": len(cfg["dhcp"]["static_leases"]),
|
||||
"custom_dns_records": len(cfg["dns"]["custom_records"]),
|
||||
"upstreams": cfg["dns"]["upstreams"],
|
||||
"domain": cfg["dns"].get("domain"),
|
||||
"active_leases": len(leases),
|
||||
"leases": leases,
|
||||
}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/ranges/add", invalidate=_DNSMASQ_TAGS)
|
||||
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
iface = body.get("interface", "").strip() or ""
|
||||
start = body.get("start", "").strip()
|
||||
end = body.get("end", "").strip()
|
||||
lease_time = body.get("lease_time", "12h")
|
||||
if not start or not end:
|
||||
raise ValueError("'start' and 'end' are required")
|
||||
cfg = _get_config()
|
||||
ranges = cfg["dhcp"]["ranges"]
|
||||
found = False
|
||||
for i, r in enumerate(ranges):
|
||||
if r.get("interface") == iface:
|
||||
ranges[i] = {
|
||||
"interface": iface,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if body.get("gateway"):
|
||||
ranges[i]["gateway"] = body["gateway"]
|
||||
if body.get("dns"):
|
||||
ranges[i]["dns"] = body["dns"]
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
entry: dict[str, Any] = {
|
||||
"interface": iface,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"lease_time": lease_time,
|
||||
}
|
||||
if body.get("gateway"):
|
||||
entry["gateway"] = body["gateway"]
|
||||
if body.get("dns"):
|
||||
entry["dns"] = body["dns"]
|
||||
ranges.append(entry)
|
||||
_save_config(cfg)
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/ranges/remove", invalidate=_DNSMASQ_TAGS)
|
||||
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
iface = body.get("interface", "").strip() or ""
|
||||
start = body.get("start", "").strip()
|
||||
end = body.get("end", "").strip()
|
||||
if not start or not end:
|
||||
raise ValueError("'start' and 'end' are required")
|
||||
cfg = _get_config()
|
||||
ranges = cfg["dhcp"]["ranges"]
|
||||
before = len(ranges)
|
||||
cfg["dhcp"]["ranges"] = [
|
||||
r
|
||||
for r in ranges
|
||||
if not (
|
||||
r.get("interface") == iface
|
||||
and r.get("start") == start
|
||||
and r.get("end") == end
|
||||
)
|
||||
]
|
||||
if len(cfg["dhcp"]["ranges"]) == before:
|
||||
raise NotFoundError(
|
||||
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
|
||||
)
|
||||
_save_config(cfg)
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/leases", cache_tags=_DNSMASQ_TAGS)
|
||||
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return _get_lease_table()
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/static-lease/add", invalidate=_DNSMASQ_TAGS)
|
||||
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
mac = body.get("mac", "").strip()
|
||||
ip = body.get("ip", "").strip()
|
||||
hostname = body.get("hostname")
|
||||
if not mac or not ip:
|
||||
raise ValueError("'mac' and 'ip' are required")
|
||||
cfg = _get_config()
|
||||
leases = cfg["dhcp"]["static_leases"]
|
||||
for i, lease in enumerate(leases):
|
||||
if lease["mac"].lower() == mac.lower():
|
||||
leases[i].update({"mac": mac, "ip": ip})
|
||||
if hostname is not None:
|
||||
leases[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
_save_config(cfg)
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/static-lease/remove", invalidate=_DNSMASQ_TAGS)
|
||||
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
mac = body.get("mac", "").strip()
|
||||
if not mac:
|
||||
raise ValueError("'mac' is required")
|
||||
cfg = _get_config()
|
||||
leases = cfg["dhcp"]["static_leases"]
|
||||
before = len(leases)
|
||||
cfg["dhcp"]["static_leases"] = [
|
||||
lease for lease in leases if lease["mac"].lower() != mac.lower()
|
||||
]
|
||||
if len(cfg["dhcp"]["static_leases"]) == before:
|
||||
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
|
||||
_save_config(cfg)
|
||||
return {"mac": mac}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/dns-record/add", invalidate=_DNSMASQ_TAGS)
|
||||
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
address = body.get("address", "").strip()
|
||||
hostname = body.get("hostname")
|
||||
if not name or not address:
|
||||
raise ValueError("'name' and 'address' are required")
|
||||
cfg = _get_config()
|
||||
records = cfg["dns"]["custom_records"]
|
||||
for i, r in enumerate(records):
|
||||
if r["name"] == name:
|
||||
records[i].update({"name": name, "address": address})
|
||||
if hostname is not None:
|
||||
records[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
_save_config(cfg)
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/dns-record/remove", invalidate=_DNSMASQ_TAGS)
|
||||
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
records = cfg["dns"]["custom_records"]
|
||||
before = len(records)
|
||||
cfg["dns"]["custom_records"] = [
|
||||
r for r in records if r["name"] != name
|
||||
]
|
||||
if len(cfg["dns"]["custom_records"]) == before:
|
||||
raise NotFoundError(f"DNS record '{name}' not found")
|
||||
_save_config(cfg)
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/upstreams", invalidate=_DNSMASQ_TAGS)
|
||||
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "servers" not in body:
|
||||
raise ValueError("'servers' is required")
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["upstreams"] = list(body["servers"])
|
||||
_save_config(cfg)
|
||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/domain", invalidate=_DNSMASQ_TAGS)
|
||||
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain")
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["domain"] = domain if domain else None
|
||||
_save_config(cfg)
|
||||
return {"domain": cfg["dns"]["domain"]}
|
||||
@@ -0,0 +1,793 @@
|
||||
"""Firewall daemon handler.
|
||||
|
||||
Executes firewall-cmd and ip commands with sudo, returns structured results.
|
||||
Parsing helpers are imported from lib.firewall.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from daemon.server import NotFoundError, 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,
|
||||
)
|
||||
|
||||
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 _ensure_config_file() -> None:
|
||||
if not CONFIG_FILE.exists():
|
||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
_ensure_config_file()
|
||||
return load_json(CONFIG_FILE)
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
_ensure_config_file()
|
||||
save_json(CONFIG_FILE, cfg, indent=2)
|
||||
|
||||
|
||||
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:
|
||||
parts.append(f"toaddr={fp['toaddr']}")
|
||||
if "toport" in fp:
|
||||
parts.append(f"toport={fp['toport']}")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _get_forward_ports(zone_name: str) -> list[str]:
|
||||
with suppress(Exception):
|
||||
fps = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
).get("forward-ports", [])
|
||||
return [_fp_to_str(fp) for fp in fps if isinstance(fp, dict)]
|
||||
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."""
|
||||
cfg = _get_lib_config()
|
||||
cfg_zones = cfg.get("zones", {})
|
||||
|
||||
_save_backup(_get_state())
|
||||
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
applied: list[str] = []
|
||||
for zone_name, zone_cfg in cfg_zones.items():
|
||||
need_create = zone_name not in available
|
||||
|
||||
if need_create:
|
||||
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={target}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
else:
|
||||
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
|
||||
if desired_target != "default":
|
||||
with suppress(RuntimeError):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={desired_target}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
current_svcs: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_svcs = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
).get("services", [])
|
||||
for svc in current_svcs:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--remove-service={svc}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for svc in zone_cfg.get("services", []):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--add-service={svc}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
current_ifaces: list[str] = []
|
||||
with suppress(Exception):
|
||||
current_ifaces = _parse_zone_output(
|
||||
zone_name,
|
||||
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
|
||||
).get("interfaces", [])
|
||||
for iface in current_ifaces:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in zone_cfg.get("interfaces", []):
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
mq = zone_cfg.get("masquerade", False)
|
||||
if mq is not None:
|
||||
action = "--add-masquerade" if mq else "--remove-masquerade"
|
||||
run(
|
||||
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
for rule_entry in zone_cfg.get("rich_rules", []):
|
||||
rule_str = (
|
||||
rule_entry.get("rule", "")
|
||||
if isinstance(rule_entry, dict)
|
||||
else str(rule_entry)
|
||||
)
|
||||
if rule_str:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--add-rich-rule={rule_str}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
current_fps = _get_forward_ports(zone_name)
|
||||
for fp_str in current_fps:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--remove-forward-port={fp_str}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for fp_entry in zone_cfg.get("forward_ports", []):
|
||||
fp_str = fp_entry if isinstance(fp_entry, str) else _fp_to_str(fp_entry)
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--add-forward-port={fp_str}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
applied.append(zone_name)
|
||||
|
||||
_reload()
|
||||
backup_path = _save_backup(_get_state())
|
||||
logger.info("Firewall config applied to %d zones", len(applied))
|
||||
return {
|
||||
"applied_zones": applied,
|
||||
"backup": backup_path,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_READ_TAGS = {"firewall", "interfaces", "zones"}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/interfaces", cache_tags=_READ_TAGS)
|
||||
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())
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones", cache_tags=_READ_TAGS)
|
||||
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}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/info", cache_tags=_READ_TAGS)
|
||||
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():
|
||||
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)
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/all", cache_tags=_READ_TAGS)
|
||||
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))
|
||||
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
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/services", cache_tags=_READ_TAGS)
|
||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||
return run(["firewall-cmd", "--get-services"], sudo=True).split()
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config", cache_tags=_READ_TAGS)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config", invalidate=_READ_TAGS)
|
||||
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")
|
||||
if not isinstance(body["zones"], dict):
|
||||
raise ValueError("'zones' must be a dict")
|
||||
_save_config(body)
|
||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/firewall/config", invalidate=_READ_TAGS)
|
||||
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")
|
||||
from lib.common import deep_merge
|
||||
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
||||
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("POST", "/firewall/config/apply", invalidate=_READ_TAGS)
|
||||
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
result = _config_apply()
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/create", invalidate=_READ_TAGS)
|
||||
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone_name = body.get("name", "").strip()
|
||||
target = body.get("target", "default").strip() or "default"
|
||||
if not zone_name:
|
||||
raise ValueError("Zone name is required")
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
if zone_name in available:
|
||||
raise ValueError(f"Zone '{zone_name}' already exists")
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone_name}",
|
||||
f"--set-target={target}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
||||
return {"zone": zone_name}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/zones/delete", invalidate=_READ_TAGS)
|
||||
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")
|
||||
zone = body["zone"]
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
if zone not in available:
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
||||
_reload()
|
||||
logger.info("Zone '%s' deleted", zone)
|
||||
return {"zone": zone}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/interfaces", invalidate=_READ_TAGS)
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
interfaces = body.get("interfaces", [])
|
||||
if not zone:
|
||||
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")
|
||||
try:
|
||||
current = _parse_zone_output(
|
||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
).get("interfaces", [])
|
||||
except Exception:
|
||||
current = []
|
||||
for iface in current:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for iface in interfaces:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--add-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/services", invalidate=_READ_TAGS)
|
||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
services = body.get("services", [])
|
||||
if not zone:
|
||||
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")
|
||||
current = _parse_zone_output(
|
||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
).get("services", [])
|
||||
for svc in current:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--remove-service={svc}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
for svc in services:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--add-service={svc}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
return {"zone": zone, "services": services}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/rich-rules/add", invalidate=_READ_TAGS)
|
||||
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
rule = body.get("rule", "").strip()
|
||||
if not zone or not rule:
|
||||
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(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--add-rich-rule=" + rule,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
cfg = _get_config()
|
||||
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("rich_rules", [])
|
||||
rule_id = uuid4().hex[:8]
|
||||
entry = {"id": rule_id, "rule": rule}
|
||||
cfg["zones"][zone]["rich_rules"].append(entry)
|
||||
_save_config(cfg)
|
||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/rich-rules/remove", invalidate=_READ_TAGS)
|
||||
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
rule_id = body.get("id", "").strip()
|
||||
if not zone or not rule_id:
|
||||
raise ValueError("'zone' and 'id' are required")
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
cfg = _get_config()
|
||||
zone_cfg = cfg.get("zones", {}).get(zone, {})
|
||||
entry = None
|
||||
for r in zone_cfg.get("rich_rules", []):
|
||||
if r.get("id") == rule_id:
|
||||
entry = r
|
||||
break
|
||||
if entry is None:
|
||||
raise NotFoundError(f"Rich rule '{rule_id}' not found in zone '{zone}'")
|
||||
rule = entry["rule"]
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--remove-rich-rule=" + rule,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
zone_cfg["rich_rules"] = [
|
||||
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
|
||||
]
|
||||
_save_config(cfg)
|
||||
return {"zone": zone, "id": rule_id}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/rich-rules", cache_tags=_READ_TAGS)
|
||||
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))
|
||||
cfg = _get_config()
|
||||
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
|
||||
result: list[dict[str, Any]] = []
|
||||
for rule_str in 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})
|
||||
else:
|
||||
result.append({"rule": rule_str})
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/masquerade", invalidate=_READ_TAGS)
|
||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
enable = body.get("enable")
|
||||
if not zone or enable is None:
|
||||
raise ValueError("'zone' and 'enable' (bool) are required")
|
||||
action = "--add-masquerade" if enable else "--remove-masquerade"
|
||||
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
|
||||
_reload()
|
||||
return {"zone": zone, "masquerade": bool(enable)}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/forward-port/add", invalidate=_READ_TAGS)
|
||||
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
port = body.get("port")
|
||||
proto = body.get("proto", "").strip()
|
||||
toaddr = body.get("toaddr")
|
||||
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:
|
||||
fwd += f"/toaddr={toaddr}/toport={toport}"
|
||||
elif toport:
|
||||
fwd += f"/toport={toport}"
|
||||
elif toaddr:
|
||||
fwd += f"/toaddr={toaddr}"
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--add-forward-port={fwd}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
fp_id = uuid4().hex[:8]
|
||||
entry: dict[str, Any] = {"id": fp_id, "port": int(port), "proto": proto}
|
||||
if toaddr:
|
||||
entry["toaddr"] = toaddr
|
||||
if toport:
|
||||
entry["toport"] = int(toport)
|
||||
cfg = _get_config()
|
||||
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
|
||||
cfg["zones"][zone]["forward_ports"].append(entry)
|
||||
_save_config(cfg)
|
||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/forward-port/remove", invalidate=_READ_TAGS)
|
||||
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
zone = body.get("zone", "").strip()
|
||||
port = body.get("port")
|
||||
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():
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
fwd = f"port={port}/proto={proto}"
|
||||
cfg = _get_config()
|
||||
fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", [])
|
||||
found = False
|
||||
for fp in fps:
|
||||
if fp.get("port") == port and fp.get("proto") == proto:
|
||||
found = True
|
||||
if fp.get("toaddr") and fp.get("toport"):
|
||||
fwd += f"/toaddr={fp['toaddr']}/toport={fp['toport']}"
|
||||
elif fp.get("toport"):
|
||||
fwd += f"/toport={fp['toport']}"
|
||||
elif fp.get("toaddr"):
|
||||
fwd += f"/toaddr={fp['toaddr']}"
|
||||
break
|
||||
if not found:
|
||||
raise NotFoundError(f"Forward port {port}/{proto} not found in zone '{zone}'")
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
f"--remove-forward-port={fwd}",
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
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") == proto)
|
||||
]
|
||||
_save_config(cfg)
|
||||
return {"zone": zone, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/state", cache_tags=_READ_TAGS)
|
||||
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_state()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Logs daemon handler.
|
||||
|
||||
Reads system logs and journal entries.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from daemon.server import registry
|
||||
from lib.common import run_proc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log"
|
||||
_MAX_LINES = 200
|
||||
|
||||
_LOG_TAGS = {"logs"}
|
||||
|
||||
|
||||
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
||||
try:
|
||||
if sudo:
|
||||
result = run_proc(["cat", path], sudo=True)
|
||||
lines = result.stdout.splitlines(keepends=True)
|
||||
else:
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
return "".join(lines[-n:])
|
||||
except FileNotFoundError:
|
||||
return "(log file not found)\n"
|
||||
except PermissionError:
|
||||
return "(permission denied)\n"
|
||||
|
||||
|
||||
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
try:
|
||||
result = run_proc(
|
||||
["journalctl", "-u", unit, "--no-pager", "-n", str(n)],
|
||||
sudo=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
return output if output else f"(no journal entries for {unit})\n"
|
||||
except Exception as exc:
|
||||
return f"(error reading journal: {exc})\n"
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/journal", cache_tags=_LOG_TAGS)
|
||||
def journal(_request, _body) -> str:
|
||||
return _sudo_journalctl("vacuum-wall")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/access", cache_tags=_LOG_TAGS)
|
||||
def nginx_access(_request, _body) -> str:
|
||||
return _tail_file("/var/log/nginx/access.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/error", cache_tags=_LOG_TAGS)
|
||||
def nginx_error(_request, _body) -> str:
|
||||
return _tail_file("/var/log/nginx/error.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/dnsmasq", cache_tags=_LOG_TAGS)
|
||||
def dnsmasq_log(_request, _body) -> str:
|
||||
return _sudo_journalctl("dnsmasq")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/app", cache_tags=_LOG_TAGS)
|
||||
def app_log(_request, _body) -> str:
|
||||
return _tail_file(str(APP_LOG_FILE))
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Nginx daemon handler."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
||||
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
||||
SITES_DIR = DATA_DIR / "sites-enabled"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf")
|
||||
SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf")
|
||||
HTPASSWD_FILE = DATA_DIR / ".htpasswd"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_SSL: dict[str, Any] = {
|
||||
"protocols": "TLSv1.2 TLSv1.3",
|
||||
"ciphers": (
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-RSA-AES128-GCM-SHA256:"
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-RSA-AES256-GCM-SHA384:"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
||||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||||
),
|
||||
"prefer_server_ciphers": False,
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"domains": {},
|
||||
"management": None,
|
||||
"ssl": {**DEFAULT_SSL},
|
||||
}
|
||||
|
||||
_NGINX_TAGS = {"nginx"}
|
||||
|
||||
|
||||
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[str, Any]) -> None:
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
|
||||
|
||||
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"],
|
||||
backend=domain_cfg.get("backend", {}),
|
||||
headers=domain_cfg.get("headers", {}),
|
||||
force_ssl=domain_cfg.get("force_ssl", True),
|
||||
cert=domain_cfg.get("cert"),
|
||||
auth=domain_cfg.get("auth"),
|
||||
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 _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:
|
||||
f.write(conf_text)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _write_include_file() -> None:
|
||||
tmpl = ENV.get_template("nginx/include.conf")
|
||||
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
|
||||
tmp = INCLUDE_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True)
|
||||
run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _write_ssl_snippet() -> None:
|
||||
cfg = _get_config()
|
||||
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"])
|
||||
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
|
||||
content = tmpl.render(ssl=ssl_cfg)
|
||||
tmp = SSL_SNIPPET.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o644)
|
||||
run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True)
|
||||
run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _test_config() -> tuple[bool, str]:
|
||||
result = run_proc(
|
||||
["nginx", "-t"], sudo=True, check=False
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
if not output and ok:
|
||||
output = "nginx configuration test passed"
|
||||
return ok, output
|
||||
|
||||
|
||||
def _reload_nginx() -> None:
|
||||
result = run_proc(
|
||||
["nginx", "-s", "reload"], sudo=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("nginx reload failed: %s", result.stderr.strip())
|
||||
else:
|
||||
logger.info("nginx configuration applied and reloaded")
|
||||
|
||||
|
||||
def _write_all_sites() -> None:
|
||||
ensure_dirs(SITES_DIR)
|
||||
cfg = _get_config()
|
||||
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else 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)
|
||||
_write_site(name, conf)
|
||||
written.add(f"{name}.conf")
|
||||
if cfg.get("management"):
|
||||
mgmt = cfg["management"]
|
||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||
mgmt_conf = tmpl.render(
|
||||
domain=mgmt.get("domain"),
|
||||
backend=dict(
|
||||
mgmt.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
||||
),
|
||||
headers={},
|
||||
force_ssl=True,
|
||||
cert=None,
|
||||
auth=mgmt.get("auth"),
|
||||
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"),
|
||||
)
|
||||
_write_site("management", mgmt_conf)
|
||||
written.add("management.conf")
|
||||
for old in existing:
|
||||
if old.suffix == ".conf" and old.name not in written:
|
||||
old.unlink()
|
||||
tmpl = ENV.get_template("nginx/acme-challenge.conf")
|
||||
acme_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(acme_content)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, site)
|
||||
|
||||
|
||||
def _write_htpasswd(user: str, password: str) -> None:
|
||||
ensure_dirs(DATA_DIR)
|
||||
import crypt
|
||||
|
||||
salt = os.urandom(16).hex()[:16]
|
||||
hashed = crypt.crypt(password, f"$5${salt}")
|
||||
existing: dict[str, str] = {}
|
||||
if HTPASSWD_FILE.exists():
|
||||
with open(HTPASSWD_FILE) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
existing[parts[0]] = line
|
||||
existing[user] = f"{user}:{hashed}"
|
||||
tmp = HTPASSWD_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
for _uname, entry in existing.items():
|
||||
f.write(entry + "\n")
|
||||
os.chmod(tmp, 0o640)
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/config", cache_tags=_NGINX_TAGS)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/config", invalidate=_NGINX_TAGS)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/nginx/config", invalidate=_NGINX_TAGS)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
from lib.common import deep_merge
|
||||
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/domains", cache_tags=_NGINX_TAGS)
|
||||
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
cfg = _get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
result.append(
|
||||
{
|
||||
"domain": name,
|
||||
"backend": dom.get("backend", {}),
|
||||
"online": site.exists(),
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/add", invalidate=_NGINX_TAGS)
|
||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
backend_host = body.get("backend_host", "").strip()
|
||||
backend_port = body.get("backend_port")
|
||||
backend_proto = body.get("backend_proto", "http").strip() or "http"
|
||||
cert = body.get("cert")
|
||||
extra_headers = body.get("extra_headers")
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
if not backend_host:
|
||||
raise ValueError("'backend_host' is required")
|
||||
if backend_port is None:
|
||||
raise ValueError("'backend_port' is required")
|
||||
cfg = _get_config()
|
||||
if domain in cfg["domains"]:
|
||||
raise ValueError(f"Domain {domain!r} already configured")
|
||||
entry: dict[str, Any] = {
|
||||
"backend": {
|
||||
"host": backend_host,
|
||||
"port": int(backend_port),
|
||||
"proto": backend_proto,
|
||||
},
|
||||
"force_ssl": True,
|
||||
}
|
||||
if cert is not None:
|
||||
entry["cert"] = cert
|
||||
if extra_headers is not None:
|
||||
entry["headers"] = extra_headers
|
||||
cfg["domains"][domain] = entry
|
||||
_save_config(cfg)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/nginx/domains/remove", invalidate=_NGINX_TAGS)
|
||||
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
cfg = _get_config()
|
||||
if domain not in cfg["domains"]:
|
||||
raise NotFoundError(f"Domain {domain!r} not found")
|
||||
del cfg["domains"][domain]
|
||||
_save_config(cfg)
|
||||
site = SITES_DIR / f"{domain}.conf"
|
||||
if site.exists():
|
||||
site.unlink()
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/update", invalidate=_NGINX_TAGS)
|
||||
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
cfg = _get_config()
|
||||
if domain not in cfg["domains"]:
|
||||
raise NotFoundError(f"Domain {domain!r} not configured")
|
||||
updates = {k: v for k, v in body.items() if k != "domain"}
|
||||
entry = cfg["domains"][domain]
|
||||
for key, val in updates.items():
|
||||
if isinstance(val, dict) and key in entry:
|
||||
entry[key].update(val)
|
||||
else:
|
||||
entry[key] = val
|
||||
_save_config(cfg)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/apply", invalidate=_NGINX_TAGS)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_write_ssl_snippet()
|
||||
_write_all_sites()
|
||||
_write_include_file()
|
||||
ok, msg = _test_config()
|
||||
if not ok:
|
||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||
_reload_nginx()
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/test", invalidate=_NGINX_TAGS)
|
||||
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
valid, output = _test_config()
|
||||
return {"valid": valid, "output": output}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/ssl-apply", invalidate=_NGINX_TAGS)
|
||||
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_write_ssl_snippet()
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/management", invalidate=_NGINX_TAGS)
|
||||
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
domain = body.get("domain", "").strip()
|
||||
if not domain:
|
||||
raise ValueError("'domain' is required")
|
||||
flask_host = body.get("flask_host", "127.0.0.1").strip() or "127.0.0.1"
|
||||
flask_port = body.get("flask_port", 9090)
|
||||
auth_user = body.get("auth_user")
|
||||
auth_pass = body.get("auth_pass")
|
||||
cfg = _get_config()
|
||||
entry: dict[str, Any] = {
|
||||
"domain": domain,
|
||||
"backend": {"host": flask_host, "port": int(flask_port), "proto": "http"},
|
||||
}
|
||||
if auth_user:
|
||||
entry["auth"] = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
|
||||
cfg["management"] = entry
|
||||
_save_config(cfg)
|
||||
if auth_user and auth_pass:
|
||||
_write_htpasswd(auth_user, auth_pass)
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/reload")
|
||||
def reload_nginx(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_reload_nginx()
|
||||
return {"reloaded": True}
|
||||
@@ -0,0 +1,330 @@
|
||||
"""WireGuard daemon handler."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||
WG_QUICK_BIN = "wg-quick"
|
||||
WG_BIN = "wg"
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
|
||||
autoescape=False,
|
||||
lstrip_blocks=True,
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
"listen_port": 51820,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
_WG_TAGS = {"wireguard"}
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
|
||||
|
||||
|
||||
def _save_config(cfg: dict[str, Any]) -> None:
|
||||
save_json(CONFIG_PATH, cfg)
|
||||
|
||||
|
||||
def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=cfg["interface"],
|
||||
peers=cfg.get("peers", {}),
|
||||
)
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/config", cache_tags=_WG_TAGS)
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
safe = dict(cfg)
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return safe
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/config", invalidate=_WG_TAGS)
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
current = _get_config()
|
||||
current_key = current.get("interface", {}).get("private_key", "")
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
if current_key:
|
||||
body.setdefault("interface", {})["private_key"] = current_key
|
||||
_save_config(body)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/wireguard/config", invalidate=_WG_TAGS)
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
if "interface" in body:
|
||||
body = dict(body)
|
||||
body["interface"] = dict(body["interface"])
|
||||
body["interface"].pop("private_key", None)
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/apply", invalidate=_WG_TAGS)
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
conf_text = _generate_conf(cfg)
|
||||
_save_config(cfg)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / "wg0.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
|
||||
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/down", invalidate=_WG_TAGS)
|
||||
def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
run([WG_QUICK_BIN, "down", name], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", name)
|
||||
return {"down": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/status", cache_tags=_WG_TAGS)
|
||||
def status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
result: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
|
||||
try:
|
||||
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
return result
|
||||
raw = res.stdout.strip()
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
current_peer: dict[str, Any] | None = None
|
||||
peers: list[dict[str, Any]] = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("interface:"):
|
||||
result["up"] = True
|
||||
result["interface"] = {}
|
||||
current_peer = None
|
||||
continue
|
||||
if line.startswith("public key:"):
|
||||
result["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("listening port:"):
|
||||
result["interface"]["listen_port"] = int(line.split(":", 1)[1].strip())
|
||||
continue
|
||||
if line.startswith("fwmark:"):
|
||||
result["interface"]["fwmark"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
current_peer = {
|
||||
"public_key": cur_key,
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": 0,
|
||||
"transfer_sent": 0,
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
peers.append(current_peer)
|
||||
continue
|
||||
if current_peer is None:
|
||||
continue
|
||||
if line.startswith("endpoint:"):
|
||||
current_peer["endpoint"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("allowed ips:"):
|
||||
current_peer["allowed_ips"] = line.split(":", 1)[1].strip().split(", ")
|
||||
elif line.startswith("latest handshake:"):
|
||||
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
rest = line.split(":", 1)[1].strip().split(", ")
|
||||
if rest:
|
||||
current_peer["transfer_received"] = rest[0].strip()
|
||||
if len(rest) > 1:
|
||||
current_peer["transfer_sent"] = rest[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
try:
|
||||
current_peer["persistent_keepalive"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
except ValueError:
|
||||
current_peer["persistent_keepalive"] = None
|
||||
result["peers"] = peers
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/initialize", invalidate=_WG_TAGS)
|
||||
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
if cfg["interface"].get("private_key"):
|
||||
return {"initialized": False, "reason": "already initialized"}
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
private_key = res.stdout.strip()
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
|
||||
public_key = res2.stdout.strip()
|
||||
cfg["interface"]["private_key"] = private_key
|
||||
cfg["interface"]["public_key"] = public_key
|
||||
_save_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16])
|
||||
safe = dict(cfg)
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return {"initialized": True, "config": safe}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/peers/add", invalidate=_WG_TAGS)
|
||||
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
allowed_ips = body.get("allowed_ips", [])
|
||||
if name in peers:
|
||||
peer = peers[name]
|
||||
peer["endpoint"] = body.get("endpoint")
|
||||
peer["allowed_ips"] = allowed_ips
|
||||
peer["persistent_keepalive"] = body.get("persistent_keepalive")
|
||||
if body.get("preshared_key") is not None:
|
||||
peer["preshared_key"] = body["preshared_key"]
|
||||
logger.info("WireGuard peer '%s' updated", name)
|
||||
else:
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
priv = res.stdout.strip()
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv)
|
||||
pub = res2.stdout.strip()
|
||||
peers[name] = {
|
||||
"public_key": pub,
|
||||
"private_key": priv,
|
||||
"endpoint": body.get("endpoint"),
|
||||
"allowed_ips": allowed_ips,
|
||||
"persistent_keepalive": body.get("persistent_keepalive"),
|
||||
"preshared_key": body.get("preshared_key"),
|
||||
}
|
||||
logger.info("WireGuard peer '%s' added", name)
|
||||
_save_config(cfg)
|
||||
peer_out = dict(peers[name])
|
||||
peer_out.pop("private_key", None)
|
||||
return peer_out
|
||||
|
||||
|
||||
@registry.register("DELETE", "/wireguard/peers/remove", invalidate=_WG_TAGS)
|
||||
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
cfg = _get_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
if name not in peers:
|
||||
raise NotFoundError(f"Peer '{name}' not found")
|
||||
del peers[name]
|
||||
_save_config(cfg)
|
||||
logger.info("WireGuard peer '%s' removed", name)
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peers", cache_tags=_WG_TAGS)
|
||||
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
cfg = _get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, info in cfg.get("peers", {}).items():
|
||||
entry = dict(info)
|
||||
entry["name"] = name
|
||||
entry.pop("private_key", None)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peer-status", cache_tags=_WG_TAGS)
|
||||
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
st = status(None, None)
|
||||
return st.get("peers", [])
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/generate-client")
|
||||
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
raise ValueError("'name' is required")
|
||||
server_endpoint = body.get("server_endpoint", "")
|
||||
if not server_endpoint:
|
||||
raise ValueError("'server_endpoint' is required")
|
||||
cfg = _get_config()
|
||||
if name not in cfg.get("peers", {}):
|
||||
raise NotFoundError(f"Peer '{name}' not found")
|
||||
peer = cfg["peers"][name]
|
||||
client_priv = peer.get("private_key", "")
|
||||
if not client_priv:
|
||||
raise NotFoundError(f"Peer '{name}' has no private key")
|
||||
iface = cfg["interface"]
|
||||
sorted_peers = sorted(cfg.get("peers", {}).keys())
|
||||
peer_index = sorted_peers.index(name) + 2
|
||||
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
|
||||
addr_part, prefix = srv_addr.rsplit("/", 1)
|
||||
prefix_base = addr_part.rsplit(".", 1)[0]
|
||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||
tmpl = ENV.get_template("wireguard-client.conf")
|
||||
conf = tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
peer_name=name,
|
||||
client_priv=client_priv,
|
||||
client_addr=client_addr,
|
||||
server_pubkey=iface.get("public_key", ""),
|
||||
server_endpoint=server_endpoint,
|
||||
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
|
||||
preshared_key=peer.get("preshared_key"),
|
||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||
)
|
||||
return {"config": conf}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""aiohttp server for vacuum-walld.
|
||||
|
||||
Listens on a Unix socket, serves the daemon API to the web UI.
|
||||
Handles routing, caching, batching, and request/response lifecycle.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
SOCKET_PATH = PROJECT_DIR / "data" / "daemon.sock"
|
||||
|
||||
|
||||
class Cache:
|
||||
"""Tag-based cache. Entries persist until invalidated by write operations.
|
||||
|
||||
External changes to system state (e.g., manual firewall-cmd, config edits on
|
||||
disk) bypass cache invalidation and will result in stale data until the cache
|
||||
is cleared or affected tags are invalidated.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, Any] = {}
|
||||
self._tags: dict[str, set[str]] = {}
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
return self._store.get(key)
|
||||
|
||||
def set(self, key: str, value: Any, tags: set[str]) -> None:
|
||||
self._store[key] = value
|
||||
self._tags[key] = tags
|
||||
|
||||
def invalidate(self, *tags: str) -> None:
|
||||
for tag in tags:
|
||||
keys = [k for k, ts in self._tags.items() if tag in ts]
|
||||
for k in keys:
|
||||
self._store.pop(k, None)
|
||||
self._tags.pop(k, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._store.clear()
|
||||
self._tags.clear()
|
||||
|
||||
|
||||
cache = Cache()
|
||||
|
||||
|
||||
class Handler:
|
||||
"""Wrapper for a daemon handler function."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
cache_tags: set[str] | None = None,
|
||||
invalidate: set[str] | None = None,
|
||||
) -> None:
|
||||
self.method = method.upper()
|
||||
self.path = path
|
||||
self.cache_tags = cache_tags or set()
|
||||
self.invalidate = invalidate or set()
|
||||
|
||||
|
||||
class Registry:
|
||||
"""Route registry for daemon handlers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._routes: dict[tuple[str, str], Callable] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
cache_tags: set[str] | None = None,
|
||||
invalidate: set[str] | None = None,
|
||||
):
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
self._routes[(method.upper(), path)] = fn
|
||||
fn._handler = Handler(method, path, cache_tags, invalidate) # type: ignore[attr-defined]
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
def get(self, method: str, path: str) -> Callable | None:
|
||||
return self._routes.get((method.upper(), path))
|
||||
|
||||
|
||||
registry = Registry()
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
"""Raised when a requested resource is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def ok(data: Any = None) -> web.Response:
|
||||
return web.json_response({"ok": True, "data": data})
|
||||
|
||||
|
||||
def error(msg: str, code: int = 400) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": msg}, status=code)
|
||||
|
||||
|
||||
async def _handle_request(request: web.Request) -> web.Response:
|
||||
"""Dispatch a request to the appropriate handler."""
|
||||
handler_fn = registry.get(request.method, request.path)
|
||||
if handler_fn is None:
|
||||
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
||||
|
||||
h = getattr(handler_fn, "_handler", None)
|
||||
|
||||
# Build body from JSON and merge query params. GET requests send params
|
||||
# as URL query string, so they need to be treated as body for handlers.
|
||||
body: dict[str, Any] | None = None
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return error("Invalid JSON body", 400)
|
||||
|
||||
query_dict = dict(request.query)
|
||||
if query_dict:
|
||||
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
||||
if body is not None:
|
||||
merged = {**query_body, **body}
|
||||
body = merged
|
||||
else:
|
||||
body = query_body
|
||||
|
||||
cache_key = json.dumps(
|
||||
{
|
||||
"method": request.method,
|
||||
"path": request.path,
|
||||
"query": query_dict,
|
||||
"body": body,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
# Cache hit for read operations
|
||||
if h and h.cache_tags:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return ok(cached)
|
||||
|
||||
try:
|
||||
if body is not None:
|
||||
result = handler_fn(request, body)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
else:
|
||||
result = handler_fn(request, None)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
except NotFoundError as exc:
|
||||
return error(str(exc), 404)
|
||||
except ValueError as exc:
|
||||
return error(str(exc), 400)
|
||||
except RuntimeError as exc:
|
||||
logger.error("Handler error: %s", exc)
|
||||
return error(str(exc), 500)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Unexpected handler error in %s %s", request.method, request.path
|
||||
)
|
||||
return error(f"Internal error: {exc}", 500)
|
||||
|
||||
# Cache write for read operations
|
||||
if h and h.cache_tags and isinstance(result, dict) and result.get("ok"):
|
||||
cache.set(cache_key, result.get("data"), h.cache_tags)
|
||||
|
||||
# Invalidate on write operations
|
||||
if h and h.invalidate:
|
||||
cache.invalidate(*h.invalidate)
|
||||
|
||||
# Convert result to response if not already
|
||||
if isinstance(result, web.Response):
|
||||
return result
|
||||
if isinstance(result, dict) and result.get("ok") is False:
|
||||
return error(result["error"], result.get("code", 400))
|
||||
return ok(result)
|
||||
|
||||
|
||||
async def _handle_batch(request: web.Request) -> web.Response:
|
||||
"""Handle batch requests: execute operations in order, return keyed results."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return error("Invalid JSON body", 400)
|
||||
|
||||
ops = body.get("ops", [])
|
||||
if not isinstance(ops, list):
|
||||
return error("'ops' must be a list", 400)
|
||||
|
||||
results: dict[str, Any] = {}
|
||||
for op in ops:
|
||||
op_id = op.get("id")
|
||||
method = op.get("method", "GET").upper()
|
||||
path = op.get("path", "")
|
||||
|
||||
if not op_id or not path:
|
||||
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
|
||||
continue
|
||||
|
||||
handler_fn = registry.get(method, path)
|
||||
if handler_fn is None:
|
||||
results[op_id] = {
|
||||
"ok": False,
|
||||
"error": f"Endpoint not found: {method} {path}",
|
||||
}
|
||||
continue
|
||||
|
||||
op_body = op.get("body")
|
||||
h = getattr(handler_fn, "_handler", None)
|
||||
|
||||
try:
|
||||
result = handler_fn(None, op_body)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
except Exception as exc:
|
||||
logger.error("Batch handler error for %s: %s", op_id, exc)
|
||||
results[op_id] = {"ok": False, "error": str(exc)}
|
||||
continue
|
||||
|
||||
# Strip ok/data wrapper for batch results
|
||||
if isinstance(result, dict) and result.get("ok") is not None:
|
||||
results[op_id] = result
|
||||
else:
|
||||
results[op_id] = {"ok": True, "data": result}
|
||||
|
||||
# Invalidate on write
|
||||
if h and h.invalidate:
|
||||
cache.invalidate(*h.invalidate)
|
||||
|
||||
return ok(results)
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
app = web.Application()
|
||||
app.router.add_route("GET", "/health", _health)
|
||||
app.router.add_route("POST", "/batch", _handle_batch)
|
||||
app.router.add_route("{tail:.*}", _catch_all)
|
||||
return app
|
||||
|
||||
|
||||
async def _health(_request: web.Request) -> web.Response:
|
||||
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
|
||||
|
||||
|
||||
async def _catch_all(request: web.Request) -> web.Response:
|
||||
"""Catch-all for registered routes."""
|
||||
return await _handle_request(request)
|
||||
|
||||
|
||||
def _register_routes() -> None:
|
||||
"""Import all handler modules to register routes."""
|
||||
from daemon.handlers import (
|
||||
acme, # noqa: F401
|
||||
dnsmasq, # noqa: F401
|
||||
firewall, # noqa: F401
|
||||
logs, # noqa: F401
|
||||
nginx, # noqa: F401
|
||||
wireguard, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for vacuum-walld."""
|
||||
from lib.logging import setup_logging
|
||||
|
||||
setup_logging()
|
||||
|
||||
_register_routes()
|
||||
app = create_app()
|
||||
|
||||
socket_path = os.environ.get("VACUUM_WALLD_SOCKET", str(SOCKET_PATH))
|
||||
socket_dir = Path(socket_path).parent
|
||||
socket_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if Path(socket_path).exists():
|
||||
os.unlink(socket_path)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
def _on_shutdown(_sig: int) -> None:
|
||||
logger.info("Shutting down daemon...")
|
||||
loop.stop()
|
||||
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, _on_shutdown, sig)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
loop.run_until_complete(runner.setup())
|
||||
site = web.UnixSite(runner, socket_path)
|
||||
loop.run_until_complete(site.start())
|
||||
|
||||
os.chmod(socket_path, 0o660)
|
||||
logger.info("vacuum-walld listening on %s", socket_path)
|
||||
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
loop.run_until_complete(runner.cleanup())
|
||||
if Path(socket_path).exists():
|
||||
os.unlink(socket_path)
|
||||
logger.info("vacuum-walld stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user