200e078bc5
- 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
331 lines
12 KiB
Python
331 lines
12 KiB
Python
"""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}
|