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:
+510
@@ -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
@@ -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
@@ -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
@@ -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}")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user