fix: pure Python keypair generation, no wg CLI dependency
Some checks failed
CI / test (push) Successful in 2m5s
CI / release (push) Successful in 34s
CI / docker (push) Has been cancelled

Replace subprocess calls to wg genkey/pubkey with cryptography
library's X25519PrivateKey. This eliminates the wg CLI dependency
for key generation, fixes device creation on machines without
wireguard-tools, and removes the event loop blocking that caused
WebSocket disconnects during device creation.

Also fix E2E test teardown to use a fresh engine for cleanup,
avoiding cross-event-loop issues with asyncpg connection pools.
This commit is contained in:
Stefano Bertelli 2026-03-30 23:11:58 -05:00
parent 92554d4089
commit 41a62832f7
8 changed files with 62 additions and 71 deletions

View file

@ -1,9 +1,12 @@
"""E2E test configuration — loads NiceGUI testing plugin and app.""" """E2E test configuration — loads NiceGUI testing plugin and app."""
import pytest import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import select from sqlmodel import select
from wiregui.auth.passwords import hash_password from wiregui.auth.passwords import hash_password
from wiregui.config import get_settings
from wiregui.db import async_session from wiregui.db import async_session
from wiregui.models.configuration import Configuration from wiregui.models.configuration import Configuration
from wiregui.models.user import User from wiregui.models.user import User
@ -14,24 +17,32 @@ FAKE_SERVER_KEY = "SFake0ServerPubKey0000000000000000000000000w="
TEST_EMAIL = "e2e-test@example.com" TEST_EMAIL = "e2e-test@example.com"
TEST_PASSWORD = "testpass123" TEST_PASSWORD = "testpass123"
async def _delete_user_cascade(session, user_id): _CHILD_TABLES = ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections")
"""Delete a user and all related objects via raw SQL to avoid stale ORM cache issues."""
from sqlalchemy import text
for table in ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections"): async def _cleanup_test_user():
await session.execute(text(f"DELETE FROM {table} WHERE user_id = :uid"), {"uid": user_id}) # noqa: S608 """Delete the test user and all related objects using a fresh engine."""
await session.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": user_id}) engine = create_async_engine(get_settings().database_url)
async with engine.begin() as conn:
# Find user id by email
row = (await conn.execute(
text("SELECT id FROM users WHERE email = :email"), {"email": TEST_EMAIL}
)).first()
if row:
uid = row[0]
for table in _CHILD_TABLES:
await conn.execute(text(f"DELETE FROM {table} WHERE user_id = :uid"), {"uid": uid}) # noqa: S608
await conn.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": uid})
await engine.dispose()
@pytest.fixture @pytest.fixture
async def test_user(): async def test_user():
"""Create a test user and ensure server config has a public key.""" """Create a test user and ensure server config has a public key."""
async with async_session() as session: # Clean up any leftover from a previous failed run
# Clean up any leftover from a previous failed run await _cleanup_test_user()
existing = (await session.execute(select(User).where(User.email == TEST_EMAIL))).scalar_one_or_none()
if existing:
await _delete_user_cascade(session, existing.id)
await session.commit()
async with async_session() as session:
# Ensure a Configuration with a server key exists # Ensure a Configuration with a server key exists
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none() config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
if config: if config:
@ -53,7 +64,4 @@ async def test_user():
yield user yield user
# Teardown await _cleanup_test_user()
async with async_session() as session:
await _delete_user_cascade(session, user.id)
await session.commit()

View file

@ -1,17 +1,11 @@
"""End-to-end tests for device management UI using NiceGUI's User fixture.""" """End-to-end tests for device management UI using NiceGUI's User fixture."""
from unittest.mock import AsyncMock, patch
import pytest import pytest
from nicegui.testing import User from nicegui.testing import User
from wiregui.models.user import User as UserModel from wiregui.models.user import User as UserModel
from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
# Fake WG keys for testing (valid base64, 32 bytes)
FAKE_PRIVATE_KEY = "YFake0PrivateKey00000000000000000000000000w="
FAKE_PUBLIC_KEY = "ZFake0PublicKey000000000000000000000000000w="
async def _login(user: User): async def _login(user: User):
"""Helper to log in via the UI.""" """Helper to log in via the UI."""
@ -25,21 +19,18 @@ async def _login(user: User):
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) @pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
async def test_add_device_via_ui(user: User, test_user: UserModel): async def test_add_device_via_ui(user: User, test_user: UserModel):
"""Test the full flow: login → devices → add device → see it in table.""" """Test the full flow: login → devices → add device → see it in table."""
with patch("wiregui.pages.devices.generate_keypair", new_callable=AsyncMock, return_value=(FAKE_PRIVATE_KEY, FAKE_PUBLIC_KEY)), \ await _login(user)
patch("wiregui.pages.devices.generate_preshared_key", return_value="cHJlc2hhcmVkMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="):
await _login(user) # Open create dialog
user.find("Add Device").click()
await user.should_see("New Device")
# Open create dialog # Fill device name and submit
user.find("Add Device").click() user.find("Device Name").type("Test Laptop")
await user.should_see("New Device") user.find("Create").click()
# Fill device name and submit # Should see config dialog with the device config
user.find("Device Name").type("Test Laptop") await user.should_see("Test Laptop")
user.find("Create").click()
# Should see config dialog with the device config
await user.should_see("Test Laptop")
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) @pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)

