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:
2026-05-27 23:38:23 +00:00
parent 5ac69dfa7e
commit 200e078bc5
39 changed files with 4671 additions and 1810 deletions
+330
View File
@@ -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}