Merge feature/498-nm-keyfile : NetworkManager keyfile export for WG R3 (ref #498)

This commit is contained in:
CyberMind-FR 2026-06-07 08:31:17 +02:00
commit 6812dfb0f6
2 changed files with 108 additions and 0 deletions

View File

@ -1000,6 +1000,49 @@ async def wg_profile_new(request: Request) -> Response:
)
@router.get("/wg/profile/new.nmconnection")
async def wg_profile_new_nmconnection(request: Request) -> Response:
"""Phase 7 (#498) — same fresh WG profile but in NetworkManager
keyfile format. Cosmic / GNOME / KDE's nmcli GUI fail to import
a plain wg-quick .conf because they store private-key as an
"agent-owned" secret (`private-key-flags=1`) and refuse to bring
up the tunnel without --ask.
This endpoint emits a native .nmconnection : drop it into
/etc/NetworkManager/system-connections/, chmod 0600, then
`nmcli connection reload` the profile is ready to click in the
Network panel.
sudo install -m 0600 village3b-toolbox.nmconnection \
/etc/NetworkManager/system-connections/
sudo nmcli connection reload
nmcli connection up village3b-toolbox
"""
try:
from . import wg as _wg
except ImportError:
raise HTTPException(503, "WG module not available (Phase 6 not provisioned)")
profile = _wg.generate_client_profile(client_label=request.headers.get("user-agent", "")[:60])
import hashlib as _h
wg_hash = _h.sha256(profile["client_pubkey"].encode()).hexdigest()[:16]
try:
store.upsert_client(wg_hash, profile["client_ip"], level="r3")
except Exception as e:
log.warning("wg peer upsert failed for %s: %s", wg_hash, e)
return Response(
content=profile["nm_text"],
media_type="text/plain; charset=utf-8",
headers={
"Content-Disposition": "attachment; filename=village3b-toolbox.nmconnection",
"X-Client-PubKey": profile["client_pubkey"],
"X-Client-IP": profile["client_ip"],
"X-Client-Hash": wg_hash,
},
)
@router.get("/wg/qr.png")
async def wg_qr(request: Request) -> Response:
"""QR code encoding the WG profile .conf — scannable by the iOS WG app."""

View File

@ -128,6 +128,20 @@ def generate_client_profile(client_label: str | None = None) -> dict:
f"PersistentKeepalive = 25\n"
)
# Phase 7 (#498) — NetworkManager keyfile alongside the standard
# wg-quick .conf. The Cosmic / GNOME Network UI imports .conf files
# via nmcli but stores the private key as an "agent-owned" secret by
# default (private-key-flags=1) — which means nmcli refuses to bring
# the tunnel up without --ask. A native .nmconnection keyfile drops
# straight into /etc/NetworkManager/system-connections/ with the
# right system-owned flag and lets the operator click "Connect".
nm_text = _nm_keyfile(
conn_id=(client_label or f"village3b-{client_ip}"),
priv=priv,
client_ip=client_ip,
server_pub=server_pub,
)
return {
"client_privkey": priv,
"client_pubkey": pub,
@ -136,10 +150,61 @@ def generate_client_profile(client_label: str | None = None) -> dict:
"endpoint": f"{WG_ENDPOINT}:{WG_PORT}",
"dns": "10.99.1.1",
"conf_text": conf_text,
"nm_text": nm_text,
"ca_pem": _ca_pem(),
}
def _nm_keyfile(*, conn_id: str, priv: str, client_ip: str, server_pub: str) -> str:
"""Build a NetworkManager keyfile (.nmconnection) for the same peer.
Dropped into /etc/NetworkManager/system-connections/<conn_id>.nmconnection
(chmod 0600, owner root:root). After `nmcli connection reload` the
profile appears in the Network panel and connects in one click.
private-key-flags=0 means "system-owned" the secret lives in the
file itself, NM doesn't ask the user at connect time. That's the
setting nmcli's default import is missing.
"""
import re as _re
import uuid as _uuid
# netlink iface name : [a-z0-9-_], max 15 chars. Slugify aggressively
# so any User-Agent-derived label still produces a valid name.
safe = _re.sub(r"[^a-z0-9_-]+", "-", conn_id.lower()).strip("-")[:13] or "village3b"
iface = f"wg-{safe}"[:15]
uuid = str(_uuid.uuid4())
return (
"[connection]\n"
f"id={conn_id}\n"
f"uuid={uuid}\n"
"type=wireguard\n"
f"interface-name={iface}\n"
"autoconnect=false\n"
"\n"
"[wireguard]\n"
f"private-key={priv}\n"
"private-key-flags=0\n"
"listen-port=0\n"
"fwmark=0\n"
"\n"
f"[wireguard-peer.{server_pub}]\n"
f"endpoint={WG_ENDPOINT}:{WG_PORT}\n"
"persistent-keepalive=25\n"
"allowed-ips=0.0.0.0/0;::/0;\n"
"\n"
"[ipv4]\n"
f"address1={client_ip}/32\n"
"dns=10.99.1.1;\n"
"method=manual\n"
"\n"
"[ipv6]\n"
"addr-gen-mode=default\n"
"method=disabled\n"
"\n"
"[proxy]\n"
)
def revoke_client(client_pubkey: str) -> bool:
"""Remove a client peer from the WG interface + peer DB."""
peers = _load_peers()