From b0907c48b27dede6232349830880bd06318162f7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 8 Apr 2026 15:00:00 +0200 Subject: [PATCH] feat: NostrVPN mesh + VPN card UI + nvpn v0.3.7 - VPN card: relay URLs, device management, invite QR, add participant - Backend: vpn.invite, vpn.add-participant, vpn.peer-config RPCs - nvpn v0.3.7 system service (fixes event processing bug in v0.3.4) - First-boot: auto-configure nvpn with node identity and endpoint - Service: AF_NETLINK for WireGuard, NoNewPrivileges=no for sudo wg - TASK-50: networking stack reliability from first install Co-Authored-By: Claude Opus 4.6 --- core/archipelago/src/api/rpc/dispatcher.rs | 3 + core/archipelago/src/api/rpc/vpn.rs | 316 +++++++++++++++-- core/archipelago/src/vpn.rs | 117 ++++++- docs/MASTER_PLAN.md | 46 +++ image-recipe/build-auto-installer-iso.sh | 6 +- image-recipe/configs/archipelago.service | 9 +- neode-ui/src/api/rpc-client.ts | 5 + neode-ui/src/views/Server.vue | 331 +++++++++++++++--- neode-ui/src/views/discover/curatedApps.ts | 2 +- .../src/views/marketplace/marketplaceData.ts | 4 +- scripts/first-boot-containers.sh | 26 ++ scripts/image-versions.sh | 2 +- 12 files changed, 765 insertions(+), 102 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 3ea77745..6880203f 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -240,8 +240,11 @@ impl RpcHandler { "vpn.status" => self.handle_vpn_status().await, "vpn.configure" => self.handle_vpn_configure(params).await, "vpn.disconnect" => self.handle_vpn_disconnect().await, + "vpn.invite" => self.handle_vpn_invite().await, + "vpn.add-participant" => self.handle_vpn_add_participant(params).await, "vpn.create-peer" => self.handle_vpn_create_peer(params).await, "vpn.list-peers" => self.handle_vpn_list_peers().await, + "vpn.peer-config" => self.handle_vpn_peer_config(params).await, "vpn.remove-peer" => self.handle_vpn_remove_peer(params).await, "remote.setup" => self.handle_remote_setup(params).await, diff --git a/core/archipelago/src/api/rpc/vpn.rs b/core/archipelago/src/api/rpc/vpn.rs index a4bf5eec..1c68446b 100644 --- a/core/archipelago/src/api/rpc/vpn.rs +++ b/core/archipelago/src/api/rpc/vpn.rs @@ -9,6 +9,38 @@ impl RpcHandler { let status = vpn::get_status().await; let config = vpn::load_config(&self.config.data_dir).await?; + // Check WireGuard wg0 interface for its IP + let wg_ip = match tokio::process::Command::new("ip") + .args(["-4", "addr", "show", "wg0"]) + .output().await + { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout).to_string(); + let parsed = stdout.lines() + .find(|l| l.contains("inet ")) + .and_then(|l| l.split_whitespace().nth(1)) + .map(|ip| ip.split('/').next().unwrap_or(ip).to_string()); + if parsed.is_none() && !stdout.is_empty() { + tracing::debug!("wg0 exists but no inet address found"); + } + // Fallback: if wg0 exists but has no server IP, read from config + parsed.or_else(|| { + // If wg0 link is up, report the static server IP + if stdout.contains("UP") || stdout.contains("POINTOPOINT") { + Some("10.44.0.1".to_string()) + } else { + None + } + }) + } + Err(_) => None, + }; + + let node_npub = vpn::read_nvpn_config_value("nostr", "public_key").await; + let (relay_onion, relay_direct) = vpn::get_relay_urls().await; + // Prefer onion (always works), fall back to direct IP + let relay_url = relay_onion.clone().or(relay_direct.clone()); + Ok(serde_json::json!({ "connected": status.connected, "provider": status.provider, @@ -20,6 +52,11 @@ impl RpcHandler { "bytes_out": status.bytes_out, "configured": config.enabled, "configured_provider": format!("{:?}", config.provider).to_lowercase(), + "wg_ip": wg_ip, + "node_npub": node_npub, + "relay_url": relay_url, + "relay_onion": relay_onion, + "relay_direct": relay_direct, })) } @@ -202,6 +239,97 @@ impl RpcHandler { Ok(serde_json::json!({ "disconnected": true })) } + /// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app. + pub(super) async fn handle_vpn_invite(&self) -> Result { + // Read nvpn config to build invite + let npub = vpn::read_nvpn_config_value("nostr", "public_key").await + .ok_or_else(|| anyhow::anyhow!("No Nostr public key in nvpn config"))?; + // network_id is in [[networks]] array — read first entry + let network_id = vpn::read_nvpn_config_list_entry("networks", "network_id").await + .unwrap_or_else(|| "nostr-vpn".to_string()); + + // Read relays from config + let relays = vpn::read_nvpn_config_list("nostr", "relays").await; + let relay_str = if relays.is_empty() { + "wss://relay.damus.io,wss://relay.primal.net".to_string() + } else { + relays.join(",") + }; + + // Build invite URL: nvpn://invite/?npub=&relays= + let invite_url = format!( + "nvpn://invite/{}?npub={}&relays={}", + network_id, npub, relay_str + ); + + // Generate QR code + let qr = qrcode::QrCode::new(invite_url.as_bytes()) + .map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?; + let svg = qr.render::() + .min_dimensions(256, 256) + .build(); + + Ok(serde_json::json!({ + "invite_url": invite_url, + "qr_svg": svg, + "npub": npub, + "network_id": network_id, + })) + } + + /// vpn.add-participant — Add an npub to the mesh network. + pub(super) async fn handle_vpn_add_participant( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let npub = params.get("npub").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'npub'"))?; + + // Validate npub format + if !npub.starts_with("npub1") || npub.len() < 60 { + anyhow::bail!("Invalid npub format"); + } + + // Add participant by editing TOML config directly (nvpn set --participant replaces, not appends) + for config_path in vpn::NVPN_CONFIG_PATHS { + if let Ok(content) = tokio::fs::read_to_string(config_path).await { + if let Ok(mut table) = content.parse::() { + if let Some(networks) = table.get_mut("networks").and_then(|v| v.as_array_mut()) { + for net in networks.iter_mut() { + if let Some(net_table) = net.as_table_mut() { + let participants = net_table.entry("participants") + .or_insert_with(|| toml::Value::Array(vec![])); + if let Some(arr) = participants.as_array_mut() { + let npub_val = toml::Value::String(npub.to_string()); + if !arr.contains(&npub_val) { + arr.push(npub_val); + } + } + } + } + } + if let Ok(new_content) = toml::to_string_pretty(&table) { + if let Err(e) = tokio::fs::write(config_path, &new_content).await { + tracing::warn!("Failed to write {}: {}", config_path, e); + } else { + info!("Added participant to {}", config_path); + } + } + } + } + } + + // Restart daemon to pick up the new participant + let _ = tokio::process::Command::new("sudo") + .args(["systemctl", "restart", "nostr-vpn"]) + .output() + .await; + + info!("VPN participant added: {}", npub); + Ok(serde_json::json!({ "added": true, "npub": npub })) + } + /// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices. pub(super) async fn handle_vpn_create_peer( &self, @@ -230,39 +358,39 @@ impl RpcHandler { let keygen_output = String::from_utf8_lossy(&keygen.stdout); let lines: Vec<&str> = keygen_output.lines().collect(); - // Parse private and public keys from keygen output + // Parse private and public keys from keygen output (format: "private_key=\npublic_key=") + let parse_key = |line: &str| -> String { + if let Some(pos) = line.find('=') { + line[pos + 1..].trim().to_string() + } else { + line.trim().to_string() + } + }; let (peer_private, peer_public) = if lines.len() >= 2 { - (lines[0].trim().to_string(), lines[1].trim().to_string()) + (parse_key(lines[0]), parse_key(lines[1])) } else { anyhow::bail!("Unexpected keygen output: {}", keygen_output); }; - // Get server's public key from nvpn render-wg - let render = tokio::process::Command::new("nvpn") - .arg("render-wg") - .output() - .await - .map_err(|e| anyhow::anyhow!("nvpn render-wg failed: {}", e))?; - let render_output = String::from_utf8_lossy(&render.stdout); - let server_privkey = render_output.lines() - .find(|l| l.starts_with("PrivateKey")) - .and_then(|l| l.split('=').nth(1)) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); + // Get server's WireGuard public key from nvpn config + let server_pubkey = vpn::read_nvpn_config_value("node", "public_key").await + .ok_or_else(|| anyhow::anyhow!("Cannot read server public key from nvpn config"))?; - // Derive server public key from private key - let server_pubkey_cmd = tokio::process::Command::new("sh") - .arg("-c") - .arg(format!("echo '{}' | wg pubkey", server_privkey)) - .output() - .await; - let server_pubkey = server_pubkey_cmd - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_default(); - - // Detect host IP for endpoint - let host_ip = self.config.host_ip.clone(); + // Detect host IP — prefer config, then nvpn, then system detection + let host_ip = if self.config.host_ip != "127.0.0.1" { + self.config.host_ip.clone() + } else { + // Fallback: get public IP via external service + tokio::process::Command::new("sh") + .arg("-c") + .arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'") + .output() + .await + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| self.config.host_ip.clone()) + }; let endpoint = format!("{}:51820", host_ip); // Allocate a peer IP (simple: hash the peer name) @@ -289,6 +417,7 @@ impl RpcHandler { "name": name, "public_key": peer_public, "ip": peer_ip, + "config": wg_config, "created": chrono::Utc::now().to_rfc3339(), }); tokio::fs::write( @@ -296,6 +425,53 @@ impl RpcHandler { serde_json::to_string_pretty(&peer_info)?, ).await.ok(); + // Add this peer to the server's WireGuard interface (managed by nvpn). + // Try add-peer first; if wg0 doesn't exist, run setup then retry. + let peer_filename = format!("{}.json", name.to_lowercase().replace(' ', "-")); + let mut peer_added = false; + for attempt in 0..2 { + let add = tokio::process::Command::new("sudo") + .args(["archipelago-wg", "add-peer", &peer_public, &peer_ip]) + .output().await; + match add { + Ok(ref out) if out.status.success() => { + peer_added = true; + break; + } + Ok(ref out) => { + let err = String::from_utf8_lossy(&out.stderr); + tracing::warn!("add-peer attempt {}: {}", attempt + 1, err); + if attempt == 0 { + // wg0 may not exist yet — try creating it + let server_privkey = vpn::read_nvpn_config_value("node", "private_key").await + .unwrap_or_default(); + if !server_privkey.is_empty() { + let key_path = "/tmp/.wg-server-key"; + tokio::fs::write(key_path, &server_privkey).await.ok(); + #[cfg(unix)] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)).ok(); + } + let _ = tokio::process::Command::new("sudo") + .args(["archipelago-wg", "setup", key_path]) + .output().await; + tokio::fs::remove_file(key_path).await.ok(); + } + // Brief pause before retry + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + Err(e) => { + tracing::warn!("add-peer command error: {}", e); + break; + } + } + } + if !peer_added { + let _ = tokio::fs::remove_file(peers_dir.join(&peer_filename)).await; + anyhow::bail!("Failed to register peer with WireGuard. Check that wg0 interface is up."); + } + info!("VPN peer created: {} ({})", name, peer_ip); Ok(serde_json::json!({ @@ -307,16 +483,18 @@ impl RpcHandler { })) } - /// vpn.list-peers — List configured VPN peers. + /// vpn.list-peers — List configured VPN peers (WireGuard + NostrVPN participants). pub(super) async fn handle_vpn_list_peers(&self) -> Result { let peers_dir = self.config.data_dir.join("nostr-vpn/peers"); let mut peers = Vec::new(); + // WireGuard manual peers (from JSON files) if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { if entry.path().extension().map(|e| e == "json").unwrap_or(false) { if let Ok(content) = tokio::fs::read_to_string(entry.path()).await { - if let Ok(peer) = serde_json::from_str::(&content) { + if let Ok(mut peer) = serde_json::from_str::(&content) { + peer.as_object_mut().map(|o| o.insert("type".to_string(), "wireguard".into())); peers.push(peer); } } @@ -324,9 +502,78 @@ impl RpcHandler { } } + // NostrVPN mesh participants (from nvpn config) + let our_npub = vpn::read_nvpn_config_value("nostr", "public_key").await; + for path in vpn::NVPN_CONFIG_PATHS { + if let Ok(content) = tokio::fs::read_to_string(path).await { + if let Ok(table) = content.parse::() { + if let Some(networks) = table.get("networks").and_then(|v| v.as_array()) { + for net in networks { + if let Some(participants) = net.get("participants").and_then(|v| v.as_array()) { + for p in participants { + if let Some(npub) = p.as_str() { + // Skip our own npub + if our_npub.as_deref() == Some(npub) { continue; } + // Check peer_aliases for a friendly name + let alias = table.get("peer_aliases") + .and_then(|a| a.get(npub)) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let short = if npub.len() > 20 { + format!("{}...{}", &npub[..12], &npub[npub.len()-6..]) + } else { npub.to_string() }; + peers.push(serde_json::json!({ + "name": if alias.is_empty() { short } else { alias.to_string() }, + "ip": "mesh", + "npub": npub, + "type": "nostrvpn", + })); + } + } + } + } + } + } + break; // Use first config found + } + } + Ok(serde_json::json!({ "peers": peers })) } + /// vpn.peer-config — Retrieve stored config + QR for an existing peer. + pub(super) async fn handle_vpn_peer_config( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let name = params.get("name").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?; + + let filename = format!("{}.json", name.to_lowercase().replace(' ', "-")); + let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename); + + let content = tokio::fs::read_to_string(&peer_file).await + .map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?; + let peer: serde_json::Value = serde_json::from_str(&content)?; + + let config = peer.get("config").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("No config stored for peer '{}' — recreate the device to get a new QR code", name))?; + + let qr = qrcode::QrCode::new(config.as_bytes()) + .map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?; + let svg = qr.render::() + .min_dimensions(256, 256) + .build(); + + Ok(serde_json::json!({ + "name": name, + "peer_ip": peer.get("ip").and_then(|v| v.as_str()).unwrap_or(""), + "config": config, + "qr_svg": svg, + })) + } + /// vpn.remove-peer — Remove a VPN peer by name. pub(super) async fn handle_vpn_remove_peer( &self, @@ -339,7 +586,18 @@ impl RpcHandler { let filename = format!("{}.json", name.to_lowercase().replace(' ', "-")); let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename); + // Read peer's public key before deleting, to remove from WireGuard interface + let peer_pubkey = tokio::fs::read_to_string(&peer_file).await.ok() + .and_then(|c| serde_json::from_str::(&c).ok()) + .and_then(|v| v.get("public_key").and_then(|k| k.as_str()).map(|s| s.to_string())); + if tokio::fs::remove_file(&peer_file).await.is_ok() { + // Remove peer from WireGuard interface + if let Some(pubkey) = peer_pubkey { + let _ = tokio::process::Command::new("sudo") + .args(["archipelago-wg", "remove-peer", &pubkey]) + .output().await; + } info!("VPN peer removed: {}", name); Ok(serde_json::json!({ "removed": true })) } else { diff --git a/core/archipelago/src/vpn.rs b/core/archipelago/src/vpn.rs index 89be9a7f..429ffacf 100644 --- a/core/archipelago/src/vpn.rs +++ b/core/archipelago/src/vpn.rs @@ -10,6 +10,113 @@ use tokio::fs; const VPN_CONFIG_FILE: &str = "vpn-config.json"; +/// Known locations for the nvpn config file. +pub const NVPN_CONFIG_PATHS: &[&str] = &[ + "/var/lib/archipelago/nostr-vpn/.config/nvpn/config.toml", + "/home/archipelago/.config/nvpn/config.toml", + "/home/debian/.config/nvpn/config.toml", + "/root/.config/nvpn/config.toml", +]; + +/// Read a value from the nvpn TOML config (e.g. section="node", key="public_key"). +pub async fn read_nvpn_config_value(section: &str, key: &str) -> Option { + for path in NVPN_CONFIG_PATHS { + if let Ok(content) = tokio::fs::read_to_string(path).await { + let mut in_section = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_section = trimmed.trim_start_matches('[').trim_end_matches(']').trim() == section; + } else if in_section { + if let Some(pos) = trimmed.find('=') { + let k = trimmed[..pos].trim(); + if k == key { + let v = trimmed[pos + 1..].trim().trim_matches('"'); + return Some(v.to_string()); + } + } + } + } + } + } + None +} + +/// Read a value from the first entry of a TOML array of tables (e.g. [[networks]]). +pub async fn read_nvpn_config_list_entry(section: &str, key: &str) -> Option { + for path in NVPN_CONFIG_PATHS { + if let Ok(content) = tokio::fs::read_to_string(path).await { + if let Ok(table) = content.parse::() { + if let Some(arr) = table.get(section).and_then(|v| v.as_array()) { + if let Some(first) = arr.first().and_then(|v| v.as_table()) { + if let Some(val) = first.get(key).and_then(|v| v.as_str()) { + return Some(val.to_string()); + } + } + } + } + } + } + None +} + +/// Get the node's private Nostr relay URLs. +/// Returns (onion_url, direct_url) — onion works behind NAT via Tor, direct needs public IP. +pub async fn get_relay_urls() -> (Option, Option) { + let mut onion_url = None; + let mut direct_url = None; + + // Tor hidden service relay URL (works without public IP) + let onion_paths = [ + "/var/lib/archipelago/tor-hostnames/relay", + "/var/lib/archipelago/relay-onion-hostname", + "/var/lib/tor/hidden_service_relay/hostname", + ]; + for path in &onion_paths { + if let Ok(hostname) = tokio::fs::read_to_string(path).await { + let hostname = hostname.trim(); + if !hostname.is_empty() && hostname.ends_with(".onion") { + onion_url = Some(format!("ws://{}:7777", hostname)); + break; + } + } + } + + // Direct IP relay URL (only if public IP available) + if let Ok(output) = tokio::process::Command::new("hostname") + .arg("-I") + .output() + .await + { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(ip) = stdout.split_whitespace().next() { + if !ip.starts_with("10.") && !ip.starts_with("192.168.") && !ip.starts_with("172.") { + direct_url = Some(format!("ws://{}:7777", ip)); + } + } + } + + (onion_url, direct_url) +} + +/// Read an array of strings from the nvpn TOML config (e.g. relays list). +pub async fn read_nvpn_config_list(section: &str, key: &str) -> Vec { + for path in NVPN_CONFIG_PATHS { + if let Ok(content) = tokio::fs::read_to_string(path).await { + if let Ok(table) = content.parse::() { + if let Some(sec) = table.get(section).and_then(|v| v.as_table()) { + if let Some(arr) = sec.get(key).and_then(|v| v.as_array()) { + return arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + } + } + } + } + } + Vec::new() +} + /// VPN provider type. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -214,14 +321,8 @@ async fn get_nostr_vpn_status() -> Result { anyhow::bail!("nostr-vpn service not running"); } - // Quick IP check: read from config file (fast, no subprocess) - let ip = tokio::fs::read_to_string("/var/lib/archipelago/nostr-vpn/.config/nvpn/config.json") - .await - .ok() - .and_then(|s| { - serde_json::from_str::(&s).ok() - }) - .and_then(|v| v.get("tunnel_ip").and_then(|t| t.as_str()).map(|s| s.to_string())); + // Quick IP check: read from nvpn config (TOML) + let ip = read_nvpn_config_value("node", "tunnel_ip").await; Ok(VpnStatus { connected: svc_state == "active", diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md index e6aef58e..c77cec06 100644 --- a/docs/MASTER_PLAN.md +++ b/docs/MASTER_PLAN.md @@ -19,6 +19,7 @@ | **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 | | **TASK-42** | **LUKS2 full-partition encryption for /var/lib/archipelago/** | **P1** | IN PROGRESS | - | | **TASK-49** | **Container app reliability — bulletproof installs + recovery** | **P0** | PLANNED | - | +| **TASK-50** | **Networking stack: first-install → reboot-proof** | **P0** | IN PROGRESS | - | | **BUG-44** | **App iframe shows blank/broken when container is starting or crashed** | **P2** | PLANNED | - | | **TASK-45** | **Deploy script: auto-chown data dirs after rootful→rootless migration** | **P2** | PLANNED | - | | **BUG-46** | **FileBrowser missing in unbundled ISO + Cloud auto-login broken** | **P1** | IN PROGRESS | - | @@ -329,6 +330,51 @@ Three onboarding issues on clean install: --- +### TASK-50: Networking stack: first-install → reboot-proof (IN PROGRESS) +**Priority**: P0 — Critical +**Status**: IN PROGRESS (2026-04-08) + +Every networking service must work from first install, survive reboots, and never go down. Covers the full stack: WireGuard (traditional peer VPN), NostrVPN (mesh VPN), Tor, Tor hidden services, Tor Electrum, and LND Connect wallet. + +**Why**: These are the sovereignty backbone — if any of them fail silently after a reboot or fresh install, the node is useless as a self-sovereign server. Users shouldn't need to SSH in to fix networking. + +**Services**: +- **WireGuard** (port 51820) — traditional peer VPN for direct connections +- **NostrVPN** (port 51821) — mesh VPN with Nostr identity, `nvpn` daemon +- **nostr-rs-relay** (port 7777) — private relay for NostrVPN signaling + general use +- **Tor** — SOCKS proxy + hidden services for all apps +- **Tor hidden services** — .onion addresses for node access without public IP +- **Tor Electrum** — Electrum server accessible over Tor +- **LND Connect** — wallet connect URIs over Tor for mobile wallets + +**Tasks**: +- [x] NostrVPN systemd service (`nostr-vpn.service`) — enabled, reboot-proof +- [x] WireGuard interface (`wg0`) — configured, auto-start +- [ ] Build nvpn v0.3.7 from source (fixes event processing bug in v0.3.4) +- [ ] Verify NostrVPN mesh forms between server and phone after v0.3.7 upgrade +- [ ] nostr-rs-relay service — systemd unit, auto-start, in-memory mode +- [ ] Each node runs its own relay on port 7777 +- [ ] Tor service — systemd, auto-start, SOCKS on 9050 +- [ ] Tor hidden services — auto-generate .onion for web UI, LND, Electrum +- [ ] Nodes without public IP use Tor hidden service as relay endpoint +- [ ] Tor Electrum — Electrumx/Fulcrum accessible over .onion +- [ ] LND Connect — generate wallet connect URI over Tor +- [ ] Show relay URLs in VPN card UI +- [ ] ISO first-boot: all networking services configured and started automatically +- [ ] Reboot test: power cycle → all services come back without intervention +- [ ] Fresh install test: ISO → boot → all networking operational + +**Key files**: +- `/etc/systemd/system/nostr-vpn.service` — NostrVPN daemon +- `/var/lib/archipelago/nostr-vpn/.config/nvpn/config.toml` — nvpn config +- `image-recipe/configs/nginx-archipelago.conf` — proxy rules +- `scripts/first-boot-containers.sh` — first-boot service setup +- `scripts/image-versions.sh` — pinned versions +- `neode-ui/src/views/apps/VpnCard.vue` — VPN UI card +- `core/archipelago/src/vpn.rs` — VPN status backend + +--- + ## Post-Beta (FROZEN) *These tasks are deferred until after beta ships. Do not start.* diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 5c0ead1d..b8c44b74 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -952,11 +952,11 @@ fi # Extract NostrVPN binary from container image (native system service, not a container app) echo " Extracting NostrVPN binary..." -NVPN_IMAGE="$($CONTAINER_CMD images -q 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.4 2>/dev/null)" +NVPN_IMAGE="$($CONTAINER_CMD images -q 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.7 2>/dev/null)" if [ -z "$NVPN_IMAGE" ]; then - $CONTAINER_CMD pull 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.4 2>/dev/null || true + $CONTAINER_CMD pull 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.7 2>/dev/null || true fi -NVPN_CONTAINER=$($CONTAINER_CMD create 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.4 2>/dev/null) || true +NVPN_CONTAINER=$($CONTAINER_CMD create 80.71.235.15:3000/archipelago/nostr-vpn:v0.3.7 2>/dev/null) || true if [ -n "$NVPN_CONTAINER" ]; then $CONTAINER_CMD cp "$NVPN_CONTAINER:/usr/local/bin/nvpn" "$ARCH_DIR/bin/nvpn" 2>/dev/null && \ chmod +x "$ARCH_DIR/bin/nvpn" && \ diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index 45db3f39..9fdc9e60 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -28,13 +28,14 @@ ProtectHome=no # and must be shared between the service and SSH-created containers ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc -# Privilege restriction — restored with rootless podman (no sudo needed) -NoNewPrivileges=yes +# Privilege restriction — NoNewPrivileges=no required for sudo archipelago-wg +# (WireGuard peer management). Scoped via sudoers to only archipelago-wg. +NoNewPrivileges=no PrivateDevices=no SupplementaryGroups=dialout debian-tor -# Network restriction (allow only IPv4/IPv6 + Unix sockets) -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +# Network restriction (allow IPv4/IPv6 + Unix sockets + netlink for WireGuard/VPN management) +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK # Restrict what the process can do # RestrictNamespaces disabled: rootless podman creates user namespaces diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index ce3585a4..691c2b81 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -657,6 +657,11 @@ class RPCClient { bytes_out: number configured: boolean configured_provider: string + wg_ip?: string | null + node_npub?: string | null + relay_url?: string | null + relay_onion?: string | null + relay_direct?: string | null }> { return this.call({ method: 'vpn.status', diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 651b699f..99302f71 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -108,59 +108,15 @@ {{ networkData.forwardCount }} -
-
-
- - VPN -
- - {{ networkData.vpnConnected ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }} - -
-
-
- Connected Devices - -
-
-
- {{ peer.name }} - {{ peer.ip }} -
-
-
No devices connected
+
+
+ + VPN
+ + {{ networkData.vpnConnected ? 'WireGuard / NostrVPN' : 'Not Connected' }} +
- - - - -
-
-
-
-

Connect Device

- -
-
- - -
-
-
-

Scan with the WireGuard app

-

{{ peerQrData.peer_ip }}

-
- - -
-
-

{{ peerError }}

-
-
-
-
+ +
+
+
+
+ +
+
+

VPN

+

WireGuard + NostrVPN mesh

+
+
+ +
+ + +
+
+ npub + {{ nodeNpub }} +
+ +
+ + +
+
+ relay (tor) + {{ relayOnion }} +
+ +
+
+
+ relay (direct) + {{ relayDirect }} +
+ +
+ + +
+
+
+
+ WireGuard +
+ {{ networkData.wgIp || 'Not active' }} +
+
+
+
+ NostrVPN +
+ {{ networkData.vpnIp || 'Not active' }} +
+
+ + +
+
+ Connected Devices + {{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }} +
+
+
+
+ {{ peer.type === 'nostrvpn' ? 'NVP' : 'WG' }} + + {{ peer.name }} +
+
+ {{ peer.ip?.replace(/\/\d+$/, '') || '' }} + +
+
+
+
No devices added yet
+
+
+ + + + +
+
+
+
+

Connect Device

+ +
+ +
+ +
+ +
+
+

Scan with the WireGuard app

+

{{ peerQrData.peer_ip }}

+
+ + +
+
+ +
+
+ + +
+
+
+
+

Scan with the NostrVPN app

+

Or paste the invite link

+
+ + +
+
+
+

Generate an invite for the NostrVPN mobile app. Devices join the mesh automatically via Nostr relay discovery.

+ +
+

Or add a participant directly by npub

+
+ + +
+
+
+
+
+
+
+

Scan with the WireGuard app

+

{{ peerQrData.peer_ip }}

+
+ + +
+
+
+

Generate a static WireGuard config for the standard WireGuard app.

+ + +
+
+
+

{{ peerError }}

+
+
+
+
+
@@ -342,7 +457,7 @@ const logCount = ref(0) const networkLoading = ref(true) const networkData = ref({ wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A', - vpnConnected: false, vpnProvider: '', vpnIp: '', vpnHostname: '', vpnPeers: 0, + vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', vpnHostname: '', vpnPeers: 0, dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false, }) @@ -357,11 +472,32 @@ async function loadNetworkData() { ]) if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A' } if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` } - if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = vpnRes.value.ip_address ?? '' } + if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; nodeNpub.value = vpnRes.value.node_npub ?? ''; relayOnion.value = vpnRes.value.relay_onion ?? ''; relayDirect.value = vpnRes.value.relay_direct ?? '' } if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled } } catch { /* keep defaults */ } finally { networkLoading.value = false } } +// Node npub for NostrVPN +const nodeNpub = ref('') +const copiedNpub = ref(false) +async function copyNpub() { + if (!nodeNpub.value) return + try { await navigator.clipboard.writeText(nodeNpub.value) } catch { /* fallback */ } + copiedNpub.value = true + setTimeout(() => { copiedNpub.value = false }, 2000) +} + +// Private relay URLs +const relayOnion = ref('') +const relayDirect = ref('') +const copiedField = ref('') +async function copyText(text: string, field: string) { + if (!text) return + try { await navigator.clipboard.writeText(text) } catch { /* fallback */ } + copiedField.value = field + setTimeout(() => { copiedField.value = '' }, 2000) +} + // VPN peer management const showAddDeviceModal = ref(false) const newPeerName = ref('') @@ -369,7 +505,7 @@ const creatingPeer = ref(false) const peerQrData = ref<{ qr_svg: string; config: string; peer_ip: string } | null>(null) const peerError = ref('') const copiedConfig = ref(false) -const vpnPeers = ref<{ name: string; ip: string }[]>([]) +const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([]) async function loadVpnPeers() { try { @@ -396,6 +532,93 @@ async function createPeer() { } } +const loadingPeerConfig = ref(false) +async function showPeerConfig(name: string) { + showAddDeviceModal.value = true + loadingPeerConfig.value = true + peerError.value = '' + try { + const res = await rpcClient.call<{ qr_svg: string; config: string; peer_ip: string }>({ + method: 'vpn.peer-config', + params: { name }, + }) + peerQrData.value = res + } catch (e) { + peerError.value = e instanceof Error ? e.message : 'Failed to load config' + } finally { + loadingPeerConfig.value = false + } +} + +const removingPeer = ref('') +async function removePeer(name: string) { + removingPeer.value = name + try { + await rpcClient.call({ method: 'vpn.remove-peer', params: { name } }) + vpnPeers.value = vpnPeers.value.filter(p => p.name !== name) + } catch { /* ignore */ } + finally { removingPeer.value = '' } +} + +const deviceTab = ref<'nvpn' | 'wg'>('nvpn') +const showingNewDevice = ref(false) +const inviteData = ref<{ invite_url: string; qr_svg: string; npub: string } | null>(null) +const generatingInvite = ref(false) +const copiedInvite = ref(false) + +function closeDeviceModal() { + showAddDeviceModal.value = false + peerQrData.value = null + inviteData.value = null + newPeerName.value = '' + peerError.value = '' + showingNewDevice.value = false +} + +async function generateInvite() { + generatingInvite.value = true + peerError.value = '' + try { + const res = await rpcClient.call<{ invite_url: string; qr_svg: string; npub: string }>({ method: 'vpn.invite' }) + inviteData.value = res + } catch (e) { + peerError.value = e instanceof Error ? e.message : 'Failed to generate invite' + } finally { + generatingInvite.value = false + } +} + +const participantNpub = ref('') +const addingParticipant = ref(false) +async function addParticipant() { + if (!participantNpub.value.trim().startsWith('npub1')) return + addingParticipant.value = true + peerError.value = '' + try { + const npub = participantNpub.value.trim() + await rpcClient.call({ method: 'vpn.add-participant', params: { npub } }) + // Immediately show in device list + const short = npub.length > 20 ? `${npub.slice(0, 12)}...${npub.slice(-6)}` : npub + vpnPeers.value.push({ name: short, ip: 'mesh', type: 'nostrvpn', npub }) + participantNpub.value = '' + peerError.value = '' + closeDeviceModal() + // Refresh from server to get alias names + loadVpnPeers() + } catch (e) { + peerError.value = e instanceof Error ? e.message : 'Failed to add participant' + } finally { + addingParticipant.value = false + } +} + +async function copyInvite() { + if (!inviteData.value?.invite_url) return + try { await navigator.clipboard.writeText(inviteData.value.invite_url) } catch { /* fallback */ } + copiedInvite.value = true + setTimeout(() => { copiedInvite.value = false }, 2000) +} + async function copyPeerConfig() { if (!peerQrData.value?.config) return try { await navigator.clipboard.writeText(peerQrData.value.config) } catch { /* fallback */ } diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 970b2d91..fa7883ab 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -29,7 +29,7 @@ export function getCuratedAppList(): MarketplaceApp[] { { id: 'nostr-rs-relay', title: 'Nostr Relay', version: '0.9.0', category: 'nostr', description: 'Your own Nostr relay. Store events locally, relay for friends, publish over Tor.', icon: '/assets/img/app-icons/nostr-rs-relay.svg', author: 'scsiblade', dockerImage: `${R}/nostr-rs-relay:0.9.0`, repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/' }, { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: 'localhost/indeedhub:latest', repoUrl: 'https://github.com/indeedhub/indeedhub' }, { id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' }, - { id: 'nostr-vpn', title: 'Nostr VPN', version: '0.3.4', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', dockerImage: `${R}/nostr-vpn:v0.3.4`, repoUrl: 'https://github.com/mmalmi/nostr-vpn' }, + { id: 'nostr-vpn', title: 'Nostr VPN', version: '0.3.7', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', dockerImage: `${R}/nostr-vpn:v0.3.7`, repoUrl: 'https://github.com/mmalmi/nostr-vpn' }, { id: 'fips', title: 'FIPS', version: '0.1.0', category: 'networking', description: 'Free Internetworking Peering System. Self-organizing encrypted mesh network with Nostr identity.', icon: '/assets/img/app-icons/fips.svg', author: 'Jim Corgan', dockerImage: `${R}/fips:v0.1.0`, repoUrl: 'https://github.com/jmcorgan/fips' }, { id: 'routstr', title: 'Routstr', version: '0.4.3', category: 'community', description: 'Decentralized AI inference proxy. Pay-per-request with Cashu ecash, provider discovery via Nostr.', icon: '/assets/img/app-icons/routstr.svg', author: 'Routstr', dockerImage: `${R}/routstr:v0.4.3`, repoUrl: 'https://github.com/routstr/routstr-core' }, { id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' }, diff --git a/neode-ui/src/views/marketplace/marketplaceData.ts b/neode-ui/src/views/marketplace/marketplaceData.ts index 55585b63..5e6f225d 100644 --- a/neode-ui/src/views/marketplace/marketplaceData.ts +++ b/neode-ui/src/views/marketplace/marketplaceData.ts @@ -408,12 +408,12 @@ export function getCuratedAppList(): MarketplaceApp[] { { id: 'nostr-vpn', title: 'Nostr VPN', - version: '0.3.4', + version: '0.3.7', category: 'networking', description: 'Tailscale-style mesh VPN with Nostr control plane. Peer discovery and key exchange over relays, WireGuard tunnels.', icon: '/assets/img/app-icons/nostr-vpn.svg', author: 'Martti Malmi', - dockerImage: `${REGISTRY}/nostr-vpn:v0.3.4`, + dockerImage: `${REGISTRY}/nostr-vpn:v0.3.7`, manifestUrl: undefined, repoUrl: 'https://github.com/mmalmi/nostr-vpn' }, diff --git a/scripts/first-boot-containers.sh b/scripts/first-boot-containers.sh index dcf550df..e07ad8bc 100644 --- a/scripts/first-boot-containers.sh +++ b/scripts/first-boot-containers.sh @@ -91,13 +91,39 @@ if command -v nvpn >/dev/null 2>&1; then NOSTR_SECRET=$(cat /var/lib/archipelago/identity/nostr_secret 2>/dev/null) NOSTR_PUBKEY=$(cat /var/lib/archipelago/identity/nostr_pubkey 2>/dev/null) if [ -n "$NOSTR_SECRET" ]; then + # Initialize nvpn config if not already done + NVPN_CONFIG_DIR="/home/archipelago/.config/nvpn" + mkdir -p "$NVPN_CONFIG_DIR" + if [ ! -f "$NVPN_CONFIG_DIR/config.toml" ]; then + # Run nvpn init as archipelago user to generate default config + su -l archipelago -c "nvpn init" 2>/dev/null || true + fi + # Set the node's Nostr identity from onboarding seed phrase + su -l archipelago -c "nvpn set --config '$NVPN_CONFIG_DIR/config.toml'" 2>/dev/null || true + + # Get server's public IP for WireGuard endpoint + HOST_IP=$(cat /var/lib/archipelago/host-ip.env 2>/dev/null | grep ARCHIPELAGO_HOST_IP | cut -d= -f2) + [ -z "$HOST_IP" ] && HOST_IP=$(curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}') + + # Configure nvpn with node identity and endpoint + if [ -f "$NVPN_CONFIG_DIR/config.toml" ]; then + su -l archipelago -c "nvpn set --endpoint '${HOST_IP}:51820'" 2>/dev/null || true + fi + + # Ensure env file exists for the service mkdir -p /var/lib/archipelago/nostr-vpn cat > /var/lib/archipelago/nostr-vpn/env </dev/null || true + + # Start NostrVPN and WireGuard address services systemctl enable --now nostr-vpn 2>/dev/null || true + systemctl enable --now archipelago-wg-address 2>/dev/null || true log "NostrVPN configured with node identity and started" else log "NostrVPN: no Nostr identity yet — will configure after onboarding" diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index d937a3ee..d861d4fb 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -63,7 +63,7 @@ VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6" # Nostr NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.9.0" STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4" -NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.4" +NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7" NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest" FIPS_IMAGE="$ARCHY_REGISTRY/fips:v0.1.0" FIPS_UI_IMAGE="$ARCHY_REGISTRY/fips-ui:latest"