From 92554d4089303f0fa2207d0d8b833b6af79824ba Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Mon, 30 Mar 2026 22:57:00 -0500 Subject: [PATCH] fix: make keypair generation async to avoid blocking the event loop generate_keypair() used synchronous subprocess.run() which blocked the NiceGUI event loop during wg genkey/pubkey calls. This caused WebSocket disconnects, page reloads, and the config dialog never appearing after device creation. Switched to asyncio.create_subprocess_exec so the event loop stays responsive while waiting for the wg CLI. --- tests/e2e/test_devices.py | 4 ++-- tests/test_utils.py | 4 ++-- wiregui/api/v0/devices.py | 2 +- wiregui/auth/seed.py | 2 +- wiregui/pages/admin/devices.py | 2 +- wiregui/pages/devices.py | 2 +- wiregui/utils/crypto.py | 37 +++++++++++++++++++++------------- 7 files changed, 31 insertions(+), 22 deletions(-) diff --git a/tests/e2e/test_devices.py b/tests/e2e/test_devices.py index 4e23cbe..3f00773 100644 --- a/tests/e2e/test_devices.py +++ b/tests/e2e/test_devices.py @@ -1,6 +1,6 @@ """End-to-end tests for device management UI using NiceGUI's User fixture.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from nicegui.testing import User @@ -25,7 +25,7 @@ async def _login(user: User): @pytest.mark.parametrize("user", [{"storage": {}}], indirect=True) async def test_add_device_via_ui(user: User, test_user: UserModel): """Test the full flow: login → devices → add device → see it in table.""" - with patch("wiregui.pages.devices.generate_keypair", return_value=(FAKE_PRIVATE_KEY, FAKE_PUBLIC_KEY)), \ + with patch("wiregui.pages.devices.generate_keypair", new_callable=AsyncMock, return_value=(FAKE_PRIVATE_KEY, FAKE_PUBLIC_KEY)), \ patch("wiregui.pages.devices.generate_preshared_key", return_value="cHJlc2hhcmVkMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="): await _login(user) diff --git a/tests/test_utils.py b/tests/test_utils.py index 1b6a55d..e73b0e3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -103,7 +103,7 @@ def test_build_client_config_no_psk(): # --- Crypto (only if wg is installed) --- -def test_generate_keypair(): +async def test_generate_keypair(): """Test keypair generation — requires `wg` CLI to be installed.""" try: subprocess.run(["wg", "--version"], capture_output=True, check=True) @@ -112,7 +112,7 @@ def test_generate_keypair(): from wiregui.utils.crypto import generate_keypair, generate_preshared_key - priv, pub = generate_keypair() + priv, pub = await generate_keypair() assert len(priv) == 44 # base64-encoded 32 bytes assert len(pub) == 44 diff --git a/wiregui/api/v0/devices.py b/wiregui/api/v0/devices.py index 0a6c751..288ffdf 100644 --- a/wiregui/api/v0/devices.py +++ b/wiregui/api/v0/devices.py @@ -54,7 +54,7 @@ async def create_device( settings = get_settings() owner_id = body.user_id if (body.user_id and current_user.role == "admin") else current_user.id - _private_key, public_key = generate_keypair() + _private_key, public_key = await generate_keypair() psk = generate_preshared_key() ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network) ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network) diff --git a/wiregui/auth/seed.py b/wiregui/auth/seed.py index 2d0e1a1..962e5f6 100644 --- a/wiregui/auth/seed.py +++ b/wiregui/auth/seed.py @@ -51,7 +51,7 @@ async def ensure_server_keypair() -> None: return # already have keys try: - private_key, public_key = generate_keypair() + private_key, public_key = await generate_keypair() config.server_private_key = private_key config.server_public_key = public_key session.add(config) diff --git a/wiregui/pages/admin/devices.py b/wiregui/pages/admin/devices.py index 3d595ed..59ee0ed 100644 --- a/wiregui/pages/admin/devices.py +++ b/wiregui/pages/admin/devices.py @@ -88,7 +88,7 @@ async def admin_devices_page(): try: settings = get_settings() - private_key, public_key = generate_keypair() + private_key, public_key = await generate_keypair() psk = generate_preshared_key() async with async_session() as session: diff --git a/wiregui/pages/devices.py b/wiregui/pages/devices.py index a26fd03..63faf37 100644 --- a/wiregui/pages/devices.py +++ b/wiregui/pages/devices.py @@ -72,7 +72,7 @@ async def devices_page(): try: settings = get_settings() - private_key, public_key = generate_keypair() + private_key, public_key = await generate_keypair() psk = generate_preshared_key() async with async_session() as session: diff --git a/wiregui/utils/crypto.py b/wiregui/utils/crypto.py index f9d14ef..2f3cf0b 100644 --- a/wiregui/utils/crypto.py +++ b/wiregui/utils/crypto.py @@ -1,28 +1,37 @@ """WireGuard key generation and encryption utilities.""" +import asyncio import base64 import os -import subprocess -def generate_private_key() -> str: +async def generate_private_key() -> str: """Generate a WireGuard private key using `wg genkey`.""" - result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True) - return result.stdout.strip() - - -def derive_public_key(private_key: str) -> str: - """Derive a WireGuard public key from a private key using `wg pubkey`.""" - result = subprocess.run( - ["wg", "pubkey"], input=private_key, capture_output=True, text=True, check=True + proc = await asyncio.create_subprocess_exec( + "wg", "genkey", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - return result.stdout.strip() + stdout, _ = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError("wg genkey failed") + return stdout.decode().strip() -def generate_keypair() -> tuple[str, str]: +async def derive_public_key(private_key: str) -> str: + """Derive a WireGuard public key from a private key using `wg pubkey`.""" + proc = await asyncio.create_subprocess_exec( + "wg", "pubkey", stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + 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]: """Generate a WireGuard keypair. Returns (private_key, public_key).""" - private_key = generate_private_key() - public_key = derive_public_key(private_key) + private_key = await generate_private_key() + public_key = await derive_public_key(private_key) return private_key, public_key