feat: NostrVPN mesh + VPN card UI + nvpn v0.3.7
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-08 15:00:00 +02:00
parent 22da11a16d
commit e977600471
12 changed files with 765 additions and 102 deletions

View File

@@ -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,

View File

@@ -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<serde_json::Value> {
// 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/<network_id>?npub=<npub>&relays=<csv>
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::<qrcode::render::svg::Color>()
.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<serde_json::Value>,
) -> Result<serde_json::Value> {
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::<toml::Table>() {
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=<key>\npublic_key=<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<serde_json::Value> {
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::<serde_json::Value>(&content) {
if let Ok(mut peer) = serde_json::from_str::<serde_json::Value>(&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::<toml::Table>() {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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::<qrcode::render::svg::Color>()
.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::<serde_json::Value>(&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 {

View File

@@ -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<String> {
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<String> {
for path in NVPN_CONFIG_PATHS {
if let Ok(content) = tokio::fs::read_to_string(path).await {
if let Ok(table) = content.parse::<toml::Table>() {
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<String>, Option<String>) {
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<String> {
for path in NVPN_CONFIG_PATHS {
if let Ok(content) = tokio::fs::read_to_string(path).await {
if let Ok(table) = content.parse::<toml::Table>() {
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<VpnStatus> {
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::<serde_json::Value>(&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",