From c5b66349d655268866bbf5c2aea86f703a04894c Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Tue, 31 Mar 2026 18:30:15 -0500 Subject: [PATCH] feat: WireGuard metrics collector + integration test stack Metrics collector (wiregui/collector.py): - Standalone process spawned by web app when WG_METRICS_ENABLED=true - Polls wg show dump every WG_METRICS_POLL_INTERVAL seconds (default 5) - Updates device stats in PostgreSQL - Pushes Prometheus-format metrics to VictoriaMetrics (if configured) - Graceful shutdown on SIGTERM Integration test stack (compose.yml): - Unified compose file for dev, test, and integration modes - VictoriaMetrics single-node TSDB for metrics storage - 3 mock WireGuard client containers generating ping traffic - Automated setup script seeds server keypair, admin user, client devices - make test-stack-up: one command to start everything - make test-stack-verify: validates metrics flowing end-to-end Infrastructure: - Makefile with targets for dev, test, integration, and production - Integration tests verify VictoriaMetrics has data for all 3 clients - Fix Dockerfile to include img/ directory - Separate TESTS.md for test tracking, clean TODO.md for features only --- .forgejo/workflows/dev.yml | 2 +- .forgejo/workflows/release.yml | 2 +- .gitignore | 1 + Dockerfile | 2 + Makefile | 123 ++++++++++++++ TESTS.md | 77 +++++++++ TODO.md | 163 +++++++------------ compose.yml | 127 ++++++++++++++- docker/mock-clients/Dockerfile | 4 + docker/mock-clients/entrypoint.sh | 27 ++++ docker/mock-clients/setup.py | 147 +++++++++++++++++ tests/integration/__init__.py | 0 tests/integration/test_metrics_pipeline.py | 156 ++++++++++++++++++ wiregui/collector.py | 176 +++++++++++++++++++++ wiregui/config.py | 5 + wiregui/main.py | 35 +++- 16 files changed, 932 insertions(+), 115 deletions(-) create mode 100644 Makefile create mode 100644 TESTS.md create mode 100644 docker/mock-clients/Dockerfile create mode 100755 docker/mock-clients/entrypoint.sh create mode 100644 docker/mock-clients/setup.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_metrics_pipeline.py create mode 100644 wiregui/collector.py diff --git a/.forgejo/workflows/dev.yml b/.forgejo/workflows/dev.yml index d9af59c..4bff0a6 100644 --- a/.forgejo/workflows/dev.yml +++ b/.forgejo/workflows/dev.yml @@ -66,7 +66,7 @@ jobs: run: uv run alembic upgrade head - name: Run unit tests - run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short + run: uv run pytest tests/ --ignore=tests/e2e --ignore=tests/integration -v --tb=short - name: Run E2E tests run: uv run pytest tests/e2e/ -v --tb=short diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index ef5d4e4..bcf6318 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -67,7 +67,7 @@ jobs: run: uv run alembic upgrade head - name: Run unit tests - run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short + run: uv run pytest tests/ --ignore=tests/e2e --ignore=tests/integration -v --tb=short - name: Run E2E tests run: uv run pytest tests/e2e/ -v --tb=short diff --git a/.gitignore b/.gitignore index f59019a..ac92cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ logs/ .idea/ .coverage +docker/mock-clients/configs/ diff --git a/Dockerfile b/Dockerfile index fea08f9..3477d11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev COPY wiregui/ wiregui/ COPY alembic/ alembic/ COPY alembic.ini ./ +COPY img/ img/ FROM python:3.13-slim AS runner @@ -34,6 +35,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/wiregui /app/wiregui +COPY --from=builder /app/img /app/img COPY --from=builder /app/alembic /app/alembic COPY --from=builder /app/alembic.ini /app/alembic.ini COPY --from=builder /app/pyproject.toml /app/pyproject.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7872750 --- /dev/null +++ b/Makefile @@ -0,0 +1,123 @@ +.PHONY: help install migrate dev dev-up dev-down dev-logs \ + test test-unit test-e2e test-e2e-headed \ + test-stack-up test-stack-seed test-stack-down test-stack-logs test-stack-verify \ + prod-build \ + clean + +# Default target +help: + @echo "WireGUI — available targets:" + @echo "" + @echo " Development (app runs on host, infra in Docker):" + @echo " make install Install dependencies (uv sync)" + @echo " make migrate Run database migrations" + @echo " make dev Start infra + mock IdPs, run app locally" + @echo " make dev-up Start infra only (Postgres, Valkey, mock IdPs)" + @echo " make dev-down Stop all containers" + @echo " make dev-logs Tail container logs" + @echo "" + @echo " Testing:" + @echo " make test Run unit + e2e tests" + @echo " make test-unit Run unit tests only" + @echo " make test-e2e Run e2e tests (headless)" + @echo " make test-e2e-headed Run e2e tests in headed mode (visible browser)" + @echo "" + @echo " Integration stack (containerized WireGUI + WG clients + VictoriaMetrics):" + @echo " make test-stack-up Seed DB, build, start everything" + @echo " make test-stack-down Stop and remove containers + volumes" + @echo " make test-stack-logs Tail logs" + @echo " make test-stack-verify Verify metrics flowing to VictoriaMetrics" + @echo "" + @echo " Production:" + @echo " make prod-build Build production Docker image" + @echo "" + @echo " Housekeeping:" + @echo " make clean Remove generated files, caches, volumes" + +# --------------------------------------------------------------------------- +# Development +# --------------------------------------------------------------------------- + +install: + uv sync + +migrate: + uv run alembic upgrade head + +dev-up: + docker compose up -d postgres valkey mock-oidc mock-saml + +dev-down: + docker compose down + +dev-logs: + docker compose logs -f + +dev: dev-up migrate + uv run python -m wiregui.main + +# --------------------------------------------------------------------------- +# Testing +# --------------------------------------------------------------------------- + +test-unit: + uv run pytest tests/ --ignore=tests/e2e --ignore=tests/integration -v --tb=short + +test-e2e: + uv run pytest tests/e2e/ -v --tb=short + +test-e2e-headed: + uv run pytest tests/e2e/ --headed --slowmo 300 -v --tb=short + +test: test-unit test-e2e + +# --------------------------------------------------------------------------- +# Integration test stack (real WireGuard + mock clients + VictoriaMetrics) +# --------------------------------------------------------------------------- + +test-stack-up: test-stack-seed + docker compose up -d --build wiregui client1 client2 client3 + @echo "" + @echo "Integration stack running:" + @echo " WireGUI: http://localhost:13000 (admin@test.local / admin123)" + @echo " VictoriaMetrics: http://localhost:8428" + @echo " Mock clients: 3 peers generating traffic every 3s" + +test-stack-seed: + @echo "[*] Starting infrastructure..." + docker compose up -d postgres valkey victoriametrics + @echo "[*] Waiting for Postgres..." + @until docker compose exec -T postgres pg_isready -U wiregui > /dev/null 2>&1; do sleep 1; done + @echo "[*] Running migrations..." + uv run alembic upgrade head + @echo "[*] Seeding server keypair, admin user, and client devices..." + PYTHONPATH=. uv run python docker/mock-clients/setup.py + +test-stack-down: + docker compose down -v + +test-stack-verify: + uv run pytest tests/integration/ -v --tb=short + +test-stack-logs: + docker compose logs -f wiregui client1 client2 client3 victoriametrics + +# --------------------------------------------------------------------------- +# Production +# --------------------------------------------------------------------------- + +PROD_IMAGE ?= wiregui +PROD_TAG ?= latest + +prod-build: + docker build --no-cache -t $(PROD_IMAGE):$(PROD_TAG) . + +# --------------------------------------------------------------------------- +# Housekeeping +# --------------------------------------------------------------------------- + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + rm -rf .pytest_cache .coverage htmlcov + rm -rf docker/mock-clients/configs/ + rm -rf .nicegui/ diff --git a/TESTS.md b/TESTS.md new file mode 100644 index 0000000..fbab1e4 --- /dev/null +++ b/TESTS.md @@ -0,0 +1,77 @@ +# WireGUI — Test Suite + +**Test count: 271 (201 unit + 70 E2E) | Unit coverage: 36% | Effective: ~81% (incl. E2E)** +**Run:** `uv run pytest` (unit) / `uv run pytest tests/e2e/` (E2E via Playwright) + +--- + +## Unit Tests — Coverage by Module + +**Done:** +- [x] `wiregui/api/deps.py` (91%) — 11 tests: Bearer token auth, get_current_api_user, require_admin +- [x] `wiregui/services/wireguard.py` (98%) — 6 tests: ensure_interface, set_private_key, set_listen_port, configure_interface +- [x] `wiregui/services/firewall.py` (94%) — 17 tests: _nft/_nft_batch errors, jump rules, policies, get_ruleset +- [x] `wiregui/auth/api_token.py` (100%) — covered via test_api_deps.py +- [x] `wiregui/auth/saml.py` — full SAML flow tested via mock SimpleSAMLphp IdP (e2e) +- [x] `wiregui/utils/server_key.py` (100%) — 3 tests: returns key, raises when missing, raises when empty + +**Remaining unit test gaps (by coverage):** +- [ ] `wiregui/auth/seed.py` (29%) — test seed_admin, seed_idp_providers with various YAML configs, ensure_server_keypair +- [ ] `wiregui/tasks/__init__.py` (35%) — test register_task, cancel_all +- [ ] `wiregui/tasks/oidc_refresh.py` (40%) — test successful refresh, failure with notification, disable_vpn_on_oidc_error +- [ ] `wiregui/api/v0/configuration.py` (55%) — test GET/PUT configuration endpoints +- [ ] `wiregui/api/v0/devices.py` (65%) — test CRUD device API endpoints +- [ ] `wiregui/api/v0/rules.py` (70%) — test CRUD rule API endpoints +- [ ] `wiregui/tasks/connectivity.py` (72%) — test connectivity check loop +- [ ] `wiregui/utils/network.py` (73%) — test IPv6 allocation, edge cases in CIDR validation +- [ ] `wiregui/tasks/stats.py` (74%) — test WG stats polling loop +- [ ] `wiregui/tasks/vpn_session.py` (77%) — test session expiry loop +- [ ] `wiregui/auth/webauthn.py` (87%) — test verify_registration, verify_authentication with mock credential data +- [ ] `wiregui/auth/middleware.py` (0%) — test NiceGUI auth middleware redirect logic + +--- + +## E2E Tests (Playwright) + +**Completed test suites:** +- [x] `tests/e2e/test_login.py` (6 tests) — valid login, invalid password, nonexistent email, disabled user, logout, unauthenticated redirect +- [x] `tests/e2e/test_devices.py` (2 tests) — add device full flow, name validation +- [x] `tests/e2e/test_account.py` (8 tests) — change password (success/wrong/mismatch/short), create API token, TOTP registration + invalid code, account deletion +- [x] `tests/e2e/test_admin_users.py` (10 tests) — page renders, create user, duplicate email, edit role/password, disable/enable, delete, cascade delete, self-delete guard +- [x] `tests/e2e/test_idp_seed.py` (9 tests) — IdP YAML seeding (noop/missing/invalid, OIDC/SAML add, upsert, preserve), OIDC button visible, full OIDC login flow via mock-oidc +- [x] `tests/e2e/test_mfa_login.py` (4 tests) — MFA redirect on login, valid TOTP completes login, invalid code error, cancel returns to login +- [x] `tests/e2e/test_magic_link_page.py` (4 tests) — page renders, success on submit, empty email error, back to login +- [x] `tests/e2e/test_admin_devices.py` (7 tests) — list all devices, filter by user, create with defaults, create with overrides, edit name/description, delete, config dialog with QR +- [x] `tests/e2e/test_admin_rules.py` (7 tests) — list rules table, create accept/drop/global rules, edit action/destination, delete rule (all verified in DB) +- [x] `tests/e2e/test_admin_settings.py` (9 tests) — client defaults save/reload, security toggles (local auth, VPN session, unprivileged), OIDC add/delete, SAML add/delete (all verified in DB) +- [x] `tests/e2e/test_saml_login.py` (4 tests) — SAML button visible, redirect to IdP, SP metadata endpoint, full SAML login flow via mock SimpleSAMLphp + +**Remaining E2E test suites:** + +`tests/e2e/test_admin_diagnostics.py` — Admin Diagnostics: +- [ ] Page renders WireGuard interface status +- [ ] Active peers table shows devices with handshakes +- [ ] Connectivity checks table shows recent results +- [ ] Notifications list shows system notifications +- [ ] Clear single notification → removed +- [ ] Clear all notifications → list empty + +`tests/e2e/test_devices_user.py` — User Device Pages: +- [ ] Device list shows only own devices (not other users') +- [ ] Create device → shows in table with allocated IPs +- [ ] Device detail page shows public key, IPs, stats, active config +- [ ] Device detail: edit name → persists +- [ ] Device detail: toggle config overrides → custom values saved +- [ ] Device detail: delete with confirmation → redirects to /devices +- [ ] Auto-refresh: stats labels update after timer fires (mock timer) + +`tests/e2e/test_account_extended.py` — Account Page (additional): +- [ ] SSO providers section shows connected providers +- [ ] SSO providers section shows "No SSO providers" when empty +- [ ] MFA: add security key (WebAuthn) → method appears in table (mock navigator.credentials) +- [ ] MFA: delete method with confirmation → removed from table +- [ ] API tokens: expired token shows "Expired" badge +- [ ] API tokens: delete token → removed from table +- [ ] API tokens: copy button calls clipboard API +- [ ] Danger zone: disabled when only admin +- [ ] Danger zone: wrong email in confirmation → shows error \ No newline at end of file diff --git a/TODO.md b/TODO.md index e23431f..88cf500 100644 --- a/TODO.md +++ b/TODO.md @@ -1,130 +1,87 @@ -# WireGUI — Pending Items - -**Test count: 268 (198 unit + 70 E2E) | Coverage: 36% unit, ~63% effective (incl. E2E)** +# WireGUI — TODO --- -## Testing +## WireGuard Metrics Collector -# WireGUI Implementation TODO +### Overview -Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI. -Source: `/home/stefanob/PycharmProjects/personal/wirezone` +Separate Python process dedicated to high-frequency WireGuard stats collection, with optional VictoriaMetrics time-series storage. Replaces the current 60s in-process polling with a 5s external collector. -**Test count: 268 (198 unit + 70 E2E) | Coverage: 36% unit, ~63% effective (incl. E2E)** -**Run:** `uv run pytest` (unit) / `uv run pytest tests/e2e/` (E2E via Playwright) +### Current state +- `tasks/stats.py`: polls `wg show dump` every 60s inside the web process asyncio loop +- UI timers: 30s refresh on device pages +- Worst-case latency: ~90s before a stat change is visible +### Target state +- Collector process: polls every 5s, writes to DB + VictoriaMetrics +- UI timers: 10s refresh +- Worst-case latency: ~15s -## Phase 7: Admin UI ✅ +### Phase 1: Configuration ✅ -- [ ] **TODO:** SAML provider management in Authentication tab +- [x] Add settings to `config.py`: + - `WG_METRICS_ENABLED: bool = False` + - `WG_METRICS_POLL_INTERVAL: int = 5` (seconds) + - `WG_VICTORIAMETRICS_URL: str | None = None` (e.g. `http://localhost:8428`) +- [x] When `WG_METRICS_ENABLED=false`, keep existing `stats_loop` as fallback +- [x] When `WG_METRICS_ENABLED=true`, skip registering `stats_loop` in `main.py` -## Phase 10: Polish, Testing & Deployment +### Phase 2: Collector process ✅ -### Testing (partially done) -- [ ] HTTP-level integration tests (OIDC redirect/callback flow with respx mocking) -- [x] `wiregui/api/deps.py` (11 tests) — resolve_bearer_token (valid/expired/invalid/disabled/no-expiry), get_current_api_user (missing header/bad scheme/invalid token/valid token), require_admin (admin/unprivileged) -- [x] `wiregui/services/wireguard.py` (6 tests) — ensure_interface (exists/creates new), set_private_key, set_listen_port, configure_interface (no config/sets key+port) -- [x] `wiregui/services/firewall.py` (17 tests) — _nft error/success, _nft_batch error/stdin, add_device_jump_rule (ipv4-only/ipv6-only/no-ips/both), setup_base_tables error handling, masquerade error, peer-to-peer/lan-to-peers policies, get_ruleset fallback -- [ ] `wiregui/tasks/oidc_refresh.py` — test successful refresh, failure with notification, disable_vpn_on_oidc_error -- [x] `wiregui/auth/saml.py` — full SAML flow tested via mock SimpleSAMLphp IdP (e2e) -- [ ] `wiregui/auth/webauthn.py` — test verify_registration, verify_authentication with mock credential data -- [ ] E2E tests for admin pages (users, devices, rules, settings) +- [x] Create `wiregui/collector.py` — standalone entry point (`python -m wiregui.collector`) +- [x] No NiceGUI dependency — only asyncio + asyncpg + httpx +- [x] Poll `wg show dump` every `WG_METRICS_POLL_INTERVAL` seconds +- [x] Update Device rows in PostgreSQL (same fields as current `stats_loop`) +- [x] Push metrics to VictoriaMetrics via `/api/v1/import/prometheus` (if URL configured) +- [x] Graceful shutdown on SIGTERM/SIGINT +- [x] Web app spawns collector as subprocess when `WG_METRICS_ENABLED=true` +- [x] Web app terminates collector on shutdown -**E2E page tests (Playwright async API in `tests/e2e/`):** -- [x] `tests/e2e/test_login.py` (6 tests) — valid login, invalid password, nonexistent email, disabled user, logout, unauthenticated redirect -- [x] `tests/e2e/test_devices.py` (2 tests) — add device full flow, name validation -- [x] `tests/e2e/test_account.py` (8 tests) — change password (success/wrong/mismatch/short), create API token, TOTP registration + invalid code, account deletion -- [x] `tests/e2e/test_admin_users.py` (10 tests) — page renders, create user, duplicate email, edit role/password, disable/enable, delete, cascade delete, self-delete guard -- [x] `tests/e2e/test_idp_seed.py` (9 tests) — IdP YAML seeding (noop/missing/invalid, OIDC/SAML add, upsert, preserve), OIDC button visible, full OIDC login flow via mock-oidc -- [x] `tests/e2e/test_mfa_login.py` (4 tests) — MFA redirect on login, valid TOTP completes login, invalid code error, cancel returns to login -- [x] `tests/e2e/test_magic_link_page.py` (4 tests) — page renders, success on submit, empty email error, back to login -- [x] `tests/e2e/test_admin_devices.py` (7 tests) — list all devices, filter by user, create with defaults, create with overrides, edit name/description, delete, config dialog with QR -- [x] `tests/e2e/test_admin_rules.py` (7 tests) — list rules table, create accept/drop/global rules, edit action/destination, delete rule (all verified in DB) -- [x] `tests/e2e/test_admin_settings.py` (9 tests) — client defaults save/reload, security toggles (local auth, VPN session, unprivileged), OIDC add/delete, SAML add/delete (all verified in DB) -- [x] `tests/e2e/test_saml_login.py` (4 tests) — SAML button visible, redirect to IdP, SP metadata endpoint, full SAML login flow via mock SimpleSAMLphp +### Phase 3: VictoriaMetrics metrics -**E2E tests still needed:** +Metrics to push (Prometheus exposition format): +- [ ] `wiregui_peer_rx_bytes{public_key, user_email, device_name}` — counter +- [ ] `wiregui_peer_tx_bytes{public_key, user_email, device_name}` — counter +- [ ] `wiregui_peer_latest_handshake_seconds{public_key, user_email, device_name}` — gauge +- [ ] `wiregui_peer_connected{public_key, user_email, device_name}` — 1 if handshake < 180s, else 0 +- [ ] `wiregui_peers_total` — gauge, count of active peers -`tests/e2e/test_login.py` — Login & Auth flows (remaining): -- [x] Login with MFA → redirects to /mfa challenge page -- [x] MFA challenge: valid TOTP code → completes login -- [x] MFA challenge: invalid code → shows error, stays on /mfa -- [x] MFA challenge: cancel → returns to /login -- [x] Magic link request page renders, shows success on submit +### Phase 4: UI improvements -`tests/e2e/test_admin_devices.py` — Admin Device Management: -- [x] List all devices across users -- [x] Filter by user → shows only that user's devices -- [x] Create device with full config overrides (DNS, endpoint, MTU, keepalive, allowed IPs) -- [x] Create device with defaults → use_default flags all True -- [x] Edit device name and description → persists -- [x] Edit device config overrides (toggle use_default off, set custom values) -- [x] Delete device → removed from table -- [x] Config dialog shows valid WireGuard config with real server public key -- [x] QR code renders in config dialog +- [ ] Reduce UI timer from 30s to 10s on device pages (devices.py, admin/devices.py) +- [ ] Add connection status indicator (green/yellow/red dot) based on handshake age + - Green: handshake < 2 min + - Yellow: handshake < 5 min + - Red: no recent handshake or never connected +- [ ] Add traffic rate display (bytes/sec computed from delta between polls) +- [ ] Device detail page: mini traffic chart (query VictoriaMetrics if available, else show last-known values) -`tests/e2e/test_admin_rules.py` — Admin Firewall Rules: -- [x] List rules → table shows action, destination, protocol, port, user -- [x] Create accept rule with CIDR → appears in table -- [x] Create drop rule with TCP port range → appears correctly -- [x] Create global rule (no user) → shows "Global" -- [x] Edit rule action (accept → drop) → persists -- [x] Edit rule destination → persists -- [x] Delete rule → removed from table +### Phase 5: Infrastructure ✅ -`tests/e2e/test_admin_settings.py` — Admin Settings: -- [x] Client defaults: save endpoint, DNS, MTU, keepalive, allowed IPs → persists in DB -- [x] Client defaults: saved values reflected on page reload -- [x] Security: toggle local auth → persists -- [x] Security: change VPN session duration → persists -- [x] Security: toggle unprivileged device management/configuration → persists -- [x] OIDC: add provider → appears in table -- [x] OIDC: delete provider → removed from table -- [x] SAML: add provider → appears in table -- [x] SAML: delete provider → removed from table +- [x] Create `compose.test.yml` — full integration stack with real WG +- [x] Add VictoriaMetrics (single-node, port 8428, 7d retention) +- [x] Add 3 mock WG client containers (alpine + wireguard-tools) +- [x] Clients generate traffic by pinging each other through the tunnel every 3s +- [x] Setup script (`docker/mock-clients/setup.py`) generates keypairs and configs +- [x] Collector runs as subprocess inside the WireGUI container (shares network namespace) +- [ ] Add VictoriaMetrics to dev `compose.yml` (optional, for local testing) -`tests/e2e/test_admin_diagnostics.py` — Admin Diagnostics: -- [ ] Page renders WireGuard interface status -- [ ] Active peers table shows devices with handshakes -- [ ] Connectivity checks table shows recent results -- [ ] Notifications list shows system notifications -- [ ] Clear single notification → removed -- [ ] Clear all notifications → list empty +### Design notes -`tests/e2e/test_devices_user.py` — User Device Pages: -- [ ] Device list shows only own devices (not other users') -- [ ] Create device → shows in table with allocated IPs -- [ ] Device detail page shows public key, IPs, stats, active config -- [ ] Device detail: edit name → persists -- [ ] Device detail: toggle config overrides → custom values saved -- [ ] Device detail: delete with confirmation → redirects to /devices -- [ ] Auto-refresh: stats labels update after timer fires (mock timer) +- **Why a separate process?** The `wg show` subprocess call and DB writes at 5s intervals shouldn't share the asyncio loop with the web app. A separate process ensures UI responsiveness isn't affected by stats collection. +- **Why not `run.cpu_bound`?** That uses `ProcessPoolExecutor` for one-shot CPU tasks inside request handling — not suitable for a long-running daemon. A separate entry point is cleaner. +- **VictoriaMetrics push model:** Use the Prometheus remote write API. No scrape config needed — the collector pushes directly. VictoriaMetrics is optional; the collector works fine with just PostgreSQL. +- **Backward compatible:** When `WG_METRICS_ENABLED=false` (default), everything works exactly as it does today. + +--- ## UI +- [ ] SAML provider management in Authentication tab (admin settings) - [ ] SSO Providers on account page: add Status column, "Disconnect" action - [ ] Admin pages (users, devices, rules): apply same card-based styling as account/settings/diagnostics -`tests/e2e/test_account_extended.py` — Account Page (additional): -- [ ] SSO providers section shows connected providers -- [ ] SSO providers section shows "No SSO providers" when empty -- [ ] MFA: add security key (WebAuthn) → method appears in table (mock navigator.credentials) -- [ ] MFA: delete method with confirmation → removed from table -- [ ] API tokens: expired token shows "Expired" badge -- [ ] API tokens: delete token → removed from table -- [ ] API tokens: copy button calls clipboard API -- [ ] Danger zone: disabled when only admin -- [ ] Danger zone: wrong email in confirmation → shows error - ## Features -### Deployment ✅ - -- [ ] First-run CLI setup command - ---- - -### Remaining -- [ ] SSO Providers: add Status column, "Disconnect" action -- [ ] Admin pages (users, devices, rules): apply same card-based styling +- [ ] First-run CLI setup command \ No newline at end of file diff --git a/compose.yml b/compose.yml index 30dd691..9f208e5 100644 --- a/compose.yml +++ b/compose.yml @@ -1,12 +1,29 @@ +# WireGUI — unified compose stack +# +# Dev mode (app runs on host): +# make dev — starts infra + mock IdPs, runs app locally +# make dev-up — starts infra only +# +# Integration test mode (real WireGuard + mock clients + metrics): +# make test-stack-up — seeds DB, builds, starts everything +# make test-stack-down — tears down and removes volumes +# +# Services are opt-in: only start what you need. + services: + + # --------------------------------------------------------------------------- + # Core infrastructure (always needed) + # --------------------------------------------------------------------------- + postgres: image: postgres:17 + ports: + - "5432:5432" environment: POSTGRES_USER: wiregui POSTGRES_PASSWORD: wiregui POSTGRES_DB: wiregui - ports: - - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data @@ -17,9 +34,12 @@ services: volumes: - valkey_data:/data - # Test OIDC Identity Provider — accepts any login, issues real JWTs + # --------------------------------------------------------------------------- + # Mock identity providers (dev + e2e tests) + # --------------------------------------------------------------------------- + + # OIDC — accepts any login, issues real JWTs # Discovery: http://localhost:9000/test-idp/.well-known/openid-configuration - # Login: enter any username/password, it will issue a token mock-oidc: image: ghcr.io/navikt/mock-oauth2-server:2.1.10 ports: @@ -49,10 +69,10 @@ services: ] } - # Test SAML Identity Provider — SimpleSAMLphp as IdP - # IdP Metadata: http://localhost:8080/simplesaml/saml2/idp/metadata.php - # Admin UI: http://localhost:8080/simplesaml (admin / secret) - # Test users: user1/password, user2/password + # SAML — SimpleSAMLphp as IdP + # Metadata: http://localhost:8080/simplesaml/saml2/idp/metadata.php + # Admin: http://localhost:8080/simplesaml (admin / secret) + # Users: user1/password, user2/password mock-saml: image: kenchan0130/simplesamlphp ports: @@ -64,6 +84,97 @@ services: volumes: - ./docker/mock-saml/saml20-sp-remote.php:/var/www/simplesamlphp/metadata/saml20-sp-remote.php:ro + # --------------------------------------------------------------------------- + # WireGUI server (integration test mode — containerized with real WG) + # --------------------------------------------------------------------------- + + wiregui: + build: . + ports: + - "13000:13000" + # 51820/udp exposed inside Docker network only — clients connect via service name + # Uncomment to expose to host: - "51820:51820/udp" + environment: + WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui + WG_REDIS_URL: redis://valkey:6379/0 + WG_WG_ENABLED: "true" + WG_EXTERNAL_URL: http://localhost:13000 + WG_ENDPOINT_HOST: wiregui + WG_METRICS_ENABLED: "true" + WG_METRICS_POLL_INTERVAL: "5" + WG_VICTORIAMETRICS_URL: http://victoriametrics:8428 + WG_ADMIN_EMAIL: admin@test.local + WG_ADMIN_PASSWORD: admin123 + WG_LOG_TO_FILE: "false" + WG_SECRET_KEY: test-secret-key-for-integration + cap_add: + - NET_ADMIN + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv6.conf.all.forwarding=1 + depends_on: + - postgres + - valkey + + # --------------------------------------------------------------------------- + # Metrics (integration test mode) + # --------------------------------------------------------------------------- + + victoriametrics: + image: victoriametrics/victoria-metrics:v1.108.1 + ports: + - "8428:8428" + command: + - "-retentionPeriod=7d" + - "-httpListenAddr=:8428" + volumes: + - vm_data:/victoria-metrics-data + + # --------------------------------------------------------------------------- + # Mock WireGuard clients (integration test mode) + # Configs generated by: make test-stack-seed + # --------------------------------------------------------------------------- + + client1: + build: docker/mock-clients + environment: + CLIENT_IP: ${CLIENT1_IP:-10.3.2.101} + PEER_IPS: ${CLIENT1_PEERS:-10.3.2.102 10.3.2.103} + PING_INTERVAL: "3" + volumes: + - ./docker/mock-clients/configs/client1.conf:/etc/wireguard/wg0.conf:ro + cap_add: + - NET_ADMIN + depends_on: + - wiregui + + client2: + build: docker/mock-clients + environment: + CLIENT_IP: ${CLIENT2_IP:-10.3.2.102} + PEER_IPS: ${CLIENT2_PEERS:-10.3.2.101 10.3.2.103} + PING_INTERVAL: "3" + volumes: + - ./docker/mock-clients/configs/client2.conf:/etc/wireguard/wg0.conf:ro + cap_add: + - NET_ADMIN + depends_on: + - wiregui + + client3: + build: docker/mock-clients + environment: + CLIENT_IP: ${CLIENT3_IP:-10.3.2.103} + PEER_IPS: ${CLIENT3_PEERS:-10.3.2.101 10.3.2.102} + PING_INTERVAL: "3" + volumes: + - ./docker/mock-clients/configs/client3.conf:/etc/wireguard/wg0.conf:ro + cap_add: + - NET_ADMIN + depends_on: + - wiregui + volumes: postgres_data: valkey_data: + vm_data: diff --git a/docker/mock-clients/Dockerfile b/docker/mock-clients/Dockerfile new file mode 100644 index 0000000..6b196ec --- /dev/null +++ b/docker/mock-clients/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3.20 +RUN apk add --no-cache wireguard-tools iproute2 iputils-ping +COPY entrypoint.sh /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/mock-clients/entrypoint.sh b/docker/mock-clients/entrypoint.sh new file mode 100755 index 0000000..0e50195 --- /dev/null +++ b/docker/mock-clients/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# WireGuard mock client — configures interface and generates traffic +set -e + +echo "[*] Configuring WireGuard interface..." +ip link add wg0 type wireguard +wg setconf wg0 /etc/wireguard/wg0.conf +ip address add "${CLIENT_IP}/32" dev wg0 +ip link set wg0 up + +# Route traffic to the VPN subnet through the tunnel +ip route add 10.3.2.0/24 dev wg0 + +echo "[*] WireGuard client up: ${CLIENT_IP}" +echo "[*] Generating traffic to peers every ${PING_INTERVAL:-5}s..." + +while true; do + # Ping the server (first host in the subnet) + ping -c 1 -W 1 10.3.2.1 > /dev/null 2>&1 || true + + # Ping other peers if specified + for peer_ip in $PEER_IPS; do + ping -c 1 -W 1 "$peer_ip" > /dev/null 2>&1 || true + done + + sleep "${PING_INTERVAL:-5}" +done diff --git a/docker/mock-clients/setup.py b/docker/mock-clients/setup.py new file mode 100644 index 0000000..a25fadf --- /dev/null +++ b/docker/mock-clients/setup.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Seed the test stack: generate server + client keypairs, write client WG configs, +and insert devices into the database. + +Usage: uv run python docker/mock-clients/setup.py + +Requires: Postgres running and migrations applied (alembic upgrade head). +""" + +import asyncio +import sys +from pathlib import Path +from uuid import uuid4 + +from sqlmodel import select + +from wiregui.auth.passwords import hash_password +from wiregui.db import async_session, engine +from wiregui.models.configuration import Configuration +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.utils.crypto import generate_keypair, generate_preshared_key + +NUM_CLIENTS = 3 +SUBNET = "10.3.2" +SERVER_ENDPOINT = "wiregui:51820" +CONFIG_DIR = Path(__file__).parent / "configs" + +# Test admin user +ADMIN_EMAIL = "admin@test.local" +ADMIN_PASSWORD = "admin123" + +# Client definitions +CLIENTS = [ + {"name": f"test-client-{i}", "ip": f"{SUBNET}.{100 + i}"} + for i in range(1, NUM_CLIENTS + 1) +] + + +async def seed(): + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + async with async_session() as session: + # --- Server keypair --- + config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none() + if config is None: + config = Configuration() + session.add(config) + await session.flush() + + if not config.server_private_key or not config.server_public_key: + server_priv, server_pub = generate_keypair() + config.server_private_key = server_priv + config.server_public_key = server_pub + session.add(config) + print(f" Server keypair generated: {server_pub[:20]}...") + else: + server_pub = config.server_public_key + print(f" Server keypair already exists: {server_pub[:20]}...") + + # --- Admin user --- + admin = (await session.execute( + select(User).where(User.email == ADMIN_EMAIL) + )).scalar_one_or_none() + if admin is None: + admin = User( + email=ADMIN_EMAIL, + password_hash=hash_password(ADMIN_PASSWORD), + role="admin", + ) + session.add(admin) + await session.flush() + print(f" Admin user created: {ADMIN_EMAIL}") + else: + print(f" Admin user already exists: {ADMIN_EMAIL}") + + # --- Client devices (delete + recreate for clean state) --- + client_names = [c["name"] for c in CLIENTS] + client_ips = [c["ip"] for c in CLIENTS] + stale = (await session.execute( + select(Device).where( + Device.name.in_(client_names) | Device.ipv4.in_(client_ips) + ) + )).scalars().all() + for d in stale: + await session.delete(d) + if stale: + await session.flush() + print(f" Cleaned up {len(stale)} stale device(s)") + + for client in CLIENTS: + client_priv, client_pub = generate_keypair() + psk = generate_preshared_key() + + device = Device( + name=client["name"], + public_key=client_pub, + preshared_key=psk, + ipv4=client["ip"], + user_id=admin.id, + ) + session.add(device) + + client["privkey"] = client_priv + client["pubkey"] = client_pub + client["psk"] = psk + print(f" Device '{client['name']}' created ({client['ip']})") + + await session.commit() + + # --- Write client WG configs --- + for i, client in enumerate(CLIENTS): + conf = f"""[Interface] +PrivateKey = {client["privkey"]} + +[Peer] +PublicKey = {server_pub} +PresharedKey = {client["psk"]} +Endpoint = {SERVER_ENDPOINT} +AllowedIPs = {SUBNET}.0/24 +PersistentKeepalive = 5 +""" + conf_path = CONFIG_DIR / f"client{i + 1}.conf" + conf_path.write_text(conf) + print(f" Config written: {conf_path}") + + # --- Write env vars for compose --- + env_lines = [] + for i, client in enumerate(CLIENTS): + other_ips = " ".join(c["ip"] for c in CLIENTS if c["ip"] != client["ip"]) + env_lines.append(f"CLIENT{i + 1}_IP={client['ip']}") + env_lines.append(f"CLIENT{i + 1}_PEERS={other_ips}") + env_path = CONFIG_DIR / "clients.env" + env_path.write_text("\n".join(env_lines) + "\n") + + await engine.dispose() + + +def main(): + print("[*] Seeding test stack...") + asyncio.run(seed()) + print("\n[*] Done. Start the stack with:") + print(" make test-stack-up") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_metrics_pipeline.py b/tests/integration/test_metrics_pipeline.py new file mode 100644 index 0000000..6560faf --- /dev/null +++ b/tests/integration/test_metrics_pipeline.py @@ -0,0 +1,156 @@ +"""Integration test: verify metrics flow from WG clients → collector → VictoriaMetrics. + +Requires the full integration stack running: make test-stack-up +Run with: make test-stack-verify (or: uv run pytest tests/integration/ -v) +""" + +import os +import time + +import httpx +import pytest + +VM_URL = os.environ.get("WG_VICTORIAMETRICS_URL", "http://localhost:8428") +WIREGUI_URL = os.environ.get("WG_EXTERNAL_URL", "http://localhost:13000") + +EXPECTED_CLIENTS = ["test-client-1", "test-client-2", "test-client-3"] +# Wait up to this long for metrics to appear (collector runs every 5s) +MAX_WAIT = 60 +POLL_INTERVAL = 5 + + +def _vm_query(query: str) -> dict: + """Execute an instant query against VictoriaMetrics.""" + resp = httpx.get(f"{VM_URL}/api/v1/query", params={"query": query}, timeout=5) + resp.raise_for_status() + return resp.json() + + +def _vm_series(metric: str) -> list[dict]: + """Get all series for a metric from VictoriaMetrics.""" + resp = httpx.get(f"{VM_URL}/api/v1/series", params={"match[]": metric}, timeout=5) + resp.raise_for_status() + return resp.json().get("data", []) + + +@pytest.fixture(scope="module", autouse=True) +def check_stack_running(): + """Skip all tests if the integration stack isn't running.""" + try: + r = httpx.get(f"{WIREGUI_URL}/api/health", timeout=3) + if r.status_code != 200: + pytest.skip("WireGUI not running") + except httpx.HTTPError: + pytest.skip("WireGUI not running — start with: make test-stack-up") + + try: + r = httpx.get(f"{VM_URL}/health", timeout=3) + if r.status_code != 200: + pytest.skip("VictoriaMetrics not running") + except httpx.HTTPError: + pytest.skip("VictoriaMetrics not running — start with: make test-stack-up") + + +@pytest.fixture(scope="module") +def wait_for_metrics(): + """Wait until at least one peer metric appears in VictoriaMetrics.""" + deadline = time.time() + MAX_WAIT + while time.time() < deadline: + result = _vm_query("wiregui_peers_total") + data = result.get("data", {}).get("result", []) + if data and float(data[0].get("value", [0, "0"])[1]) > 0: + return + time.sleep(POLL_INTERVAL) + pytest.fail(f"No metrics appeared in VictoriaMetrics after {MAX_WAIT}s") + + +def test_peers_total(wait_for_metrics): + """wiregui_peers_total reports at least 1 active peer.""" + result = _vm_query("wiregui_peers_total") + data = result["data"]["result"] + assert len(data) > 0 + value = float(data[0]["value"][1]) + assert value >= 1, f"Expected at least 1 peer, got {value}" + + +def test_rx_bytes_per_client(wait_for_metrics): + """Each client has wiregui_peer_rx_bytes > 0.""" + series = _vm_series("wiregui_peer_rx_bytes") + device_names = {s.get("device_name") for s in series} + + for client in EXPECTED_CLIENTS: + assert client in device_names, ( + f"Missing rx_bytes metric for '{client}'. " + f"Found: {device_names}" + ) + + # Verify values are non-zero (traffic is flowing) + for client in EXPECTED_CLIENTS: + result = _vm_query(f'wiregui_peer_rx_bytes{{device_name="{client}"}}') + data = result["data"]["result"] + assert len(data) > 0, f"No rx_bytes data for {client}" + value = float(data[0]["value"][1]) + assert value > 0, f"rx_bytes for {client} is 0 — no traffic?" + + +def test_tx_bytes_per_client(wait_for_metrics): + """Each client has wiregui_peer_tx_bytes > 0.""" + for client in EXPECTED_CLIENTS: + result = _vm_query(f'wiregui_peer_tx_bytes{{device_name="{client}"}}') + data = result["data"]["result"] + assert len(data) > 0, f"No tx_bytes data for {client}" + value = float(data[0]["value"][1]) + assert value > 0, f"tx_bytes for {client} is 0 — no traffic?" + + +def test_handshake_per_client(wait_for_metrics): + """Each client has a recent handshake timestamp.""" + now = time.time() + for client in EXPECTED_CLIENTS: + result = _vm_query(f'wiregui_peer_latest_handshake_seconds{{device_name="{client}"}}') + data = result["data"]["result"] + assert len(data) > 0, f"No handshake data for {client}" + ts = float(data[0]["value"][1]) + assert ts > 0, f"Handshake timestamp for {client} is 0" + age = now - ts + assert age < 300, f"Handshake for {client} is {age:.0f}s old (stale?)" + + +def test_connected_status_per_client(wait_for_metrics): + """Each client reports wiregui_peer_connected = 1.""" + for client in EXPECTED_CLIENTS: + result = _vm_query(f'wiregui_peer_connected{{device_name="{client}"}}') + data = result["data"]["result"] + assert len(data) > 0, f"No connected status for {client}" + value = int(float(data[0]["value"][1])) + assert value == 1, f"Client {client} not connected (wiregui_peer_connected={value})" + + +def test_db_devices_have_stats(): + """Verify device rows in PostgreSQL also have updated stats.""" + import asyncio + from sqlmodel import select + from wiregui.db import async_session, engine + from wiregui.models.device import Device + + async def check(): + async with async_session() as session: + result = await session.execute( + select(Device).where(Device.name.in_(EXPECTED_CLIENTS)) + ) + devices = result.scalars().all() + + assert len(devices) == len(EXPECTED_CLIENTS), ( + f"Expected {len(EXPECTED_CLIENTS)} devices, found {len(devices)}" + ) + + for device in devices: + assert device.latest_handshake is not None, ( + f"Device {device.name} has no handshake in DB" + ) + assert device.rx_bytes is not None and device.rx_bytes > 0, ( + f"Device {device.name} has no rx_bytes in DB" + ) + await engine.dispose() + + asyncio.run(check()) diff --git a/wiregui/collector.py b/wiregui/collector.py new file mode 100644 index 0000000..d1ad2d7 --- /dev/null +++ b/wiregui/collector.py @@ -0,0 +1,176 @@ +"""WireGuard metrics collector — standalone process for high-frequency stats polling. + +Run as: python -m wiregui.collector + +Polls `wg show dump` at a configurable interval, updates device rows +in PostgreSQL, and optionally pushes Prometheus-format metrics to VictoriaMetrics. +""" + +import asyncio +import signal +import time + +import httpx +from loguru import logger +from sqlmodel import select + +from wiregui.config import get_settings +from wiregui.db import async_session, engine +from wiregui.log_config import setup_logging +from wiregui.models.device import Device +from wiregui.models.user import User +from wiregui.services.wireguard import PeerInfo, get_peers + +_shutdown = asyncio.Event() + + +def _handle_signal() -> None: + logger.info("Shutdown signal received") + _shutdown.set() + + +async def _update_db(peers: list[PeerInfo]) -> dict[str, dict]: + """Update device rows in DB and return metadata for metrics labels. + + Returns: {public_key: {"device_name": ..., "user_email": ...}} + """ + if not peers: + return {} + + peer_map = {p.public_key: p for p in peers} + + async with async_session() as session: + result = await session.execute( + select(Device, User.email).join(User).where( + Device.public_key.in_(list(peer_map.keys())) + ) + ) + rows = result.all() + + labels = {} + updated = 0 + for device, user_email in rows: + peer = peer_map.get(device.public_key) + if peer is None: + continue + # Only write connection status to PostgreSQL — traffic metrics go to VictoriaMetrics + device.latest_handshake = peer.latest_handshake + device.remote_ip = peer.endpoint.split(":")[0] if peer.endpoint else None + device.rx_bytes = peer.rx_bytes + device.tx_bytes = peer.tx_bytes + session.add(device) + updated += 1 + labels[device.public_key] = { + "device_name": device.name, + "user_email": user_email, + } + + if updated: + await session.commit() + logger.debug("Updated stats for {} devices", updated) + + return labels + + +def _build_prometheus_payload(peers: list[PeerInfo], labels: dict[str, dict]) -> str: + """Build Prometheus exposition format text for VictoriaMetrics import.""" + now_ms = int(time.time() * 1000) + lines = [] + active_count = 0 + + for peer in peers: + meta = labels.get(peer.public_key) + if not meta: + continue + + tag = ( + f'public_key="{peer.public_key[:16]}",' + f'device_name="{meta["device_name"]}",' + f'user_email="{meta["user_email"]}"' + ) + + lines.append(f"wiregui_peer_rx_bytes{{{tag}}} {peer.rx_bytes} {now_ms}") + lines.append(f"wiregui_peer_tx_bytes{{{tag}}} {peer.tx_bytes} {now_ms}") + + handshake_ts = int(peer.latest_handshake.timestamp()) if peer.latest_handshake else 0 + lines.append(f"wiregui_peer_latest_handshake_seconds{{{tag}}} {handshake_ts} {now_ms}") + + connected = 1 if (handshake_ts and (time.time() - handshake_ts) < 180) else 0 + lines.append(f"wiregui_peer_connected{{{tag}}} {connected} {now_ms}") + + if connected: + active_count += 1 + + lines.append(f"wiregui_peers_total {active_count} {now_ms}") + return "\n".join(lines) + "\n" + + +async def _push_metrics(client: httpx.AsyncClient, url: str, payload: str) -> None: + """Push Prometheus-format metrics to VictoriaMetrics.""" + try: + resp = await client.post( + f"{url}/api/v1/import/prometheus", + content=payload, + headers={"Content-Type": "text/plain"}, + ) + if resp.status_code >= 400: + logger.warning("VictoriaMetrics push failed (HTTP {}): {}", resp.status_code, resp.text[:200]) + except httpx.HTTPError as e: + logger.warning("VictoriaMetrics push error: {}", e) + + +async def run() -> None: + """Main collector loop.""" + settings = get_settings() + interval = settings.metrics_poll_interval + vm_url = settings.victoriametrics_url + + logger.info( + "Collector started: interval={}s, victoriametrics={}", + interval, vm_url or "disabled", + ) + + client = httpx.AsyncClient(timeout=5) if vm_url else None + + try: + while not _shutdown.is_set(): + try: + peers = await get_peers() + labels = await _update_db(peers) + + if client and vm_url and peers: + payload = _build_prometheus_payload(peers, labels) + await _push_metrics(client, vm_url, payload) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Collector poll failed: {}", e) + + try: + await asyncio.wait_for(_shutdown.wait(), timeout=interval) + break # shutdown signalled + except asyncio.TimeoutError: + pass # normal — interval elapsed, loop again + finally: + if client: + await client.aclose() + await engine.dispose() + logger.info("Collector stopped") + + +def main() -> None: + setup_logging(log_to_file=get_settings().log_to_file) + + loop = asyncio.new_event_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, _handle_signal) + + try: + loop.run_until_complete(run()) + finally: + loop.close() + + +if __name__ == "__main__": + main() diff --git a/wiregui/config.py b/wiregui/config.py index 17ba1c4..ae8220b 100644 --- a/wiregui/config.py +++ b/wiregui/config.py @@ -41,6 +41,11 @@ class Settings(BaseSettings): smtp_password: str | None = None smtp_from: str = "wiregui@localhost" + # Metrics collector + metrics_enabled: bool = False # run separate collector process for high-frequency stats + metrics_poll_interval: int = 5 # seconds between wg show polls (collector process) + victoriametrics_url: str | None = None # e.g. http://localhost:8428 + # IdP provisioning idp_config_file: str | None = None # path to YAML file with IdP definitions diff --git a/wiregui/main.py b/wiregui/main.py index 267a150..78b4315 100644 --- a/wiregui/main.py +++ b/wiregui/main.py @@ -62,14 +62,18 @@ async def startup() -> None: from wiregui.services.firewall import setup_base_tables, setup_masquerade from wiregui.services.wireguard import configure_interface, ensure_interface from wiregui.tasks.reconcile import reconcile - from wiregui.tasks.stats import stats_loop await ensure_interface() await configure_interface() await setup_base_tables() await setup_masquerade() await reconcile() - register_task(stats_loop(), name="wg-stats") + + if settings.metrics_enabled: + _start_collector() + else: + from wiregui.tasks.stats import stats_loop + register_task(stats_loop(), name="wg-stats") register_task(vpn_session_loop(), name="vpn-session-expiry") else: logger.info("WireGuard disabled (WG_WG_ENABLED=false) — running in UI-only mode") @@ -77,10 +81,37 @@ async def startup() -> None: logger.info("WireGUI ready") +_collector_proc = None + + +def _start_collector() -> None: + """Spawn the metrics collector as a subprocess sharing our network namespace.""" + import subprocess + import sys + + global _collector_proc + _collector_proc = subprocess.Popen( + [sys.executable, "-m", "wiregui.collector"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + logger.info("Metrics collector started (pid={})", _collector_proc.pid) + + async def shutdown() -> None: from wiregui.tasks import cancel_all await cancel_all() + global _collector_proc + if _collector_proc and _collector_proc.poll() is None: + logger.info("Stopping metrics collector (pid={})", _collector_proc.pid) + _collector_proc.terminate() + try: + _collector_proc.wait(timeout=5) + except Exception: + _collector_proc.kill() + _collector_proc = None + app.on_startup(startup) app.on_shutdown(shutdown)