feat: NostrVPN mesh + VPN card UI + nvpn v0.3.7
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user