fix: pure Python keypair generation, no wg CLI dependency
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:
parent
92554d4089
commit
41a62832f7
8 changed files with 62 additions and 71 deletions
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue