use super::RpcHandler; use crate::vpn; use anyhow::Result; use tracing::info; impl RpcHandler { /// vpn.status — Get current VPN connection status. pub(super) async fn handle_vpn_status(&self) -> Result { 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, "interface": status.interface, "ip_address": status.ip_address, "hostname": status.hostname, "peers_connected": status.peers_connected, "bytes_in": status.bytes_in, "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, })) } /// vpn.configure — Configure VPN (Tailscale or WireGuard). pub(super) async fn handle_vpn_configure( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let provider = params .get("provider") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'provider' (tailscale or wireguard)"))?; match provider { "tailscale" => { let auth_key = params .get("auth_key") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'auth_key' for Tailscale"))?; vpn::configure_tailscale(auth_key, &self.config.data_dir).await?; info!("Tailscale VPN configured"); Ok(serde_json::json!({ "configured": true, "provider": "tailscale", })) } "wireguard" => { let address = params .get("address") .and_then(|v| v.as_str()) .unwrap_or("10.0.0.1/24"); let dns = params .get("dns") .and_then(|v| v.as_str()) .unwrap_or("1.1.1.1"); let peer = if let Some(peer_obj) = params.get("peer") { let public_key = peer_obj .get("public_key") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing peer public_key"))?; let endpoint = peer_obj .get("endpoint") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing peer endpoint"))?; let allowed_ips = peer_obj .get("allowed_ips") .and_then(|v| v.as_str()) .unwrap_or("0.0.0.0/0"); let keepalive = peer_obj .get("persistent_keepalive") .and_then(|v| v.as_u64()) .map(|v| v as u16); Some(vpn::WireGuardPeer { public_key: public_key.to_string(), endpoint: endpoint.to_string(), allowed_ips: allowed_ips.to_string(), persistent_keepalive: keepalive, }) } else { None }; let wg_config = vpn::configure_wireguard( &self.config.data_dir, address, dns, peer, ) .await?; info!("WireGuard VPN configured"); Ok(serde_json::json!({ "configured": true, "provider": "wireguard", "public_key": wg_config.public_key, "address": wg_config.address, })) } _ => { anyhow::bail!("Unknown provider: {} (expected tailscale or wireguard)", provider); } } } /// remote.setup — One-click Tailscale remote access setup. /// Accepts an auth key, configures Tailscale, and restricts access to ports 80/443. pub(super) async fn handle_remote_setup( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let auth_key = params .get("auth_key") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'auth_key' — get one from https://login.tailscale.com/admin/settings/keys"))?; // Configure Tailscale vpn::configure_tailscale(auth_key, &self.config.data_dir).await?; info!("Remote access: Tailscale configured"); // Set ACL-like port restrictions via iptables on tailscale0 // Allow only HTTP (80) and HTTPS (443) on the Tailscale interface let restrict_cmds = [ "sudo iptables -D INPUT -i tailscale0 -p tcp --dport 80 -j ACCEPT 2>/dev/null; true", "sudo iptables -D INPUT -i tailscale0 -p tcp --dport 443 -j ACCEPT 2>/dev/null; true", "sudo iptables -D INPUT -i tailscale0 -j DROP 2>/dev/null; true", "sudo iptables -A INPUT -i tailscale0 -p tcp --dport 80 -j ACCEPT", "sudo iptables -A INPUT -i tailscale0 -p tcp --dport 443 -j ACCEPT", "sudo iptables -A INPUT -i tailscale0 -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT", "sudo iptables -A INPUT -i tailscale0 -j DROP", ]; for cmd in &restrict_cmds { let _ = tokio::process::Command::new("sh") .arg("-c") .arg(cmd) .output() .await; } info!("Remote access: Restricted Tailscale to ports 80/443"); // Get the Tailscale IP for display let status = vpn::get_status().await; let tailscale_ip = status.ip_address.clone().unwrap_or_default(); let hostname = status.hostname.clone().unwrap_or_default(); // Build the remote access URL let remote_url = if !hostname.is_empty() { format!("http://{}", hostname) } else if !tailscale_ip.is_empty() { format!("http://{}", tailscale_ip) } else { String::new() }; Ok(serde_json::json!({ "configured": true, "provider": "tailscale", "tailscale_ip": tailscale_ip, "hostname": hostname, "remote_url": remote_url, "ports_exposed": [80, 443], })) } /// vpn.disconnect — Disable VPN. pub(super) async fn handle_vpn_disconnect(&self) -> Result { let mut config = vpn::load_config(&self.config.data_dir).await?; config.enabled = false; vpn::save_config(&self.config.data_dir, &config).await?; // Try to bring down the interface match config.provider { vpn::VpnProvider::Tailscale => { let _ = tokio::process::Command::new("podman") .args(["exec", "tailscale", "tailscale", "down"]) .output() .await; } vpn::VpnProvider::Wireguard => { let _ = tokio::process::Command::new("wg-quick") .args(["down", "wg0"]) .output() .await; } vpn::VpnProvider::NostrVpn => { let _ = tokio::process::Command::new("systemctl") .args(["stop", "nostr-vpn"]) .output() .await; } } info!("VPN disconnected"); 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, params: Option, ) -> Result { let params = params.unwrap_or(serde_json::json!({})); let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Mobile"); // Get server status for endpoint info let status = vpn::get_status().await; if !status.connected { anyhow::bail!("NostrVPN is not running. Start VPN first."); } // Generate a keypair for the new peer via nvpn keygen let keygen = tokio::process::Command::new("nvpn") .arg("keygen") .output() .await .map_err(|e| anyhow::anyhow!("nvpn keygen failed: {}", e))?; if !keygen.status.success() { anyhow::bail!("nvpn keygen failed: {}", String::from_utf8_lossy(&keygen.stderr)); } 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 (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 { (parse_key(lines[0]), parse_key(lines[1])) } else { anyhow::bail!("Unexpected keygen output: {}", keygen_output); }; // 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"))?; // 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) let peer_num = (name.bytes().map(|b| b as u32).sum::() % 253) + 2; let peer_ip = format!("10.44.0.{}/32", peer_num); // Build WireGuard config for the mobile device let wg_config = format!( "[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n", peer_private, peer_ip, server_pubkey, endpoint ); // Generate QR code as SVG let qr = qrcode::QrCode::new(wg_config.as_bytes()) .map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?; let svg = qr.render::() .min_dimensions(256, 256) .build(); // Save peer info let peers_dir = self.config.data_dir.join("nostr-vpn/peers"); tokio::fs::create_dir_all(&peers_dir).await.ok(); let peer_info = serde_json::json!({ "name": name, "public_key": peer_public, "ip": peer_ip, "config": wg_config, "created": chrono::Utc::now().to_rfc3339(), }); tokio::fs::write( peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))), 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!({ "name": name, "peer_ip": peer_ip, "config": wg_config, "qr_svg": svg, "public_key": peer_public, })) } /// 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(mut peer) = serde_json::from_str::(&content) { peer.as_object_mut().map(|o| o.insert("type".to_string(), "wireguard".into())); peers.push(peer); } } } } } // 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, 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); // 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 { anyhow::bail!("Peer '{}' not found", name); } } }