diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4613d0b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to **NX9-WG (`nx9-wg`)** are documented here. + +## [1.0.0] — 2026-08-18 + +NX9-WG 1.0.0 is the first production release of the native Linux WireGuard + network control plane. + +### Added + +- Native Linux WireGuard lifecycle management through WireGuard Generic Netlink and RTNETLINK. +- Native IPv4/IPv6 address and route management without `wg`, `wg-quick`, `ip`, `iptables`, `nft`, `sysctl`, or shell orchestration from production Rust. +- Native nftables firewall/NAT execution scoped to the managed `table inet nx9_wg` table. +- SQLite authoritative desired-state storage with reconciliation and drift correction. +- Live WireGuard telemetry including learned peer endpoints, handshake timestamps, and RX/TX counters. +- Correct separation of client-side `AllowedIPs` from server-side WireGuard Cryptokey Routing `AllowedIPs`. +- Road-warrior server peer routing derived from assigned tunnel addresses (`/32` and `/128`) unless an explicit server-side override is configured. +- Persistent WireGuard server endpoint configuration for client configuration and QR exports. +- Interface editing through the WebUI with cryptographic identity preservation. +- WebUI peer lifecycle states: Connected, Awaiting Handshake, Disconnected, Disabled, Expired, and Revoked. +- Pure Rust client configuration and QR generation. +- CLI, REST API, WebSocket, embedded SPA, diagnostics, backup/restore, and reconciliation tooling. + +### Changed + +- Peer API responses now merge fresh kernel telemetry instead of relying solely on cached SQLite values. +- Handshake timestamps are serialized as explicit UTC/RFC3339 values and parsed defensively by the WebUI. +- Interface edits preserve interface UUID, private key, public key, and peer associations. +- Server endpoint resolution prefers explicit export overrides, then persistent server endpoint settings, with controlled fallback behavior. +- Reconciliation detects and repairs server-side peer `AllowedIPs` drift. +- Release documentation and testing documentation are promoted to the v1.0.0 baseline. + +### Fixed + +- Fixed road-warrior peers incorrectly receiving client full-tunnel `AllowedIPs` (`0.0.0.0/0, ::/0`) in the server kernel Cryptokey Routing table. +- Fixed server-to-peer routing failure caused by missing `/32` peer routes in WireGuard peer configuration. +- Fixed WebUI active peers appearing Disconnected because backend `NaiveDateTime` values lacked an explicit UTC offset. +- Fixed stale peer telemetry in REST/WebUI responses. +- Fixed missing WebUI interface Edit action. +- Fixed missing persistent server endpoint for QR/config export. +- Fixed reconciliation convergence after deliberate interface-address drift. + +### Networking & Firewall + +- IPv4 forwarding is managed through the native Linux networking engine. +- Outbound masquerading is scoped to the WireGuard client subnet and non-WireGuard egress interfaces. +- Firewall/NAT state is reconciled atomically within the dedicated NX9 nftables table. +- Server-side peer routes and cryptokey routing are kept distinct from client routing policy. + +### Validation + +- Workspace test suite: **162 tests passing** at the documented release baseline. +- Comprehensive CLI suite: **203 passed / 7 skipped**. +- Native integration suite: **19 passed / 1 skipped**. +- Dedicated live-kernel suite: **23 passed / 1 skipped** in SAFE mode baseline. +- Real Android/mobile WireGuard client: **operator-verified** for VPN connectivity and full-tunnel Internet operation during v1.0.0 acceptance. +- WebUI interface editing: **operator-verified**. +- Live peer telemetry/status: **operator-verified** with connected mobile client. +- Final reconciliation: **operator-verified** with zero drift after convergence. +- External cellular/WAN road-warrior acceptance and post-reboot physical-client acceptance remain separate operational gates unless explicitly recorded in the release evidence. + +## [0.8.0] + +Previous development release. See repository history for detailed implementation changes. diff --git a/Cargo.lock b/Cargo.lock index 3b208fb..907ac3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "nx9-wg" -version = "0.8.0" +version = "1.0.0" dependencies = [ "axum", "base64", @@ -1807,7 +1807,7 @@ dependencies = [ [[package]] name = "nx9-wg-api" -version = "0.8.0" +version = "1.0.0" dependencies = [ "axum", "chrono", @@ -1832,7 +1832,7 @@ dependencies = [ [[package]] name = "nx9-wg-core" -version = "0.8.0" +version = "1.0.0" dependencies = [ "argon2", "base64", @@ -1852,7 +1852,7 @@ dependencies = [ [[package]] name = "nx9-wg-db" -version = "0.8.0" +version = "1.0.0" dependencies = [ "chrono", "ipnet", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "nx9-wg-network" -version = "0.8.0" +version = "1.0.0" dependencies = [ "async-trait", "chrono", @@ -1890,7 +1890,7 @@ dependencies = [ [[package]] name = "nx9-wg-ui" -version = "0.8.0" +version = "1.0.0" dependencies = [ "chrono", "nx9-wg-core", @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "nx9-wireguard" -version = "0.8.0" +version = "1.0.0" dependencies = [ "async-trait", "base64", @@ -3721,9 +3721,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 491f23c..00e82d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,11 @@ members = [ [workspace.package] license = "MIT OR Apache-2.0" -version = "0.8.0" +version = "1.0.0" edition = "2024" authors = ["NX9 Authors "] -repository = "https://github.com/nx9/nx9-wg" -homepage = "https://github.com/nx9/nx9-wg" +repository = "https://github.com/thakares/nx9-wg" +homepage = "https://github.com/thakares/nx9-wg" readme = "README.md" keywords = ["wireguard", "vpn", "netlink", "nftables", "network"] categories = ["network-programming", "command-line-utilities", "system-administration"] diff --git a/NX9-WG-STACK.md b/NX9-WG-STACK.md index 017cc1b..9ab897d 100644 --- a/NX9-WG-STACK.md +++ b/NX9-WG-STACK.md @@ -4,7 +4,7 @@ ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) -![Version](https://img.shields.io/badge/Version-v0.8.0-purple) +![Version](https://img.shields.io/badge/Version-v1.0.0-purple) ![Ecosystem](https://img.shields.io/badge/NX9-Ecosystem-6d5df6) > **"Software people can own, understand, and control."** @@ -367,7 +367,7 @@ nx9-wg forwarding enable # State Reconciliation & Diagnostics nx9-wg reconcile plan nx9-wg reconcile apply -nx9-wg diagnostics inspect all +nx9-wg diagnostics all # Disaster Recovery nx9-wg backup create --description "Pre-upgrade snapshot" @@ -464,9 +464,9 @@ LogsDirectory=nx9-wg ### Automated Packaging Pipeline ([`scripts/package-release.sh`](file:///home/sunil/Programs/nx9-wg/scripts/package-release.sh)) Generates self-contained, reproducible distribution archives in `target/dist/`: -- `nx9-wg-v0.8.0-linux-x86_64.tar.gz` (7.8 MB) -- `nx9-wg-v0.8.0-linux-x86_64.tar.xz` (5.0 MB) -- `nx9-wg-v0.8.0-linux-x86_64.sha256` (Cryptographic checksum manifest) +- `nx9-wg-v1.0.0-linux-x86_64.tar.gz` (7.8 MB) +- `nx9-wg-v1.0.0-linux-x86_64.tar.xz` (5.0 MB) +- `nx9-wg-v1.0.0-linux-x86_64.sha256` (Cryptographic checksum manifest) ### Production Filesystem Layout & Permissions ``` @@ -492,7 +492,7 @@ All quality gates have been executed and verified clean: | :--- | :--- | :---: | | **Code Formatting** | `cargo fmt --all -- --check` | **PASS** (Zero diffs) | | **Workspace Compilation** | `cargo check --workspace` | **PASS** (Zero errors) | -| **Workspace Unit Tests** | `cargo test --workspace` | **PASS** (**91 / 91 passed**, 100%) | +| **Workspace Unit Tests** | `cargo test --workspace` | **PASS** (**162 / 162 passed**, 100%) | | **Clippy Linter Check** | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | **PASS** (Zero warnings) | | **Comprehensive CLI Suite** | `LIVE=0 bash scripts/test-cli-comprehensive.sh` | **PASS** (**203 passed** / 7 skipped) | | **Native Integration Suite** | `LIVE=0 bash scripts/test-native-integration.sh` | **PASS** (**19 passed** / 1 skipped) | diff --git a/README.md b/README.md index 9f383c6..b6c7e7a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) -![Version](https://img.shields.io/badge/Version-v0.8.0-purple) +![Version](https://img.shields.io/badge/Version-v1.0.0-purple) > **Sovereign, self-hosted, Linux-native VPN and network control plane built around the kernel's WireGuard implementation.** @@ -129,7 +129,7 @@ That is why **"native Linux VPN + networking platform"** is the accurate descrip | **Reconciliation Engine** | **Implemented** | Closed-loop drift detection, read-only plan, and serialized apply | | **Web User Interface** | **Verified** | Zero-dependency SPA with theme engine and 15 interactive routes | | **Release & Deployment** | **Implemented** | Standalone installer, uninstaller, packaging script, and systemd unit | -| **SAFE Verification Suite** | **PASS** | 91 workspace tests, 203 CLI tests, 19 integration tests, 23 live tests | +| **SAFE Verification Suite** | **PASS** | 162 workspace tests, 203 CLI tests, 19 integration tests, 23 live tests | | **LIVE Kernel Verification** | **Framework Ready** | SAFE mode (`LIVE=0`) verified; dedicated host ready via `LIVE=1` | --- @@ -152,7 +152,7 @@ That is why **"native Linux VPN + networking platform"** is the accurate descrip # Build the release binary cargo build --release -# Run the complete test suite (91/91 passed) +# Run the complete test suite (162/162 passed) cargo test --workspace ``` @@ -194,8 +194,8 @@ To install `nx9-wg` as a managed systemd service: ```bash # Download and extract release archive: -tar -xzf nx9-wg-v0.8.0-linux-x86_64.tar.gz -cd nx9-wg-v0.8.0-linux-x86_64 +tar -xzf nx9-wg-v1.0.0-linux-x86_64.tar.gz +cd nx9-wg-v1.0.0-linux-x86_64 # Run production installer: sudo bash install.sh @@ -223,7 +223,7 @@ See the [**Installation & Deployment Guide**](docs/installation.md) for step-by- | **Security Architecture** | [**Security Model & Permissions**](docs/security.md) | | **Backup & Recovery** | [**Backup & Disaster Recovery**](docs/backup_restore.md) | | **Release Engineering** | [**Release Packaging & Systemd**](docs/release.md) | -| **Quality Assurance** | [**Testing Strategy**](docs/testing.md) | +| **Quality Assurance** | [**Comprehensive Testing Specification**](docs/TESTING.md) | | **Developer Guide** | [**Development Guide**](docs/development.md) | | **Configuration** | [**Configuration Reference**](docs/configuration.md) | | **Containerization** | [**Docker Deployment**](docs/docker.md) | diff --git a/crates/nx9-wg-api/src/reconciliation.rs b/crates/nx9-wg-api/src/reconciliation.rs index dcf97b8..377159b 100644 --- a/crates/nx9-wg-api/src/reconciliation.rs +++ b/crates/nx9-wg-api/src/reconciliation.rs @@ -3,6 +3,7 @@ use crate::error::{ApiError, ApiResult}; use crate::state::{AppState, SystemEvent}; use chrono::Utc; +use ipnet::IpNet; use nx9_wg_core::types::audit::AuditEventType; use nx9_wg_core::types::wireguard::PeerState; use nx9_wg_network::NetworkEngine; @@ -11,6 +12,36 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::time::Duration; +/// Check if a slice of live address strings contains the desired IpNet. +fn matches_ipnet(live_addrs: &[String], desired: &IpNet) -> bool { + live_addrs.iter().any(|s| { + if let Ok(net) = s.parse::() { + net.addr() == desired.addr() && net.prefix_len() == desired.prefix_len() + } else { + false + } + }) +} + +/// Check if live WireGuard peer allowed IPs match desired server-side allowed IPs. +fn matches_allowed_ips(live_allowed_ips: &[String], desired_str: &str) -> bool { + let desired_nets: std::collections::BTreeSet = desired_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect(); + + let live_nets: std::collections::BTreeSet = live_allowed_ips + .iter() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse::().ok()) + .collect(); + + desired_nets == live_nets +} + /// Individual action proposed or taken by the reconciler. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReconciliationAction { @@ -53,6 +84,8 @@ pub struct ReconciliationReport { #[serde(default)] pub status: ReconciliationStatus, pub executed_actions: usize, + #[serde(default)] + pub failed_actions: usize, pub details: Vec, } @@ -132,40 +165,66 @@ impl ReconciliationEngine { .ok() .flatten(); - let live_peer_keys: Vec = live_stats - .as_ref() - .map(|s| s.peers.iter().map(|p| p.public_key.clone()).collect()) - .unwrap_or_default(); + let iface_exists = live_interfaces.contains(&iface.name) || live_stats.is_some(); - match live_stats.as_ref() { - Some(stats) => { - if stats.public_key != iface.public_key.as_str() - || stats.listen_port != iface.listen_port + if iface_exists { + if let Some(stats) = live_stats.as_ref() { + let mut drift_reasons = Vec::new(); + + if !stats.public_key.is_empty() + && stats.public_key != iface.public_key.as_str() { + drift_reasons.push("public key mismatch".to_string()); + } + if stats.listen_port != 0 && stats.listen_port != iface.listen_port { + drift_reasons.push("listen port mismatch".to_string()); + } + if !matches_ipnet(&stats.addresses, &iface.address_v4) { + drift_reasons + .push(format!("missing IPv4 address '{}'", iface.address_v4)); + } + if let Some(ref v6) = iface.address_v6 + && !matches_ipnet(&stats.addresses, v6) + { + drift_reasons.push(format!("missing IPv6 address '{v6}'")); + } + if let Some(desired_mtu) = iface.mtu + && let Some(live_mtu) = stats.mtu + && live_mtu != desired_mtu as u32 + { + drift_reasons.push(format!( + "MTU mismatch (live: {live_mtu}, desired: {desired_mtu})" + )); + } + if !stats.is_up { + drift_reasons.push("interface link is down".to_string()); + } + + if !drift_reasons.is_empty() { plan.actions.push(ReconciliationAction { subsystem: "wireguard".to_string(), resource_id: iface.id.to_string(), action_type: "update_interface".to_string(), description: format!( - "Interface '{}' configuration drift detected; update listen port / keys", - iface.name + "Interface '{}' configuration drift detected ({}); synchronize link, address, port, or keys", + iface.name, + drift_reasons.join(", ") ), }); plan.interface_changes += 1; } } - None => { - plan.actions.push(ReconciliationAction { - subsystem: "wireguard".to_string(), - resource_id: iface.id.to_string(), - action_type: "create_interface".to_string(), - description: format!( - "Interface '{}' missing in kernel; create and sync", - iface.name - ), - }); - plan.interface_changes += 1; - } + } else { + plan.actions.push(ReconciliationAction { + subsystem: "wireguard".to_string(), + resource_id: iface.id.to_string(), + action_type: "create_interface".to_string(), + description: format!( + "Interface '{}' missing in kernel; create and sync", + iface.name + ), + }); + plan.interface_changes += 1; } // Check peers (only Active desired peers should be live) @@ -175,16 +234,86 @@ impl ReconciliationEngine { .filter(|p| p.state == PeerState::Active) .collect(); + let live_peers_map: std::collections::HashMap< + String, + &nx9_wireguard::LivePeerStats, + > = if let Some(ref stats) = live_stats { + stats + .peers + .iter() + .map(|p| (p.public_key.clone(), p)) + .collect() + } else { + std::collections::HashMap::new() + }; + for p in &active_desired_peers { - if !live_peer_keys.contains(&p.public_key.as_str().to_string()) { + let pub_key_str = p.public_key.as_str(); + let desired_server_allowed = p.server_wireguard_allowed_ips(); + + if let Some(live_p) = live_peers_map.get(pub_key_str) { + // Peer is present in live kernel interface. Verify semantic drift: + let mut peer_drifts = Vec::new(); + + if !matches_allowed_ips(&live_p.allowed_ips, &desired_server_allowed) { + peer_drifts.push(format!( + "AllowedIPs drift (live: [{:?}], desired: [{desired_server_allowed}])", + live_p.allowed_ips + )); + } + + if let (Some(desired_ka), Some(live_ka)) = + (p.persistent_keepalive, live_p.persistent_keepalive) + && live_ka != desired_ka + { + peer_drifts.push(format!( + "persistent keepalive drift (live: {live_ka}s, desired: {desired_ka}s)" + )); + } + + if !peer_drifts.is_empty() { + plan.actions.push(ReconciliationAction { + subsystem: "wireguard".to_string(), + resource_id: p.id.to_string(), + action_type: "update_peer".to_string(), + description: format!( + "Peer '{}' ({}) drift detected: {}; re-sync in kernel", + p.name, + pub_key_str, + peer_drifts.join(", ") + ), + }); + plan.peer_changes += 1; + } + + // Update operational telemetry (handshake timestamp and learned endpoint) from kernel + if live_p.last_handshake_at.is_some() || live_p.endpoint.is_some() { + let hs_newer = live_p.last_handshake_at.is_some() + && live_p.last_handshake_at != p.last_handshake_at; + let ep_newer = live_p.endpoint.is_some() + && live_p.endpoint.as_deref() != p.endpoint.as_deref(); + + if hs_newer || ep_newer { + let _ = self + .state + .store + .update_peer_learned_telemetry( + p.id, + live_p.last_handshake_at.or(p.last_handshake_at), + live_p.endpoint.as_deref().or(p.endpoint.as_deref()), + ) + .await; + } + } + } else { plan.actions.push(ReconciliationAction { subsystem: "wireguard".to_string(), resource_id: p.id.to_string(), action_type: "add_peer".to_string(), description: format!( - "Peer '{}' ({}) missing in live interface", + "Peer '{}' ({}) missing in live interface; add to kernel with AllowedIPs [{desired_server_allowed}]", p.name, - p.public_key.as_str() + pub_key_str ), }); plan.peer_changes += 1; @@ -293,7 +422,7 @@ impl ReconciliationEngine { .await .unwrap_or_default(); - if expected_ruleset.trim() != active_ruleset.trim() { + if nx9_wg_network::has_nftables_drift(&expected_ruleset, &active_ruleset) { plan.actions.push(ReconciliationAction { subsystem: "firewall".to_string(), resource_id: "nftables".to_string(), @@ -340,6 +469,17 @@ impl ReconciliationEngine { // Sweep expired peers let _ = self.sweep_expired_peers().await; + let initial_plan = self.plan().await.unwrap_or_default(); + if !initial_plan.has_drift { + return Ok(ReconciliationReport { + success: true, + status: ReconciliationStatus::Converged, + executed_actions: 0, + failed_actions: 0, + details: vec!["System is already fully converged; zero drift detected".to_string()], + }); + } + let desired_interfaces = self.state.store.list_interfaces().await?; let mut details = Vec::new(); @@ -419,10 +559,28 @@ impl ReconciliationEngine { // 4. Verify post-apply convergence let post_plan = self.plan().await.unwrap_or_default(); - let (success, status) = if !post_plan.has_drift { - (true, ReconciliationStatus::Converged) + let (success, status, executed_actions, failed_actions) = if !post_plan.has_drift { + ( + true, + ReconciliationStatus::Converged, + initial_plan.actions.len(), + 0, + ) } else { - (false, ReconciliationStatus::DriftRemains) + let remaining = post_plan.actions.len(); + let completed = initial_plan.actions.len().saturating_sub(remaining); + for action in &post_plan.actions { + details.push(format!( + "Unresolved drift: [{}] {}", + action.subsystem, action.description + )); + } + ( + false, + ReconciliationStatus::DriftRemains, + completed, + remaining, + ) }; // 5. Audit reconciliation run @@ -435,8 +593,8 @@ impl ReconciliationEngine { Some("reconciliation"), None, Some(&format!( - "Reconciliation applied {} actions (status: {status:?})", - details.len() + "Reconciliation applied {} actions (status: {status:?}, failed: {failed_actions})", + executed_actions )), None, None, @@ -446,8 +604,8 @@ impl ReconciliationEngine { self.state.broadcast(SystemEvent::AuditEvent { event_type: AuditEventType::ReconciliationRun, message: Some(format!( - "Reconciliation applied {} actions (status: {status:?})", - details.len() + "Reconciliation applied {} actions (status: {status:?}, failed: {failed_actions})", + executed_actions )), resource_type: Some("reconciliation".to_string()), resource_id: None, @@ -456,7 +614,8 @@ impl ReconciliationEngine { Ok(ReconciliationReport { success, status, - executed_actions: details.len(), + executed_actions, + failed_actions, details, }) } diff --git a/crates/nx9-wg-api/src/routes/app_client_js.js b/crates/nx9-wg-api/src/routes/app_client_js.js index 1723700..f9fd8e3 100644 --- a/crates/nx9-wg-api/src/routes/app_client_js.js +++ b/crates/nx9-wg-api/src/routes/app_client_js.js @@ -3,12 +3,65 @@ 'use strict'; // ── State ─────────────────────────────────────────────────────────────────── + let isAuthenticated = false; + let currentUser = null; let currentPage = 'dashboard'; let ws = null; let peersData = []; let interfacesData = []; let networksData = []; + // ── Authentication & UI State Transitions ────────────────────────────────── + function showAuthenticatedState(user) { + isAuthenticated = true; + currentUser = user || { username: 'admin' }; + const loginView = document.getElementById('login-view'); + const appLayout = document.getElementById('app-layout'); + const adminLabel = document.getElementById('admin-user-label'); + const loginPassword = document.getElementById('login-password'); + const errorMsg = document.getElementById('login-error-msg'); + + if (loginView) loginView.style.display = 'none'; + if (appLayout) appLayout.style.display = 'flex'; + if (adminLabel && currentUser && currentUser.username) { + adminLabel.textContent = currentUser.username; + } + if (loginPassword) loginPassword.value = ''; + if (errorMsg) { + errorMsg.style.display = 'none'; + errorMsg.textContent = ''; + } + + initWebSocket(); + } + + function showUnauthenticatedState() { + isAuthenticated = false; + currentUser = null; + peersData = []; + interfacesData = []; + networksData = []; + + closeWebSocket(); + + const appLayout = document.getElementById('app-layout'); + const loginView = document.getElementById('login-view'); + const pageContainer = document.getElementById('page-container'); + const loginPassword = document.getElementById('login-password'); + + if (appLayout) appLayout.style.display = 'none'; + if (loginView) loginView.style.display = 'flex'; + if (pageContainer) { + pageContainer.innerHTML = ''; + } + if (loginPassword) { + loginPassword.value = ''; + setTimeout(() => loginPassword.focus(), 50); + } + window.closeModal(); + window.closeSidebar(); + } + // ── Theme Management ──────────────────────────────────────────────────────── function initTheme() { const saved = localStorage.getItem('nx9_wg_theme') || 'dark'; @@ -54,7 +107,24 @@ }); // ── WebSocket Live Connection ─────────────────────────────────────────────── + function closeWebSocket() { + if (ws) { + try { + ws.onclose = null; + ws.close(); + } catch (_) {} + ws = null; + } + const dot = document.getElementById('ws-dot'); + const indicator = document.getElementById('ws-indicator'); + if (dot) dot.style.color = 'var(--status-fail-text)'; + if (indicator) indicator.classList.remove('connected'); + } + function initWebSocket() { + if (!isAuthenticated) return; + closeWebSocket(); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`; @@ -77,12 +147,15 @@ const indicator = document.getElementById('ws-indicator'); if (dot) dot.style.color = 'var(--status-fail-text)'; if (indicator) indicator.classList.remove('connected'); - setTimeout(initWebSocket, 3000); + if (isAuthenticated) { + setTimeout(initWebSocket, 3000); + } }; } catch (_) {} } function handleLiveEvent(evt) { + if (!isAuthenticated) return; if (currentPage === 'dashboard' || currentPage === 'live-state' || currentPage === 'reconciliation') { renderPage(currentPage); } @@ -90,6 +163,15 @@ // ── Navigation Router ─────────────────────────────────────────────────────── window.navigateTo = function(pageId) { + if (!isAuthenticated) { + showUnauthenticatedState(); + return; + } + + if (!pageId || pageId === 'login') { + pageId = 'dashboard'; + } + currentPage = pageId; window.location.hash = pageId; window.closeSidebar(); @@ -105,7 +187,28 @@ renderPage(pageId); }; - // ── API Helpers ───────────────────────────────────────────────────────────── + // ── Error Extraction & API Helpers ────────────────────────────────────────── + window.extractErrorMessage = function(err) { + if (!err) return 'Unknown error occurred'; + if (typeof err === 'string') return err; + if (typeof err === 'object') { + if (err.error) { + if (typeof err.error === 'string') return err.error; + if (typeof err.error === 'object') { + if (err.error.message && typeof err.error.message === 'string') return err.error.message; + if (err.error.code && typeof err.error.code === 'string') return `[${err.error.code}] ${err.error.message || 'Validation error'}`; + } + } + if (err.message && typeof err.message === 'string') return err.message; + try { + return JSON.stringify(err); + } catch (_) { + return 'An error occurred'; + } + } + return String(err); + }; + async function api(path, options = {}) { try { const res = await fetch(`/api/v1${path}`, { @@ -116,7 +219,7 @@ ...options }); if (res.status === 401 && !path.includes('/auth/login')) { - renderLoginPage(); + showUnauthenticatedState(); return null; } if (!res.ok) { @@ -124,8 +227,9 @@ try { errData = await res.json(); } catch (_) {} + const errorMsg = extractErrorMessage(errData || `HTTP ${res.status}: ${res.statusText}`); return { - error: errData?.error || errData?.message || `HTTP ${res.status}: ${res.statusText}`, + error: errorMsg, status: res.status }; } @@ -137,8 +241,17 @@ } window.handleLogout = async function() { - await api('/auth/logout', { method: 'POST' }); - renderLoginPage(); + try { + await fetch('/api/v1/auth/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + } catch (e) { + console.error('Logout error:', e); + } finally { + showUnauthenticatedState(); + window.location.hash = ''; + } }; // ── Clipboard Copy ────────────────────────────────────────────────────────── @@ -305,10 +418,11 @@