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 <noreply@anthropic.com>
This commit is contained in:
parent
6c1f316956
commit
b0907c48b2
@ -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",
|
||||
|
||||
@ -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.*
|
||||
|
||||
@ -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" && \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -108,59 +108,15 @@
|
||||
</div>
|
||||
<span class="text-white/60 text-sm">{{ networkData.forwardCount }}</span>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<span class="text-white/80 text-sm">VPN</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
||||
{{ networkData.vpnConnected ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="networkData.vpnConnected" class="mt-3 pt-3 border-t border-white/10">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white/50">Connected Devices</span>
|
||||
<button @click="showAddDeviceModal = true" class="glass-button px-3 py-1 text-xs">Add Device</button>
|
||||
</div>
|
||||
<div v-if="vpnPeers.length" class="space-y-1">
|
||||
<div v-for="peer in vpnPeers" :key="peer.name" class="flex items-center justify-between text-xs py-1">
|
||||
<span class="text-white/70">{{ peer.name }}</span>
|
||||
<span class="text-white/40 font-mono">{{ peer.ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/40">No devices connected</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
<span class="text-white/80 text-sm">VPN</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
||||
{{ networkData.vpnConnected ? 'WireGuard / NostrVPN' : 'Not Connected' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="showAddDeviceModal = false">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
|
||||
<button @click="showAddDeviceModal = false" class="p-1 rounded hover:bg-white/10 text-white/60"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
|
||||
</div>
|
||||
<div v-if="!peerQrData">
|
||||
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
|
||||
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||
<button @click="peerQrData = null; newPeerName = ''" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<button class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left" @click="showDnsModal = true">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9" /></svg>
|
||||
@ -204,6 +160,165 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VPN Card -->
|
||||
<div class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">VPN</h2>
|
||||
<p class="text-xs text-white/50">WireGuard + NostrVPN mesh</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
|
||||
</div>
|
||||
|
||||
<!-- Node npub for sharing -->
|
||||
<div v-if="nodeNpub" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs text-white/40 shrink-0">npub</span>
|
||||
<span class="text-xs font-mono text-white/60 truncate">{{ nodeNpub }}</span>
|
||||
</div>
|
||||
<button @click="copyNpub" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedNpub ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Private relay URLs for mesh VPN peer discovery -->
|
||||
<div v-if="relayOnion" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs text-purple-400/70 shrink-0">relay (tor)</span>
|
||||
<span class="text-xs font-mono text-white/60 truncate">{{ relayOnion }}</span>
|
||||
</div>
|
||||
<button @click="copyText(relayOnion, 'onion')" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'onion' ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<div v-if="relayDirect" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs text-white/40 shrink-0">relay (direct)</span>
|
||||
<span class="text-xs font-mono text-white/60 truncate">{{ relayDirect }}</span>
|
||||
</div>
|
||||
<button @click="copyText(relayDirect, 'direct')" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'direct' ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- VPN IPs -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" :class="networkData.wgIp ? 'bg-green-400' : 'bg-white/20'"></div>
|
||||
<span class="text-xs text-white/50">WireGuard</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Not active' }}</span>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" :class="networkData.vpnIp ? 'bg-green-400' : 'bg-white/20'"></div>
|
||||
<span class="text-xs text-white/50">NostrVPN</span>
|
||||
</div>
|
||||
<span class="text-sm font-mono" :class="networkData.vpnIp ? 'text-white' : 'text-white/30'">{{ networkData.vpnIp || 'Not active' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Devices -->
|
||||
<div class="border-t border-white/10 pt-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white/50">Connected Devices</span>
|
||||
<span class="text-xs text-white/30">{{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div v-if="vpnPeers.length" class="space-y-1">
|
||||
<div v-for="peer in vpnPeers" :key="peer.name + (peer.npub || '')" class="flex items-center justify-between text-xs py-1.5 px-2 bg-white/5 rounded">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium" :class="peer.type === 'nostrvpn' ? 'bg-purple-500/20 text-purple-300' : 'bg-blue-500/20 text-blue-300'">{{ peer.type === 'nostrvpn' ? 'NVP' : 'WG' }}</span>
|
||||
<button v-if="peer.type !== 'nostrvpn'" @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
|
||||
<span v-else class="text-white/70">{{ peer.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-white/40 font-mono">{{ peer.ip?.replace(/\/\d+$/, '') || '' }}</span>
|
||||
<button v-if="peer.type !== 'nostrvpn'" @click="removePeer(peer.name)" :disabled="removingPeer === peer.name" class="p-0.5 rounded hover:bg-white/10 text-white/30 hover:text-red-400 transition-colors" :title="'Remove ' + peer.name">
|
||||
<svg v-if="removingPeer === peer.name" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Device Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="closeDeviceModal">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
|
||||
<button @click="closeDeviceModal" class="p-1 rounded hover:bg-white/10 text-white/60"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
|
||||
</div>
|
||||
<!-- Loading state (for existing peer config) -->
|
||||
<div v-if="loadingPeerConfig" class="text-center py-8">
|
||||
<svg class="w-6 h-6 animate-spin text-white/40 mx-auto" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
</div>
|
||||
<!-- Existing peer QR view -->
|
||||
<div v-else-if="peerQrData && !showingNewDevice" class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New device: tab selection -->
|
||||
<div v-else>
|
||||
<div v-if="!peerQrData && !inviteData" class="flex gap-1 mb-4 bg-white/5 rounded-lg p-1">
|
||||
<button @click="deviceTab = 'nvpn'" :class="deviceTab === 'nvpn' ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'" class="flex-1 py-1.5 text-xs font-medium rounded-md transition-colors">NostrVPN App</button>
|
||||
<button @click="deviceTab = 'wg'" :class="deviceTab === 'wg' ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'" class="flex-1 py-1.5 text-xs font-medium rounded-md transition-colors">WireGuard App</button>
|
||||
</div>
|
||||
<div v-if="deviceTab === 'nvpn'">
|
||||
<div v-if="inviteData" class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="inviteData.qr_svg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>NostrVPN</strong> app</p>
|
||||
<p class="text-xs text-white/40 mb-4">Or paste the invite link</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyInvite" class="flex-1 glass-button py-2 text-xs">{{ copiedInvite ? 'Copied!' : 'Copy Invite' }}</button>
|
||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="text-sm text-white/50 mb-3">Generate an invite for the NostrVPN mobile app. Devices join the mesh automatically via Nostr relay discovery.</p>
|
||||
<button @click="generateInvite" :disabled="generatingInvite" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30 mb-4">{{ generatingInvite ? 'Generating...' : 'Generate Invite QR' }}</button>
|
||||
<div class="border-t border-white/10 pt-3">
|
||||
<p class="text-xs text-white/40 mb-2">Or add a participant directly by npub</p>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="participantNpub" type="text" placeholder="npub1..." class="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-xs text-white placeholder-white/30 focus:outline-none focus:border-white/30 font-mono" />
|
||||
<button @click="addParticipant" :disabled="addingParticipant || !participantNpub.trim().startsWith('npub1')" class="glass-button px-3 py-2 text-xs disabled:opacity-30">{{ addingParticipant ? '...' : 'Add' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="deviceTab === 'wg'">
|
||||
<div v-if="peerQrData" class="text-center">
|
||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="text-sm text-white/50 mb-3">Generate a static WireGuard config for the standard WireGuard app.</p>
|
||||
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
|
||||
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Network Interfaces -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||
@ -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 */ }
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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'
|
||||
},
|
||||
|
||||
@ -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 <<NVPNENV
|
||||
NOSTR_SECRET=${NOSTR_SECRET}
|
||||
NOSTR_PUBKEY=${NOSTR_PUBKEY}
|
||||
NVPNENV
|
||||
chmod 600 /var/lib/archipelago/nostr-vpn/env
|
||||
|
||||
# Load WireGuard kernel module
|
||||
modprobe wireguard 2>/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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user