refactor(lib): fix acme.renew deploy call and hoist wireguard imports

- Add deploy(domain) to acme.renew() to register deploy hook after
  renewal, matching the pattern in issue()
- Hoist run and run_proc imports to module level in wireguard,
  removing 4 inline imports for consistency
This commit is contained in:
2026-05-23 03:56:13 +00:00
parent 37039351be
commit cf8115bb0d
3 changed files with 126 additions and 148 deletions
+44 -47
View File
@@ -27,6 +27,8 @@ _ACME_ENVIRON = {
), ),
} }
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
def _find_acme() -> str: def _find_acme() -> str:
"""Locate the acme.sh binary on the system. """Locate the acme.sh binary on the system.
@@ -147,77 +149,52 @@ def get_email() -> str:
return "" return ""
def issue(domain: str, webroot: str | None = None) -> dict: def issue(domain: str, webroot: str | None = None, email: str | None = None) -> str:
"""Issue a new SSL certificate for a domain. """Issue a new SSL certificate for a domain.
Args: Args:
domain: The primary domain name. domain: The primary domain name.
webroot: Path to the web root directory for HTTP-01 validation. webroot: Path to the web root directory for HTTP-01 validation.
email: Contact email. Falls back to configured ACME email if not given.
Returns: Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'. Combined stdout from the acme.sh command.
Raises:
RuntimeError: If issuance fails.
""" """
args: list[str] = ["--issue", "-d", domain] args: list[str] = ["--issue", "-d", domain]
args.extend(["--webroot", webroot or str(_WEBROOT)])
if webroot: contact = email or get_email()
args.extend(["--webroot", webroot]) if contact:
args.extend(["-m", contact])
email = get_email()
if email:
args.extend(["-m", email])
args.append("--force") args.append("--force")
try:
output = _run_acme(args) output = _run_acme(args)
deploy(domain) deploy(domain)
return { logger.info("Certificate for %s issued successfully", domain)
"success": True, return output.strip()
"domain": domain,
"message": f"Certificate for {domain} issued successfully",
"output": output.strip(),
"error": None,
}
except RuntimeError as exc:
return {
"success": False,
"domain": domain,
"message": f"Failed to issue certificate for {domain}",
"output": "",
"error": str(exc),
}
def renew(domain: str, force: bool = False) -> dict: def renew(domain: str, force: bool = False) -> str:
"""Renew an existing SSL certificate. """Renew an existing SSL certificate.
Args: Args:
domain: The domain whose certificate should be renewed. domain: The domain whose certificate should be renewed.
force: If True, renew even if the certificate isn't close to expiry. force: If True, renew even if not close to expiry.
Returns: Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'. Combined stdout from the acme.sh command.
Raises:
RuntimeError: If renewal fails.
""" """
args: list[str] = ["--renew", "-d", domain] args: list[str] = ["--renew", "-d", domain]
if force: if force:
args.append("--force") args.append("--force")
try:
output = _run_acme(args) output = _run_acme(args)
return { deploy(domain)
"success": True, logger.info("Certificate for %s renewed successfully", domain)
"domain": domain, return output.strip()
"message": f"Certificate for {domain} renewed successfully",
"output": output.strip(),
"error": None,
}
except RuntimeError as exc:
return {
"success": False,
"domain": domain,
"message": f"Failed to renew certificate for {domain}",
"output": "",
"error": str(exc),
}
def remove(domain: str) -> str: def remove(domain: str) -> str:
@@ -274,7 +251,10 @@ def list_certs() -> list[dict]:
certs.append( certs.append(
{ {
"domain": main, "domain": main,
"ca": entry.get("CA", ""), "issuer": entry.get("CA", ""),
"expiry": entry.get("certificate_expires", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": cert_path, "cert_path": cert_path,
"key_path": key_path, "key_path": key_path,
"ca_path": ca_path, "ca_path": ca_path,
@@ -494,3 +474,20 @@ def _has_auto_renew(domain: str) -> bool:
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
domain_conf = Path(acme_home_env) / f"{domain}.conf" domain_conf = Path(acme_home_env) / f"{domain}.conf"
return bool(domain_conf.is_file()) return bool(domain_conf.is_file())
__all__ = [
"copy_cert",
"days_until_expiry",
"deploy",
"get_cert_info",
"get_cert_paths",
"get_email",
"get_expiry",
"is_expired",
"issue",
"list_certs",
"remove",
"renew",
"set_email",
]
+55 -74
View File
@@ -1,23 +1,24 @@
""" """WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface. the WireGuard tunnel interface.
""" """
import json
import logging import logging
import os import os
import subprocess from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json") CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_CONF_PATH = "/etc/wireguard/wg0.conf" WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick" WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg" WG_BIN = "wg"
@@ -29,28 +30,7 @@ ENV = Environment(
trim_blocks=True, trim_blocks=True,
) )
DEFAULT_CONFIG: dict[str, Any] = {
# --- Helpers ---
def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a command via sudo and return the completed process."""
return subprocess.run(
["sudo", *cmd],
capture_output=True,
text=True,
check=check,
)
def _ensure_dir(path: str) -> None:
"""Create parent directories for *path* if they don't exist."""
Path(path).parent.mkdir(parents=True, exist_ok=True)
def _default_config() -> dict:
"""Return the skeleton config with no keys and no peers."""
return {
"interface": { "interface": {
"name": "wg0", "name": "wg0",
"listen_port": 51820, "listen_port": 51820,
@@ -61,40 +41,20 @@ def _default_config() -> dict:
"post_down": None, "post_down": None,
}, },
"peers": {}, "peers": {},
} }
# --- Core config persistence --- # --- Core config persistence ---
def get_config() -> dict: def get_config() -> dict[str, Any]:
"""Load the current WireGuard configuration from the JSON store.""" """Load the current WireGuard configuration from the JSON store."""
try: return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
with open(CONFIG_PATH) as f:
cfg = json.load(f)
defaults = _default_config()
cfg.setdefault("interface", defaults["interface"])
cfg["interface"].setdefault("name", defaults["interface"]["name"])
cfg["interface"].setdefault("listen_port", defaults["interface"]["listen_port"])
cfg["interface"].setdefault("private_key", defaults["interface"]["private_key"])
cfg["interface"].setdefault("public_key", defaults["interface"]["public_key"])
cfg["interface"].setdefault("addresses", defaults["interface"]["addresses"])
cfg["interface"].setdefault("post_up", defaults["interface"]["post_up"])
cfg["interface"].setdefault("post_down", defaults["interface"]["post_down"])
cfg.setdefault("peers", {})
return cfg
except (FileNotFoundError, json.JSONDecodeError):
return _default_config()
def save_config(cfg: dict) -> None: def save_config(cfg: dict[str, Any]) -> None:
"""Persist *cfg* to the JSON store atomically.""" """Persist *cfg* to the JSON store atomically."""
_ensure_dir(CONFIG_PATH) save_json(CONFIG_PATH, cfg)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(cfg, f, indent=4)
f.write("\n")
os.replace(tmp, CONFIG_PATH)
# --- Key generation --- # --- Key generation ---
@@ -102,9 +62,9 @@ def save_config(cfg: dict) -> None:
def generate_keypair() -> tuple[str, str]: def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI.""" """Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = _run([WG_BIN, "genkey"]) res = run_proc([WG_BIN, "genkey"], sudo=True)
private_key = res.stdout.strip() private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key) res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
public_key = res2.stdout.strip() public_key = res2.stdout.strip()
return private_key, public_key return private_key, public_key
@@ -112,7 +72,7 @@ def generate_keypair() -> tuple[str, str]:
# --- wg0.conf generation --- # --- wg0.conf generation ---
def generate_conf(cfg: dict) -> str: def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2.""" """Render a valid wg-quick config file from *cfg* using Jinja2."""
tmpl = ENV.get_template("wireguard.conf") tmpl = ENV.get_template("wireguard.conf")
return tmpl.render( return tmpl.render(
@@ -137,11 +97,11 @@ def apply() -> None:
with open(local_tmp, "w") as f: with open(local_tmp, "w") as f:
f.write(conf_text) f.write(conf_text)
os.chmod(local_tmp, 0o600) os.chmod(local_tmp, 0o600)
_run(["cp", "--", str(local_tmp), WG_CONF_PATH]) run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
_run(["chown", "root:root", WG_CONF_PATH], check=False) run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
local_tmp.unlink(missing_ok=True) local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]]) run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
@@ -149,34 +109,34 @@ def down() -> None:
"""Bring the WireGuard tunnel interface down.""" """Bring the WireGuard tunnel interface down."""
cfg = get_config() cfg = get_config()
name = cfg["interface"]["name"] name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name]) run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name) logger.info("WireGuard tunnel '%s' brought down", name)
# --- Status --- # --- Status ---
def status() -> dict: def status() -> dict[str, Any]:
"""Query the live tunnel state via ``wg show``.""" """Query the live tunnel state via ``wg show``."""
cfg = get_config() cfg = get_config()
name = cfg["interface"]["name"] name = cfg["interface"]["name"]
result = { result: dict[str, Any] = {
"up": False, "up": False,
"interface": {}, "interface": {},
"peers": [], "peers": [],
} }
try: try:
proc = _run([WG_BIN, "show", name], check=False) res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
if proc.returncode != 0: if res.returncode != 0:
return result return result
raw = proc.stdout.strip() raw = res.stdout.strip()
except Exception: except Exception:
return result return result
current_peer = None current_peer: dict[str, Any] | None = None
peers: list[dict] = [] peers: list[dict[str, Any]] = []
for line in raw.splitlines(): for line in raw.splitlines():
line = line.strip() line = line.strip()
@@ -261,7 +221,7 @@ def add_peer(
allowed_ips: list[str] | None = None, allowed_ips: list[str] | None = None,
persistent_keepalive: int | None = None, persistent_keepalive: int | None = None,
preshared_key: str | None = None, preshared_key: str | None = None,
) -> dict: ) -> dict[str, Any]:
"""Add (or update) a peer in the configuration.""" """Add (or update) a peer in the configuration."""
cfg = get_config() cfg = get_config()
peers = cfg.setdefault("peers", {}) peers = cfg.setdefault("peers", {})
@@ -301,10 +261,10 @@ def remove_peer(name: str) -> None:
logger.info("WireGuard peer '%s' removed", name) logger.info("WireGuard peer '%s' removed", name)
def get_peers() -> list[dict]: def get_peers() -> list[dict[str, Any]]:
"""List all configured peers (from the JSON store, *not* live).""" """List all configured peers (from the JSON store, *not* live)."""
cfg = get_config() cfg = get_config()
peers = [] peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items(): for name, info in cfg.get("peers", {}).items():
entry = dict(info) entry = dict(info)
entry["name"] = name entry["name"] = name
@@ -313,7 +273,7 @@ def get_peers() -> list[dict]:
return peers return peers
def get_peer_status() -> list[dict]: def get_peer_status() -> list[dict[str, Any]]:
"""Return live peer status from ``wg show``.""" """Return live peer status from ``wg show``."""
st = status() st = status()
return st.get("peers", []) return st.get("peers", [])
@@ -393,7 +353,7 @@ def set_post_down(cmd: str | None) -> None:
# --- Initialise --- # --- Initialise ---
def initialize() -> dict: def initialize() -> dict[str, Any]:
"""Perform first-time WireGuard setup.""" """Perform first-time WireGuard setup."""
cfg = get_config() cfg = get_config()
@@ -411,10 +371,10 @@ def initialize() -> dict:
# --- Utility: parse wg show into structured peer map --- # --- Utility: parse wg show into structured peer map ---
def _parse_wg_show(output: str) -> dict: def _parse_wg_show(output: str) -> dict[str, Any]:
"""Internal parser for ``wg show`` multiline output.""" """Internal parser for ``wg show`` multiline output."""
peers: dict = {} peers: dict[str, dict[str, Any]] = {}
current = None current: dict[str, Any] | None = None
for line in output.splitlines(): for line in output.splitlines():
line = line.strip() line = line.strip()
@@ -440,3 +400,24 @@ def _parse_wg_show(output: str) -> dict:
current["persistent_keepalive"] = line.split(":", 1)[1].strip() current["persistent_keepalive"] = line.split(":", 1)[1].strip()
return peers return peers
__all__ = [
"DEFAULT_CONFIG",
"add_peer",
"apply",
"down",
"generate_client_conf",
"generate_conf",
"generate_keypair",
"get_config",
"get_peer_status",
"get_peers",
"initialize",
"remove_peer",
"save_config",
"set_listen_port",
"set_post_down",
"set_post_up",
"status",
]
+14 -14
View File
@@ -1,5 +1,5 @@
import json import json
from pathlib import Path from copy import deepcopy
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -10,14 +10,14 @@ from lib import wireguard
@pytest.fixture @pytest.fixture
def temp_config(tmp_path): def temp_config(tmp_path):
original = wireguard.CONFIG_PATH original = wireguard.CONFIG_PATH
wireguard.CONFIG_PATH = str(tmp_path / "config.json") wireguard.CONFIG_PATH = tmp_path / "config.json"
yield tmp_path yield tmp_path
wireguard.CONFIG_PATH = original wireguard.CONFIG_PATH = original
class TestDefaultConfig: class TestDefaultConfig:
def test_returns_skeleton(self): def test_returns_skeleton(self):
cfg = wireguard._default_config() cfg = wireguard.DEFAULT_CONFIG
assert cfg["interface"]["name"] == "wg0" assert cfg["interface"]["name"] == "wg0"
assert cfg["interface"]["listen_port"] == 51820 assert cfg["interface"]["listen_port"] == 51820
assert cfg["interface"]["private_key"] == "" assert cfg["interface"]["private_key"] == ""
@@ -31,8 +31,7 @@ class TestGetConfig:
assert cfg["peers"] == {} assert cfg["peers"] == {}
def test_loads_existing_config(self, temp_config): def test_loads_existing_config(self, temp_config):
path = Path(wireguard.CONFIG_PATH) wireguard.CONFIG_PATH.write_text(json.dumps({
expected = {
"interface": { "interface": {
"name": "wg0", "name": "wg0",
"listen_port": 51820, "listen_port": 51820,
@@ -43,15 +42,14 @@ class TestGetConfig:
"post_down": None, "post_down": None,
}, },
"peers": {}, "peers": {},
} }))
path.write_text(json.dumps(expected))
cfg = wireguard.get_config() cfg = wireguard.get_config()
assert cfg["interface"]["private_key"] == "existing-key" assert cfg["interface"]["private_key"] == "existing-key"
class TestSaveConfig: class TestSaveConfig:
def test_save_and_reload(self, temp_config): def test_save_and_reload(self, temp_config):
cfg = wireguard._default_config() cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["interface"]["listen_port"] = 51821 cfg["interface"]["listen_port"] = 51821
wireguard.save_config(cfg) wireguard.save_config(cfg)
loaded = wireguard.get_config() loaded = wireguard.get_config()
@@ -59,7 +57,7 @@ class TestSaveConfig:
class TestGenerateKeyPair: class TestGenerateKeyPair:
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_returns_keypair(self, mock_run): def test_returns_keypair(self, mock_run):
mock_run.side_effect = [ mock_run.side_effect = [
MagicMock(returncode=0, stdout="private-key\n"), MagicMock(returncode=0, stdout="private-key\n"),
@@ -68,6 +66,8 @@ class TestGenerateKeyPair:
private, public = wireguard.generate_keypair() private, public = wireguard.generate_keypair()
assert private == "private-key" assert private == "private-key"
assert public == "public-key" assert public == "public-key"
assert mock_run.call_count == 2
assert mock_run.call_args_list[1].kwargs.get("input") == "private-key"
class TestGetPeers: class TestGetPeers:
@@ -97,7 +97,7 @@ class TestGetPeers:
} }
}, },
} }
Path(wireguard.CONFIG_PATH).write_text(json.dumps(cfg)) wireguard.CONFIG_PATH.write_text(json.dumps(cfg))
peers = wireguard.get_peers() peers = wireguard.get_peers()
assert len(peers) == 1 assert len(peers) == 1
assert peers[0]["name"] == "client1" assert peers[0]["name"] == "client1"
@@ -180,14 +180,14 @@ class TestInitialize:
}, },
"peers": {}, "peers": {},
} }
Path(wireguard.CONFIG_PATH).write_text(json.dumps(existing)) wireguard.CONFIG_PATH.write_text(json.dumps(existing))
cfg = wireguard.initialize() cfg = wireguard.initialize()
assert cfg["interface"]["private_key"] == "original-private" assert cfg["interface"]["private_key"] == "original-private"
mock_gen.assert_not_called() mock_gen.assert_not_called()
class TestStatus: class TestStatus:
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_returns_down_when_interface_down(self, mock_run, temp_config): def test_returns_down_when_interface_down(self, mock_run, temp_config):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="interface not found" returncode=1, stdout="", stderr="interface not found"
@@ -195,7 +195,7 @@ class TestStatus:
result = wireguard.status() result = wireguard.status()
assert result["up"] is False assert result["up"] is False
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_parses_interface_info(self, mock_run, temp_config): def test_parses_interface_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, returncode=0,
@@ -206,7 +206,7 @@ class TestStatus:
assert result["interface"]["public_key"] == "ABCDEF" assert result["interface"]["public_key"] == "ABCDEF"
assert result["interface"]["listen_port"] == 51820 assert result["interface"]["listen_port"] == 51820
@patch("lib.wireguard._run") @patch("lib.wireguard.run_proc")
def test_parses_peer_info(self, mock_run, temp_config): def test_parses_peer_info(self, mock_run, temp_config):
mock_run.return_value = MagicMock( mock_run.return_value = MagicMock(
returncode=0, returncode=0,