View file

@ -103,18 +103,16 @@ def test_build_client_config_no_psk():
# --- Crypto (only if wg is installed) --- # --- Crypto (only if wg is installed) ---
async def test_generate_keypair(): def test_generate_keypair():
"""Test keypair generation — requires `wg` CLI to be installed.""" """Test keypair generation (pure Python, no wg CLI needed)."""
try:
subprocess.run(["wg", "--version"], capture_output=True, check=True)
except FileNotFoundError:
pytest.skip("wg CLI not installed")
from wiregui.utils.crypto import generate_keypair, generate_preshared_key from wiregui.utils.crypto import generate_keypair, generate_preshared_key
priv, pub = await generate_keypair() priv, pub = generate_keypair()
assert len(priv) == 44 # base64-encoded 32 bytes assert len(priv) == 44 # base64-encoded 32 bytes
assert len(pub) == 44 assert len(pub) == 44
psk = generate_preshared_key() psk = generate_preshared_key()
assert len(psk) == 44 assert len(psk) == 44
psk = generate_preshared_key()
assert len(psk) == 44

View file

@ -54,7 +54,7 @@ async def create_device(
settings = get_settings() settings = get_settings()
owner_id = body.user_id if (body.user_id and current_user.role == "admin") else current_user.id owner_id = body.user_id if (body.user_id and current_user.role == "admin") else current_user.id
_private_key, public_key = await generate_keypair() _private_key, public_key = generate_keypair()
psk = generate_preshared_key() psk = generate_preshared_key()
ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network) ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network)
ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network) ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network)

View file

@ -51,7 +51,7 @@ async def ensure_server_keypair() -> None:
return # already have keys return # already have keys
try: try:
private_key, public_key = await generate_keypair() private_key, public_key = generate_keypair()
config.server_private_key = private_key config.server_private_key = private_key
config.server_public_key = public_key config.server_public_key = public_key
session.add(config) session.add(config)

View file

@ -88,7 +88,7 @@ async def admin_devices_page():
try: try:
settings = get_settings() settings = get_settings()
private_key, public_key = await generate_keypair() private_key, public_key = generate_keypair()
psk = generate_preshared_key() psk = generate_preshared_key()
async with async_session() as session: async with async_session() as session:

View file

@ -72,7 +72,7 @@ async def devices_page():
try: try:
settings = get_settings() settings = get_settings()
private_key, public_key = await generate_keypair() private_key, public_key = generate_keypair()
psk = generate_preshared_key() psk = generate_preshared_key()
async with async_session() as session: async with async_session() as session:

View file

@ -1,37 +1,31 @@
"""WireGuard key generation and encryption utilities.""" """WireGuard key generation and encryption utilities — pure Python, no wg CLI needed."""
import asyncio
import base64 import base64
import os import os
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
async def generate_private_key() -> str: from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
"""Generate a WireGuard private key using `wg genkey`."""
proc = await asyncio.create_subprocess_exec(
"wg", "genkey", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError("wg genkey failed")
return stdout.decode().strip()
async def derive_public_key(private_key: str) -> str: def generate_private_key() -> str:
"""Derive a WireGuard public key from a private key using `wg pubkey`.""" """Generate a WireGuard private key (Curve25519, base64-encoded)."""
proc = await asyncio.create_subprocess_exec( key = X25519PrivateKey.generate()
"wg", "pubkey", stdin=asyncio.subprocess.PIPE, raw = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, return base64.b64encode(raw).decode()
)
stdout, _ = await proc.communicate(input=private_key.encode())
if proc.returncode != 0:
raise RuntimeError("wg pubkey failed")
return stdout.decode().strip()
async def generate_keypair() -> tuple[str, str]: def derive_public_key(private_key: str) -> str:
"""Derive a WireGuard public key from a private key."""
raw = base64.b64decode(private_key)
key = X25519PrivateKey.from_private_bytes(raw)
pub = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64.b64encode(pub).decode()
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard keypair. Returns (private_key, public_key).""" """Generate a WireGuard keypair. Returns (private_key, public_key)."""
private_key = await generate_private_key() private_key = generate_private_key()
public_key = await derive_public_key(private_key) public_key = derive_public_key(private_key)
return private_key, public_key return private_key, public_key