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?; 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(), })) } /// 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.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 let (peer_private, peer_public) = if lines.len() >= 2 { (lines[0].trim().to_string(), lines[1].trim().to_string()) } 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(); // 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(); 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, "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(); 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. 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(); 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) { peers.push(peer); } } } } } Ok(serde_json::json!({ "peers": peers })) } /// 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); if tokio::fs::remove_file(&peer_file).await.is_ok() { info!("VPN peer removed: {}", name); Ok(serde_json::json!({ "removed": true })) } else { anyhow::bail!("Peer '{}' not found", name); } } }