- Interface model with per-interface subnet/port/keys; import/adopt existing wg-quick configs (key-less imported peers, optional key rotation), cascade delete - Split wireguard.py into a package (keys via cryptography X25519, status, addressing, conf parse/render, sync, host tuning) - ECharts horizontal topology view (interface -> peers -> site subnets) - Advanced options: MTU, MSS clamping, FwMark/Table, custom PostUp/PostDown, per-peer keepalive override - Runtime settings (sample interval/retention, online threshold, UI refresh) with traffic sample pruning; host tuning (UDP buffers, backlog, GRO forwarding) - Precompiled Tailwind CSS replacing Play CDN runtime (fixes FOUC); stable table layout and diffed polling renders - Host network mode in compose; NAT/isolation iptables moved into app sync Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from ..config import settings
|
|
from ..crypto import decrypt
|
|
from ..models import Interface, Peer
|
|
from .addressing import server_address
|
|
|
|
|
|
def config_path(name: str) -> Path:
|
|
return settings.wg_config_dir / f"{name}.conf"
|
|
|
|
|
|
def discover_configs() -> list[str]:
|
|
if not settings.wg_config_dir.exists():
|
|
return []
|
|
return sorted(p.stem for p in settings.wg_config_dir.glob("*.conf"))
|
|
|
|
|
|
def server_allowed_ips(peer: Peer) -> str:
|
|
allowed = peer.address
|
|
if peer.extra_allowed_ips:
|
|
allowed += f", {peer.extra_allowed_ips}"
|
|
return allowed
|
|
|
|
|
|
def render_server_config(iface: Interface, peers: list[Peer]) -> str:
|
|
lines = [
|
|
"[Interface]",
|
|
f"PrivateKey = {decrypt(iface.private_key_enc)}",
|
|
f"Address = {server_address(iface.subnet)}",
|
|
f"ListenPort = {iface.listen_port}",
|
|
]
|
|
if iface.mtu:
|
|
lines.append(f"MTU = {iface.mtu}")
|
|
if iface.fwmark:
|
|
lines.append(f"FwMark = {iface.fwmark}")
|
|
if iface.route_table:
|
|
lines.append(f"Table = {iface.route_table}")
|
|
if iface.mss_clamp:
|
|
rule = (
|
|
"-o %i -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu"
|
|
)
|
|
lines.append(f"PostUp = iptables -t mangle -A FORWARD {rule}")
|
|
lines.append(f"PostDown = iptables -t mangle -D FORWARD {rule} || true")
|
|
# wg-quick adds routes for /32 peer addresses automatically; extra
|
|
# site subnets need explicit routes so return traffic enters the tunnel.
|
|
for peer in peers:
|
|
if peer.enabled and peer.extra_allowed_ips:
|
|
for subnet in peer.extra_allowed_ips.split(","):
|
|
subnet = subnet.strip()
|
|
lines.append(f"PostUp = ip route replace {subnet} dev %i")
|
|
for command in (iface.post_up or "").splitlines():
|
|
command = command.strip()
|
|
if command:
|
|
lines.append(f"PostUp = {command}")
|
|
for command in (iface.post_down or "").splitlines():
|
|
command = command.strip()
|
|
if command:
|
|
lines.append(f"PostDown = {command}")
|
|
for peer in peers:
|
|
if not peer.enabled:
|
|
continue
|
|
lines += [
|
|
"",
|
|
"[Peer]",
|
|
f"# {peer.name}",
|
|
f"PublicKey = {peer.public_key}",
|
|
]
|
|
if peer.preshared_key_enc:
|
|
lines.append(f"PresharedKey = {decrypt(peer.preshared_key_enc)}")
|
|
lines.append(f"AllowedIPs = {server_allowed_ips(peer)}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render_client_config(peer: Peer, iface: Interface) -> str:
|
|
if not peer.has_private_key:
|
|
raise ValueError(
|
|
"Peer has no private key (imported). Rotate keys to generate a config."
|
|
)
|
|
lines = [
|
|
"[Interface]",
|
|
f"PrivateKey = {decrypt(peer.private_key_enc)}",
|
|
f"Address = {peer.address}",
|
|
f"DNS = {peer.dns or iface.dns}",
|
|
]
|
|
if iface.mtu:
|
|
lines.append(f"MTU = {iface.mtu}")
|
|
lines += [
|
|
"",
|
|
"[Peer]",
|
|
f"PublicKey = {iface.public_key}",
|
|
]
|
|
if peer.preshared_key_enc:
|
|
lines.append(f"PresharedKey = {decrypt(peer.preshared_key_enc)}")
|
|
keepalive = (
|
|
peer.persistent_keepalive
|
|
if peer.persistent_keepalive is not None
|
|
else iface.persistent_keepalive
|
|
)
|
|
lines += [
|
|
f"Endpoint = {iface.host}:{iface.listen_port}",
|
|
f"AllowedIPs = {peer.client_allowed_ips or iface.allowed_ips}",
|
|
]
|
|
if keepalive:
|
|
lines.append(f"PersistentKeepalive = {keepalive}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def write_server_config(iface: Interface, peers: list[Peer]) -> None:
|
|
settings.wg_config_dir.mkdir(parents=True, exist_ok=True)
|
|
path = config_path(iface.name)
|
|
path.touch(mode=0o600)
|
|
path.write_text(render_server_config(iface, peers))
|
|
|
|
|
|
def backup_config(name: str) -> Path | None:
|
|
path = config_path(name)
|
|
if not path.exists():
|
|
return None
|
|
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
backup = path.with_name(f"{name}.conf.bak-{stamp}")
|
|
backup.write_bytes(path.read_bytes())
|
|
backup.chmod(0o600)
|
|
return backup
|
|
|
|
|
|
@dataclass
|
|
class ParsedPeer:
|
|
public_key: str = ""
|
|
preshared_key: str = ""
|
|
allowed_ips: list[str] = field(default_factory=list)
|
|
name: str = ""
|
|
|
|
|
|
@dataclass
|
|
class ParsedConfig:
|
|
private_key: str = ""
|
|
address: str = ""
|
|
listen_port: int | None = None
|
|
peers: list[ParsedPeer] = field(default_factory=list)
|
|
|
|
|
|
def parse_config(text: str) -> ParsedConfig:
|
|
parsed = ParsedConfig()
|
|
section = ""
|
|
current: ParsedPeer | None = None
|
|
pending_comment = ""
|
|
for raw in text.splitlines():
|
|
line = raw.strip()
|
|
if not line:
|
|
continue
|
|
if line.startswith("#"):
|
|
comment = line.lstrip("# ").strip()
|
|
if section == "peer" and current is not None and not current.name:
|
|
current.name = comment
|
|
else:
|
|
pending_comment = comment
|
|
continue
|
|
if line.startswith("["):
|
|
section = line.strip("[]").lower()
|
|
if section == "peer":
|
|
current = ParsedPeer(name=pending_comment)
|
|
parsed.peers.append(current)
|
|
pending_comment = ""
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
key = key.strip().lower()
|
|
value = value.strip()
|
|
if section == "interface":
|
|
if key == "privatekey":
|
|
parsed.private_key = value
|
|
elif key == "address":
|
|
parsed.address = value.split(",")[0].strip()
|
|
elif key == "listenport":
|
|
parsed.listen_port = int(value)
|
|
elif section == "peer" and current is not None:
|
|
if key == "publickey":
|
|
current.public_key = value
|
|
elif key == "presharedkey":
|
|
current.preshared_key = value
|
|
elif key == "allowedips":
|
|
current.allowed_ips += [v.strip() for v in value.split(",") if v.strip()]
|
|
parsed.peers = [p for p in parsed.peers if p.public_key]
|
|
return parsed
|