Initial commit: SSL proxy / firewall appliance

Flask WebUI behind nginx reverse proxy with zone-based firewall, DHCP,
WireGuard, and ACME certificate management.
This commit is contained in:
2026-05-07 22:24:24 +00:00
commit e2f56b8cc8
56 changed files with 10013 additions and 0 deletions
View File
+510
View File
@@ -0,0 +1,510 @@
"""
ACME certificate manager for Vacuum Wall.
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
from Let's Encrypt (or other ACME providers).
"""
import logging
import os
import re
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger(__name__)
_ACME_ENVIRON = {
"HOME": str(Path.home()),
"PATH": os.environ.get(
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
),
}
def _find_acme() -> str:
"""Locate the acme.sh binary on the system.
Checks:
1. ~/.acme.sh/acme.sh
2. /usr/local/bin/acme.sh
Returns:
Absolute path to the acme.sh binary.
Raises:
FileNotFoundError: If acme.sh cannot be found.
"""
candidates = [
Path.home() / ".acme.sh" / "acme.sh",
Path("/usr/local/bin/acme.sh"),
]
for path in candidates:
if path.is_file() and os.access(path, os.X_OK):
logger.info("Found acme.sh at %s", path)
return str(path)
acme = shutil.which("acme.sh")
if acme:
logger.info("Found acme.sh via PATH at %s", acme)
return acme
raise FileNotFoundError(
"acme.sh not found in any standard location. "
"Install it with: curl -sSL https://get.acme.sh | sh"
)
def _run_acme(args: list[str]) -> str:
"""Execute acme.sh with the given arguments.
Runs the command as root via sudo because standalone / webroot
validation often requires binding to privileged ports (80/443).
Args:
args: List of arguments to pass to acme.sh.
Returns:
Combined stdout + stderr from the command, since acme.sh writes
meaningful output to both streams.
Raises:
RuntimeError: If the acme.sh command exits with a non-zero code.
"""
acme_bin = _find_acme()
cmd: list[str] = [
"sudo",
acme_bin,
"--home",
str(Path.home() / ".acme.sh"),
"--config-home",
str(Path.home() / ".acme.sh"),
*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 command timed out after 120s: {' '.join(cmd)}"
) from exc
output = result.stdout
if result.stderr:
output = output + result.stderr if output else result.stderr
if result.returncode != 0:
logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip())
raise RuntimeError(
f"acme.sh failed with exit code {result.returncode}: {output.strip()}"
)
return output
def set_email(email: str) -> None:
"""Configure the default ACME contact email.
Registers or updates the ACME account with the given email address.
Args:
email: The contact email for the ACME account.
"""
_run_acme(["--register-account", "-m", email])
logger.info("ACME contact email set to %s", email)
def get_email() -> str:
"""Return the ACME contact email, or '' if none is configured."""
try:
account_conf = Path.home() / ".acme.sh" / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip().strip("'\"")
except OSError as exc:
logger.warning("Could not read account.conf: %s", exc)
return ""
def issue(domain: str, webroot: str | None = None, standalone: bool = False) -> dict:
"""Issue a new SSL certificate for a domain.
Args:
domain: The primary domain name.
webroot: Path to the web root directory for HTTP-01 validation.
standalone: If True, use standalone TCP validation (binds port 80).
Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'.
"""
args: list[str] = ["--issue", "-d", domain]
if webroot:
args.extend(["--webroot", webroot])
elif standalone:
args.append("--standalone")
email = get_email()
if email:
args.extend(["-m", email])
args.append("--force")
try:
output = _run_acme(args)
return {
"success": True,
"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:
"""Renew an existing SSL certificate.
Args:
domain: The domain whose certificate should be renewed.
force: If True, renew even if the certificate isn't close to expiry.
Returns:
A dict with 'success', 'domain', 'message', 'output', and 'error'.
"""
args: list[str] = ["--renew", "-d", domain]
if force:
args.append("--force")
try:
output = _run_acme(args)
return {
"success": True,
"domain": domain,
"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:
"""Stop auto-renewal for a domain.
Runs ``acme.sh --remove`` which stops the cron job from renewing
the certificate. Per the acme.sh README the cert/key files are
**not** deleted from disk after ``--remove``; remove them with
the ``--ecc`` flag if needed, or delete the ``~/.acme.sh/{domain}``
directory manually.
Args:
domain: The domain to remove from the renewal list.
Returns:
The combined stdout from the acme.sh command.
"""
output = _run_acme(["--remove", "-d", domain])
logger.info("Certificate for %s removed", domain)
return output
def list_certs() -> list[dict]:
"""List all managed certificates with expiry information.
Returns:
A list of dicts, one per certificate, with keys matching
the cert-info schema (domain, ca, cert_path, etc.).
"""
raw = _run_acme(["--list"])
certs: list[dict] = []
entries = _parse_list_output(raw)
acme_home = Path.home() / ".acme.sh"
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
cert_path = str(cert_dir / "fullchain.cer")
key_path = str(cert_dir / f"{main}.key")
ca_path = str(cert_dir / "ca.cer")
days = _days_until(entry.get("certificate_expires", ""))
auto = _has_auto_renew(main)
certs.append(
{
"domain": main,
"ca": entry.get("CA", ""),
"cert_path": cert_path,
"key_path": key_path,
"ca_path": ca_path,
"issued_at": entry.get("certificate_date", ""),
"expires_at": entry.get("certificate_expires", ""),
"days_until_expiry": days,
"auto_renew": auto,
"san_domains": san_domains,
}
)
return certs
def get_cert_info(domain: str) -> dict:
"""Return detailed information about a certificate.
Args:
domain: The domain name.
Returns:
A dict matching the cert-info schema.
Raises:
ValueError: If no certificate is found for the domain.
"""
certs = list_certs()
for c in certs:
if c["domain"] == domain or domain in c["san_domains"]:
return c
raise ValueError(f"No certificate found for domain: {domain}")
def get_expiry(domain: str) -> str | None:
"""Return the certificate expiry date as an ISO string, or None.
Args:
domain: The domain name.
Returns:
Expiry date string (e.g. '2026-04-15') or None.
"""
try:
info = get_cert_info(domain)
return info.get("expires_at")
except ValueError:
return None
def is_expired(domain: str) -> bool:
"""Check whether a certificate has expired.
Args:
domain: The domain name.
Returns:
True if the certificate is expired or not found, False otherwise.
"""
days = days_until_expiry(domain)
if days is None:
return True
return days < 0
def days_until_expiry(domain: str) -> int | None:
"""Calculate the number of days until a certificate expires.
Args:
domain: The domain name.
Returns:
Integer days remaining (negative if expired), or None if cert not found.
"""
try:
info = get_cert_info(domain)
return _days_until(info.get("expires_at", ""))
except ValueError:
return None
def copy_cert(domain: str, dest_dir: str) -> dict:
"""Copy certificate files to a target directory.
Copies the fullchain, key, and CA certificate files.
Args:
domain: The domain name.
dest_dir: Destination directory path.
Returns:
A dict with paths to the copied files.
"""
paths = get_cert_paths(domain)
target = Path(dest_dir)
target.mkdir(parents=True, exist_ok=True)
copied = {}
for label, src in paths.items():
src_path = Path(src)
if src_path.is_file():
dst = target / src_path.name
shutil.copy2(str(src_path), str(dst))
copied[label] = str(dst)
else:
logger.warning("Source %s (%s) not found, skipping", label, src)
return {
"domain": domain,
"dest_dir": str(target),
"copied": copied,
"failed": [k for k in paths if k not in copied],
}
def get_cert_paths(domain: str) -> dict:
"""Return the file paths for all certificate components.
Args:
domain: The domain name.
Returns:
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
"""
acme_home = str(Path.home() / ".acme.sh" / 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",
}
def setup_nginx_install(domain: str) -> None:
"""Configure acme.sh to automatically install certs for nginx.
Sets up a post-hook so that nginx-specific files are copied to
/etc/ssl/certs and /etc/ssl/private after each (re)issue, followed
by an nginx reload.
Args:
domain: The domain name.
"""
cert_dest = f"/etc/ssl/certs/{domain}"
key_dest = f"/etc/ssl/private/{domain}.key"
args: list[str] = [
"--install-cert",
"-d",
domain,
"--cert-file",
cert_dest,
"--key-file",
key_dest,
"--ca-file",
f"/etc/ssl/certs/{domain}-ca.crt",
"--fullchain-file",
f"/etc/ssl/certs/{domain}-fullchain.crt",
"--reloadcmd",
"sudo nginx -t && sudo systemctl reload nginx",
]
_run_acme(args)
logger.info("nginx auto-install configured for %s", domain)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _parse_list_output(raw: str) -> list[dict]:
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
Each line in the output contains ``Key:Value`` tokens separated by
whitespace, e.g.::
Main_Domain:example.com SAN_Domain:www.example.com CA:Let's
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No
Keys are converted to lowercase in the returned dicts.
"""
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 _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now."""
if not date_str:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y%m%d%H%M%z"):
try:
dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC)
delta = dt - datetime.now(UTC)
return delta.days
except ValueError:
continue
return None
def _has_auto_renew(domain: str) -> bool:
"""Check whether a domain has a scheduled cron renewal.
The README states the cron entry format is:
0 0 * * * "~/.acme.sh"/acme.sh --cron --home "~/.acme.sh" > /dev/null
A per-domain ``{domain}.conf`` file existing under ``~/.acme.sh/``
indicates the domain is being tracked by the cron job.
"""
acme_home = Path.home() / ".acme.sh"
# The cron job iterates all domains tracked in ~/.acme.sh/; if the
# per-domain config exists, the cron will pick it up.
domain_conf = acme_home / f"{domain}.conf"
if domain_conf.is_file():
return True
# Fallback: check crontab -l for the domain.
try:
result = subprocess.run(
["sudo", "crontab", "-l"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and "--cron" in result.stdout:
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return False
+354
View File
@@ -0,0 +1,354 @@
"""
dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo.
"""
import json
import os
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
PROJECT_DIR = Path("/home/wall/vacuum-wall")
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = DATA_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,
)
# --- defaults ---
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {
"ranges": [],
"static_leases": [],
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": None,
"custom_records": [],
},
}
# ───────── helpers ───────────────────────────────────────────────────
def _ensure_dirs() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
def _sudo(*cmd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["sudo", *list(cmd)],
capture_output=True,
text=True,
check=True,
)
def _load_json(path: Path) -> dict:
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
def _save_json(path: Path, data: dict) -> None:
_ensure_dirs()
with open(path, "w") as f:
json.dump(data, f, indent=4)
def _deep_merge(base: dict, overrides: dict) -> dict:
result = deepcopy(base)
for k, v in overrides.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = _deep_merge(result[k], v)
else:
result[k] = deepcopy(v)
return result
# ───────── config lifecycle ──────────────────────────────────────────
def get_config() -> dict:
"""Load current dnsmasq config from JSON state file."""
_ensure_dirs()
raw = _load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
return _deep_merge(deepcopy(DEFAULT_CFG), raw)
def save_config(cfg: dict) -> None:
"""Persist config to JSON (does NOT touch on-disk dnsmasq config)."""
_ensure_dirs()
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged)
def apply_config() -> None:
"""Write generated config to disk via sudo tee, then reload dnsmasq."""
cfg = get_config()
conf_text = generate_conf(cfg)
_ensure_dirs()
_sudo("mkdir", "-p", "/etc/dnsmasq.d")
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
capture_output=True,
text=True,
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
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,
)
# ───────── dhcp management ───────────────────────────────────────────
def set_dhcp_range(
iface: str,
start: str,
end: str,
lease_time: str = "12h",
gateway: str | None = None,
dns: str | None = None,
) -> None:
"""Add or replace the DHCP range for a given interface."""
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 gateway:
ranges[i]["gateway"] = gateway
if dns:
ranges[i]["dns"] = dns
found = True
break
if not found:
entry: dict[str, Any] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
entry["gateway"] = gateway
if dns:
entry["dns"] = dns
ranges.append(entry)
save_config(cfg)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
"""Add (or update) a static DHCP lease by MAC address."""
cfg = get_config()
leases = cfg["dhcp"]["static_leases"]
for i, lease in enumerate(leases):
if lease["mac"].lower() == mac.lower():
leases[i] = {"mac": mac, "ip": ip}
if hostname:
leases[i]["hostname"] = hostname
save_config(cfg)
return
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
save_config(cfg)
def remove_static_lease(mac: str) -> None:
"""Remove a static DHCP lease by MAC address."""
cfg = get_config()
cfg["dhcp"]["static_leases"] = [
lease
for lease in cfg["dhcp"]["static_leases"]
if lease["mac"].lower() != mac.lower()
]
save_config(cfg)
# ───────── dns record management ─────────────────────────────────────
def add_dns_record(name: str, address: str, hostname: str | None = None) -> None:
"""Add or update a custom DNS A record."""
cfg = get_config()
records = cfg["dns"]["custom_records"]
for i, r in enumerate(records):
if r["name"] == name:
records[i] = {"name": name, "address": address}
if hostname:
records[i]["hostname"] = hostname
save_config(cfg)
return
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
save_config(cfg)
def remove_dns_record(name: str) -> None:
"""Remove a custom DNS record by name."""
cfg = get_config()
cfg["dns"]["custom_records"] = [
r for r in cfg["dns"]["custom_records"] if r["name"] != name
]
save_config(cfg)
# ───────── lease table ───────────────────────────────────────────────
def _parse_lease_line(line: str) -> dict[str, Any] | None:
"""Parse one line from dnsmasq.leases into a dict."""
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]:
"""Read and parse the current dnsmasq lease file."""
leases: list[dict] = []
try:
result = _sudo("cat", LEASE_FILE)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
except subprocess.CalledProcessError:
pass
return leases
# ───────── upstream / domain helpers ─────────────────────────────────
def set_upstreams(servers: list[str]) -> None:
"""Set the list of upstream DNS forwarders."""
cfg = get_config()
cfg["dns"]["upstreams"] = list(servers)
save_config(cfg)
def set_domain(domain: str | None) -> None:
"""Set (or clear) the local DNS domain."""
cfg = get_config()
cfg["dns"]["domain"] = domain if domain else None
save_config(cfg)
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
# dnsmasq process check
try:
proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"],
capture_output=True,
text=True,
)
active = proc.stdout.strip() == "active"
except Exception:
active = False
# config on disk
conf_exists = os.path.isfile(DNSMASQ_CONF)
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
conf_on_disk = f.read()
except PermissionError:
conf_on_disk = ""
else:
conf_on_disk = ""
# current expected config
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,
}
+655
View File
@@ -0,0 +1,655 @@
"""
firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance.
Wraps firewall-cmd CLI via sudo, manages zones, rules, masquerade/NAT,
and port-forwarding. All mutations are --permanent followed by --reload.
A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the
Flask UI can inspect or restore previous configurations.
"""
import json
import os
import subprocess
from datetime import UTC
from typing import Any
DATA_DIR: str = "/home/wall/vacuum-wall/data/firewall"
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _run(cmd: list[str], check: bool = True) -> str:
"""Run a command via subprocess and return its stdout.
Callers must include ``"sudo"`` as the first argument when the
command requires elevated privileges.
Raises:
RuntimeError: When ``check=True`` and the process exits non-zero.
"""
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
return result.stdout.strip()
def _reload() -> None:
"""Reload firewalld so permanent changes take effect immediately."""
_run(["sudo", "firewall-cmd", "--reload"])
def _ensure_data_dir() -> None:
"""Create the data directory tree if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
# ---------------------------------------------------------------------------
# Read-only queries
# ---------------------------------------------------------------------------
def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names."""
output = _run(["sudo", "firewall-cmd", "--get-zones"])
return output.split()
def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces.
Example return value::
{
"public": ["eth0"],
"internal": ["eth1"],
}
"""
output = _run(["sudo", "firewall-cmd", "--get-active-zones"])
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
stripped = raw_line.strip()
if not stripped:
continue
# Indented lines belong to the current zone section.
if raw_line.startswith(" "):
current_ifaces = (
zones[current_zone]
if current_zone
else zones.get(list(zones.keys())[-1], [])
)
for piece in stripped.split():
if current_zone and piece not in current_ifaces:
current_ifaces.append(piece)
else:
current_zone = stripped
zones[current_zone] = []
return zones
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*.
Keys in the returned dict include:
``name``, ``target``, ``interfaces``, ``sources``, ``services``,
``ports``, ``protocols``, ``forward-ports``, ``masquerade``,
``rich-rules``, ``ics``, ``icmp-blocks``, ``module``.
"""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if not value:
# Lines like "interfaces: " or "masquerade: " when disabled
if key in ("masquerade", "ics"):
info[key] = False
else:
info[key] = []
else:
if key in (
"interfaces",
"sources",
"services",
"ports",
"protocols",
"icmp-blocks",
"module",
):
info[key] = value.split()
elif key == "forward-ports":
info[key] = _parse_forward_ports(value)
elif key in ("masquerade", "ics"):
info[key] = value.lower() == "yes"
elif key == "rich-rules":
# rich-rules can span multiple lines; we'll parse below.
info[key] = [value] if value else []
else:
info[key] = value
# rich-rules may already have been set; if not, default to empty.
info.setdefault("rich-rules", [])
info.setdefault("interfaces", [])
info.setdefault("sources", [])
info.setdefault("services", [])
info.setdefault("ports", [])
info.setdefault("protocols", [])
info.setdefault("forward-ports", [])
info.setdefault("masquerade", False)
info.setdefault("ics", False)
info.setdefault("icmp-blocks", [])
info.setdefault("module", [])
info.setdefault("target", "default")
return info
def get_services() -> list[str]:
"""Return the list of available service names known to firewalld."""
output = _run(["sudo", "firewall-cmd", "--get-services"])
return output.split()
def get_icmp_blocks() -> list[str]:
"""Return the list of available ICMP block names."""
output = _run(["sudo", "firewall-cmd", "--get-icmptypes"])
return output.split()
def get_interfaces() -> list[str]:
"""Return the list of network interfaces visible via iproute2."""
output = _run(["ip", "-o", "link", "show"])
ifaces: list[str] = []
for line in output.splitlines():
if line:
# Format: "NUM: NAME: <FLAGS> ..."
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(":")
ifaces.append(name)
return ifaces
def get_rich_rules(zone: str) -> list[str]:
"""Return the rich rules defined for *zone* as a list of raw strings."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-rich-rules"])
output = output.strip()
if not output:
return []
rules: list[str] = []
current: list[str] = []
for line in output.splitlines():
raw = line.rstrip()
if not raw.endswith(";"):
current.append(raw)
else:
current.append(raw)
rules.append(" ".join(current))
current = []
if current:
rules.append(" ".join(current))
return rules
# ---------------------------------------------------------------------------
# Zone CRUD
# ---------------------------------------------------------------------------
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld.
Args:
zone: Name of the zone to create.
Raises:
RuntimeError: If the zone already exists or creation fails.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
]
)
_reload()
def delete_zone(zone: str) -> None:
"""Delete an existing zone.
Raises:
RuntimeError: If the zone does not exist or the deletion fails.
"""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
_reload()
# ---------------------------------------------------------------------------
# Interface assignment
# ---------------------------------------------------------------------------
def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
"""Assign *interfaces* to *zone*, replacing any existing assignments.
Existing interfaces on the zone are removed first so only the
provided list remains.
"""
# Remove current permanent interfaces for this zone.
try:
current = get_zone_info(zone).get("interfaces", [])
except Exception:
current = []
for iface in current:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
check=False,
)
# Add the desired set.
for iface in interfaces:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
)
_reload()
def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
)
_reload()
def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------
def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services."""
# Remove all current services.
current = get_zone_info(zone).get("services", [])
for svc in current:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
check=False,
)
for svc in services:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
]
)
_reload()
def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
]
)
_reload()
def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Rich rules
# ---------------------------------------------------------------------------
def add_rich_rule(zone: str, rule: str) -> None:
"""Add a rich rule to *zone*.
The *rule* argument should be a fully-formed rich-rule expression,
e.g. ``rule family="ipv4" port protocol="tcp" port="443" accept``.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
]
)
_reload()
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*.
The rule string must match exactly what was added.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Masquerade (NAT)
# ---------------------------------------------------------------------------
def set_masquerade(zone: str, enable: bool) -> None:
"""Enable or disable masquerade (source-NAT) on *zone*."""
action = "--add-masquerade" if enable else "--remove-masquerade"
_run(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"])
_reload()
# ---------------------------------------------------------------------------
# Port forwarding
# ---------------------------------------------------------------------------
def add_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Add a port forwarding rule to *zone*.
Forward traffic arriving on ``port/protocol`` to
``toaddr:toport`` (or just ``toport`` when *toaddr* is omitted).
"""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
elif toport:
fwd += f"/toport={toport}"
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
]
)
_reload()
def remove_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Remove a previously added port-forwarding rule from *zone*.
All parameters must match the original rule exactly.
"""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
elif toport:
fwd += f"/toport={toport}"
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
def _parse_forward_ports(value: str) -> list[str]:
"""Parse the 'forward-ports' line into individual forward-port specifiers.
Multiple entries are space-separated; each looks like
``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``.
"""
return value.split() if value else []
# ---------------------------------------------------------------------------
# State snapshot / backup helpers
# ---------------------------------------------------------------------------
def get_state() -> dict[str, Any]:
"""Return the complete current state of firewalld as a Python dict.
The dict contains all zones with their per-zone configuration, all
rich rules, masquerade settings, forward-port rules, and the set of
active interfaces.
"""
zones: dict[str, dict[str, Any]] = {}
for name in get_available_zones():
try:
zones[name] = get_zone_info(name)
except Exception:
continue
return {
"active_zones": get_active_zones(),
"interfaces": get_interfaces(),
"available_services": get_services(),
"zones": zones,
"rich_rules": {name: get_rich_rules(name) for name in zones},
"timestamp": _now_iso(),
}
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string."""
from datetime import datetime
return datetime.now(UTC).isoformat()
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk.
Returns:
Absolute path to the written file.
"""
_ensure_data_dir()
state = get_state()
with open(RULES_FILE, "w") as fh:
json.dump(state, fh, indent=2, default=str)
return RULES_FILE
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict.
Use :func:`restore_backup` to actually apply the loaded state.
Raises:
FileNotFoundError: When no backup file exists at RULES_FILE.
json.JSONDecodeError: When the file is not valid JSON.
Returns:
The loaded state dict.
"""
with open(RULES_FILE) as fh:
state: dict[str, Any] = json.load(fh)
return state
def restore_backup(state: dict[str, Any]) -> None:
"""Apply the zone configuration described in *state*.
Walks every zone in *state*["zones"] and re-creates services,
interfaces, forward ports, masquerade, and rich rules.
This is a *merge*: zones not present in the snapshot are **not**
touched.
"""
zones_cfg = state.get("zones", {})
for zone_name, zinfo in zones_cfg.items():
# Ensure the zone exists.
if zone_name not in get_available_zones():
target = zinfo.get("target", "default")
create_zone(zone_name, target)
# Services
services = zinfo.get("services", [])
set_zone_services(zone_name, services)
# Interfaces
interfaces = zinfo.get("interfaces", [])
set_zone_interfaces(zone_name, interfaces)
# Masquerade
if zinfo.get("masquerade"):
set_masquerade(zone_name, True)
# Forward ports (stored as raw strings in zinfo)
for fp in zinfo.get("forward-ports", []):
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp}",
"--permanent",
],
check=False,
)
# Rich rules
for rule in zinfo.get("rich-rules", []):
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
check=False,
)
_reload()
__all__ = [
"DATA_DIR",
"RULES_FILE",
"_reload",
"_run",
"add_forward_port",
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"create_zone",
"delete_zone",
"get_active_zones",
"get_available_zones",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
"get_services",
"get_state",
"get_zone_info",
"load_backup",
"remove_forward_port",
"remove_rich_rule",
"remove_zone_interface",
"remove_zone_service",
"restore_backup",
"save_backup",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",
]
+365
View File
@@ -0,0 +1,365 @@
"""
Nginx server-block generator for Vacuum Wall SSL proxy firewall.
Manages per-domain SSL reverse proxy configurations, certificate
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import json
import os
import subprocess
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
PROJECT_DIR = Path("/home/wall/vacuum-wall")
DATA_DIR = PROJECT_DIR / "data" / "nginx"
SITES_DIR = DATA_DIR / "sites-enabled"
CONFIG_FILE = DATA_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 = {
"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 = {
"domains": {},
"management": None,
"ssl": DEFAULT_SSL.copy(),
}
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _ensure_dirs():
SITES_DIR.mkdir(parents=True, exist_ok=True)
def _run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, check=False, **kw)
def _json_load(path):
_ensure_dirs()
if not path.exists():
return DEFAULT_CONFIG.copy()
with open(path) as f:
data = json.load(f)
if "ssl" not in data:
data["ssl"] = DEFAULT_SSL.copy()
return data
def _json_dump(path, data):
_ensure_dirs()
tmp = path.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=4)
f.write("\n")
os.replace(tmp, path)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_config() -> dict:
return _json_load(CONFIG_FILE)
def save_config(cfg: dict) -> None:
_json_dump(CONFIG_FILE, cfg)
def get_domains() -> list[dict]:
cfg = get_config()
result = []
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
# ------------------------------------------------------------------
# Domain CRUD
# ------------------------------------------------------------------
def add_domain(
domain,
backend_host,
backend_port,
backend_proto="http",
cert=None,
extra_headers=None,
) -> None:
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
entry = {
"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)
def remove_domain(domain) -> None:
cfg = get_config()
cfg["domains"].pop(domain, None)
save_config(cfg)
site = SITES_DIR / f"{domain}.conf"
if site.exists():
site.unlink()
def update_domain(domain, **kwargs) -> None:
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
for key, val in kwargs.items():
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
entry[key] = val
save_config(cfg)
# ------------------------------------------------------------------
# Nginx config generation
# ------------------------------------------------------------------
def generate_server_conf(domain_cfg: dict) -> 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,
)
def _generate_management_conf(management: dict) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
return tmpl.render(
domain=management.get("domain", "wall.lan"),
backend=dict(
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
),
headers={},
force_ssl=True,
cert=None,
auth=management.get("auth"),
is_management=True,
)
# ------------------------------------------------------------------
# File writers
# ------------------------------------------------------------------
def write_site(domain, conf_text) -> None:
_ensure_dirs()
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_all_sites() -> None:
_ensure_dirs()
cfg = get_config()
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written = 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_conf = _generate_management_conf(cfg["management"])
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()
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)
subprocess.run(["sudo", "cp", str(tmp), INCLUDE_FILE], check=True)
subprocess.run(["sudo", "chown", "root:root", INCLUDE_FILE], check=True)
tmp.unlink(missing_ok=True)
def write_ssl_snippet() -> None:
cfg = get_config()
ssl_cfg = cfg.get("ssl", DEFAULT_SSL.copy())
ssl_cfg.setdefault("prefer_server_ciphers", False)
ssl_cfg.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)
subprocess.run(["sudo", "cp", str(tmp), SSL_SNIPPET], check=True)
subprocess.run(["sudo", "chown", "root:root", SSL_SNIPPET], check=True)
tmp.unlink(missing_ok=True)
# ------------------------------------------------------------------
# nginx lifecycle
# ------------------------------------------------------------------
def test_config() -> tuple[bool, str]:
result = _run(["sudo", "nginx", "-t"])
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 apply() -> None:
write_ssl_snippet()
write_all_sites()
write_include_file()
ok, msg = test_config()
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"])
# ------------------------------------------------------------------
# Management WebUI
# ------------------------------------------------------------------
def set_management_proxy(
domain, flask_host="127.0.0.1", flask_port=9090, auth_user=None, auth_pass=None
) -> None:
cfg = get_config()
entry = {
"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)
# ------------------------------------------------------------------
# htpasswd
# ------------------------------------------------------------------
def write_htpasswd(user, password) -> None:
"""
Append (or create) an htpasswd entry for *user*.
Uses passlib's apache_passwd hash so the file remains portable.
If passlib is unavailable falls back to Python's built-in crypt.
If the user already exists the line is replaced in-place.
"""
_ensure_dirs()
hashed = _hash_password(password)
existing = {}
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)
def _hash_password(password):
try:
from passlib.hash import apache_passwd
return apache_passwd.using(rounds=12).hash(password)
except Exception:
import crypt as _crypt
salt = os.urandom(16).hex()[:16]
return _crypt.crypt(password, f"$5${salt}")
+511
View File
@@ -0,0 +1,511 @@
"""
WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface.
"""
import json
import os
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
PROJECT_DIR = Path("/home/wall/vacuum-wall")
CONFIG_PATH = str(PROJECT_DIR / "data" / "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,
)
# --- 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": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
# --- Core config persistence ---
def get_config() -> dict:
"""Load the current WireGuard configuration from the JSON store.
Returns the full config dict. If the file does not exist or is
unreadable, returns the default (empty) config skeleton.
"""
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
# Backfill keys that might be missing from older snapshots.
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:
"""Persist *cfg* to the JSON store atomically.
Writes to a temporary file in the same directory and then renames
to avoid partial reads on crash.
"""
_ensure_dir(CONFIG_PATH)
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 ---
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI.
Returns:
``(private_key, public_key)`` as two 43-character base64 strings.
"""
res = _run([WG_BIN, "genkey"])
private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key)
public_key = res2.stdout.strip()
return private_key, public_key
# --- wg0.conf generation ---
def generate_conf(cfg: dict) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
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", {}),
)
# --- Apply / down ---
def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick."""
cfg = get_config()
conf_text = generate_conf(cfg)
save_config(cfg) # ensure latest state persisted
local_dir = Path("/home/wall/vacuum-wall/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])
_run(["chown", "root:root", WG_CONF_PATH], check=False)
local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
def down() -> None:
"""Bring the WireGuard tunnel interface down."""
cfg = get_config()
name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name])
# --- Status ---
def status() -> dict:
"""Query the live tunnel state via ``wg show``.
Returns a dict with keys:
- ``up`` (bool) - whether the interface is currently up.
- ``interface`` (dict) - name, public key, listen port, fwmark.
- ``peers`` (list[dict]) - per-peer status from ``wg show wg0``.
"""
cfg = get_config()
name = cfg["interface"]["name"]
result = {
"up": False,
"interface": {},
"peers": [],
}
try:
proc = _run([WG_BIN, "show", name], check=False)
if proc.returncode != 0:
return result
raw = proc.stdout.strip()
except Exception:
return result
# Parse the wg show output.
# Format (multi-section, separated by blank lines or interleaved):
# interface:
# public key: ...
# listening port: ...
# peer: <key>
# endpoint: ...
# allowed ips: ...
# latest handshake: ...
# transfer: ...
# persistent-keepalive: ...
current_peer = None
peers: list[dict] = []
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:"):
val = line.split(":", 1)[1].strip()
result["interface"]["listen_port"] = int(val)
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()
continue
if line.startswith("allowed ips:"):
vals = line.split(":", 1)[1].strip().split(", ")
current_peer["allowed_ips"] = vals
continue
if line.startswith("latest handshake:"):
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
continue
if line.startswith("transfer:"):
rest = line.split(":", 1)[1].strip()
parts = rest.split(", ")
if parts:
current_peer["transfer_received"] = parts[0].strip()
if len(parts) > 1:
current_peer["transfer_sent"] = parts[1].strip()
continue
if line.startswith("persistent-keepalive:"):
val = line.split(":", 1)[1].strip()
try:
current_peer["persistent_keepalive"] = int(val)
except ValueError:
current_peer["persistent_keepalive"] = None
result["peers"] = peers
return result
# --- Peer management ---
def add_peer(
name: str,
endpoint: str | None = None,
allowed_ips: list[str] | None = None,
persistent_keepalive: int | None = None,
preshared_key: str | None = None,
) -> dict:
"""Add (or update) a peer in the configuration.
If the peer has no public key yet, one will be generated
together with a matching private key (useful for client provi-
sioning). The returned dict mirrors the stored peer record
with an additional ``private_key`` field so the caller can
distribute the client credentials.
Args:
name: Human-readable identifier (dict key in config).
endpoint: e.g. ``203.0.113.1:51820``.
allowed_ips: CIDR list, e.g. ``["0.0.0.0/0"]``.
persistent_keepalive: Interval in seconds (or ``None``).
preshared_key: Optional PSK (base64 string).
Returns:
The peer dict as stored, plus ``private_key`` for client use.
"""
cfg = get_config()
peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
if name in peers:
peer = peers[name]
peer["endpoint"] = endpoint
peer["allowed_ips"] = allowed_ips
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
else:
# Generate a key pair for the new peer.
priv, pub = generate_keypair()
peer = {
"public_key": pub,
"private_key": priv, # stored so we can hand it to the client
"endpoint": endpoint,
"allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive,
"preshared_key": preshared_key,
}
peers[name] = peer
save_config(cfg)
# Return a copy that includes the private key (safe — used for provisioning).
peer_out = dict(peer)
return peer_out
def remove_peer(name: str) -> None:
"""Remove a peer from the configuration by name."""
cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg)
def get_peers() -> list[dict]:
"""List all configured peers (from the JSON store, *not* live).
Returns a list of dicts. Each dict includes ``name`` and all
stored fields **except** ``private_key`` (not exposed here).
"""
cfg = get_config()
peers = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
# Strip private key from the public listing.
entry.pop("private_key", None)
peers.append(entry)
return peers
def get_peer_status() -> list[dict]:
"""Return live peer status from ``wg show``.
Each element contains:
- ``public_key``, ``endpoint``, ``allowed_ips``,
``latest_handshake``, ``transfer_received``,
``transfer_sent``, ``persistent_keepalive``.
"""
st = status()
return st.get("peers", [])
# --- Client config generation ---
def generate_client_conf(
peer_name: str,
server_endpoint: str,
server_pubkey: str,
) -> str:
"""Build a client-side wg-quick config snippet for *peer_name*."""
cfg = get_config()
iface = cfg["interface"]
peer = cfg["peers"].get(peer_name)
if peer is None:
raise KeyError(f"Peer '{peer_name}' not found in configuration")
client_priv = peer.get("private_key", "")
if not client_priv:
raise ValueError(
f"Peer '{peer_name}' has no private key — cannot generate client config."
)
sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(peer_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")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=server_pubkey,
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"),
)
# --- Interface-level setters ---
def set_listen_port(port: int) -> None:
"""Update the server listen port in the stored configuration.
Does **not** hot-reload; call :func:`apply` afterwards to
activate the change.
"""
if not (1 <= port <= 65535):
raise ValueError("Listen port must be in range 1..65535")
cfg = get_config()
cfg["interface"]["listen_port"] = port
save_config(cfg)
def set_post_up(cmd: str | None) -> None:
"""Set (or clear) the PostUp hook command.
The command is passed verbatim to the generated wg0.conf.
"""
cfg = get_config()
cfg["interface"]["post_up"] = cmd
save_config(cfg)
def set_post_down(cmd: str | None) -> None:
"""Set (or clear) the PostDown hook command."""
cfg = get_config()
cfg["interface"]["post_down"] = cmd
save_config(cfg)
# --- Initialise ---
def initialize() -> dict:
"""Perform first-time WireGuard setup.
Generates a fresh server key pair, writes the initial config
to disk, and returns the full config dict.
Call this once at appliance bootstrapping time. It will
**not** overwrite an existing config that already has a
non-empty private key.
"""
cfg = get_config()
if cfg["interface"].get("private_key"):
# Already initialised — return existing config.
return cfg
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
save_config(cfg)
return cfg
# --- Utility: parse wg show into structured peer map ---
def _parse_wg_show(output: str) -> dict:
"""Internal parser for ``wg show`` multiline output.
Returns a dict keyed by peer public key with parsed values.
Used internally; ``status()`` is the public interface.
"""
peers: dict = {}
current = None
for line in output.splitlines():
line = line.strip()
if line.startswith("peer:"):
key = line.split(":", 1)[1].strip()
current = {"_key": key}
peers[key] = current
continue
if current is None:
continue
if line.startswith("endpoint:"):
val = line.split(":", 1)[1].strip()
current["endpoint"] = val
elif line.startswith("allowed ips:"):
current["allowed_ips"] = line.split(":", 1)[1].strip()
elif line.startswith("latest handshake:"):
current["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
current["transfer_raw"] = line.split(":", 1)[1].strip()
elif line.startswith("persistent-keepalive:"):
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
return peers