fix htmx refactor route mismatches and remaining TODO items

- wireguard: POST /peers with JSON encoding (was /add-peer)
- rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render
- nat: port forward delete uses URL path params to match blueprint
- nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch)
- app.js renderers updated to use URL path deletes for rules and forwards
- remove TODO.md
This commit is contained in:
2026-05-17 01:15:52 +00:00
parent 0e7090a2cb
commit 37039351be
26 changed files with 1737 additions and 848 deletions
+26 -95
View File
@@ -6,6 +6,7 @@ the WireGuard tunnel interface.
"""
import json
import logging
import os
import subprocess
from datetime import UTC, datetime
@@ -13,6 +14,8 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json")
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
@@ -65,15 +68,10 @@ def _default_config() -> dict:
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.
"""
"""Load the current WireGuard configuration from the JSON store."""
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"])
@@ -90,11 +88,7 @@ def get_config() -> dict:
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.
"""
"""Persist *cfg* to the JSON store atomically."""
_ensure_dir(CONFIG_PATH)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
@@ -107,11 +101,7 @@ def save_config(cfg: dict) -> None:
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.
"""
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = _run([WG_BIN, "genkey"])
private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key)
@@ -139,7 +129,7 @@ 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
save_config(cfg)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
@@ -152,6 +142,7 @@ def apply() -> None:
local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
def down() -> None:
@@ -159,19 +150,14 @@ def down() -> None:
cfg = get_config()
name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name])
logger.info("WireGuard tunnel '%s' brought 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``.
"""
"""Query the live tunnel state via ``wg show``."""
cfg = get_config()
name = cfg["interface"]["name"]
result = {
@@ -189,17 +175,6 @@ def status() -> dict:
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] = []
@@ -287,24 +262,7 @@ def add_peer(
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.
"""
"""Add (or update) a peer in the configuration."""
cfg = get_config()
peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
@@ -316,22 +274,21 @@ def add_peer(
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
logger.info("WireGuard peer '%s' updated", name)
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
"private_key": priv,
"endpoint": endpoint,
"allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive,
"preshared_key": preshared_key,
}
peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
save_config(cfg)
# Return a copy that includes the private key (safe — used for provisioning).
peer_out = dict(peer)
return peer_out
@@ -341,33 +298,23 @@ def remove_peer(name: str) -> None:
cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
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).
"""
"""List all configured peers (from the JSON store, *not* live)."""
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``.
"""
"""Return live peer status from ``wg show``."""
st = status()
return st.get("peers", [])
@@ -401,7 +348,7 @@ def generate_client_conf(
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf")
return tmpl.render(
conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name,
client_priv=client_priv,
@@ -412,29 +359,25 @@ def generate_client_conf(
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
)
logger.info("Client config generated for peer '%s'", peer_name)
return conf
# --- 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.
"""
"""Update the server listen port in the stored configuration."""
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)
logger.info("WireGuard listen port set to %d", port)
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.
"""
"""Set (or clear) the PostUp hook command."""
cfg = get_config()
cfg["interface"]["post_up"] = cmd
save_config(cfg)
@@ -451,25 +394,17 @@ def set_post_down(cmd: str | None) -> None:
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.
"""
"""Perform first-time WireGuard setup."""
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)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg
@@ -477,11 +412,7 @@ def initialize() -> dict:
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.
"""
"""Internal parser for ``wg show`` multiline output."""
peers: dict = {}
current = None