Dorian 8cdc542c42 fix: ISO build freshness, WireGuard startup, VPN status, kiosk remote doubling
- ISO builder: run npm ci before npm run build to prevent stale UI artifacts
- Unbundled ISO: clean container-images dir to prevent bundled tars leaking
- WireGuard: use After=network.target instead of network-online.target for
  faster wg0 startup on install
- VPN status: check actual nvpn0 interface instead of config tunnel_ip to
  prevent NostrVPN from showing standalone WireGuard IP
- ContainerApps: filter out not-installed bundled apps (fixes Bitcoin Knots
  appearing on clean unbundled installs)
- Kiosk: persist kiosk mode to localStorage before /kiosk redirect so
  App.vue can skip remote relay (fixes input doubling with companion app)
- IndeedHub: fix port mapping and X-Forwarded-Prefix passthrough

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:01:10 -04:00

693 lines
30 KiB
Rust

use super::RpcHandler;
use crate::vpn;
use anyhow::Result;
use tracing::info;
impl RpcHandler {
/// vpn.status — Get current VPN connection status.
pub(super) async fn handle_vpn_status(&self) -> Result<serde_json::Value> {
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
.map(|k| vpn::ensure_npub(&k));
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());
// Standalone WireGuard public key
let wg_pubkey = tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key")
.await.ok().map(|s| s.trim().to_string());
// Check if nvpn0 tunnel interface actually exists and has an IP
let nvpn0_ip = tokio::process::Command::new("ip")
.args(["-4", "addr", "show", "nvpn0"])
.output().await
.ok()
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string();
out.lines()
.find(|l| l.contains("inet "))
.and_then(|l| l.split_whitespace().nth(1))
.map(|s| s.split('/').next().unwrap_or(s).to_string())
});
// NostrVPN IP: only report if nvpn0 tunnel is actually up with its own IP,
// and that IP is distinct from the standalone WireGuard IP
let nvpn_ip = nvpn0_ip.as_ref().and_then(|ip| {
if wg_ip.as_deref() == Some(ip.as_str()) { None } else { Some(ip.clone()) }
});
// NostrVPN is connected only if its dedicated tunnel (nvpn0) has a distinct IP
let nvpn_connected = status.provider.as_deref() == Some("nostr-vpn") && nvpn_ip.is_some();
// connected = NostrVPN tunnel is up OR another VPN provider is active OR standalone WireGuard is up
let is_connected = if status.provider.as_deref() == Some("nostr-vpn") {
nvpn_connected || wg_ip.is_some()
} else {
status.connected || wg_ip.is_some()
};
Ok(serde_json::json!({
"connected": is_connected,
"provider": status.provider,
"interface": status.interface,
"ip_address": nvpn_ip,
"hostname": status.hostname,
"peers_connected": status.peers_connected,
"bytes_in": status.bytes_in,
"bytes_out": status.bytes_out,
"configured": config.enabled,
"configured_provider": format!("{:?}", config.provider).to_lowercase(),
"wg_ip": wg_ip,
"wg_pubkey": wg_pubkey,
"node_npub": node_npub,
"relay_url": relay_url,
"relay_onion": relay_onion,
"relay_direct": relay_direct,
}))
}
/// vpn.configure — Configure VPN (Tailscale or WireGuard).
pub(super) async fn handle_vpn_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let provider = params
.get("provider")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'provider' (tailscale or wireguard)"))?;
match provider {
"tailscale" => {
let auth_key = params
.get("auth_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'auth_key' for Tailscale"))?;
vpn::configure_tailscale(auth_key, &self.config.data_dir).await?;
info!("Tailscale VPN configured");
Ok(serde_json::json!({
"configured": true,
"provider": "tailscale",
}))
}
"wireguard" => {
let address = params
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("10.0.0.1/24");
let dns = params
.get("dns")
.and_then(|v| v.as_str())
.unwrap_or("1.1.1.1");
let peer = if let Some(peer_obj) = params.get("peer") {
let public_key = peer_obj
.get("public_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing peer public_key"))?;
let endpoint = peer_obj
.get("endpoint")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing peer endpoint"))?;
let allowed_ips = peer_obj
.get("allowed_ips")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0.0/0");
let keepalive = peer_obj
.get("persistent_keepalive")
.and_then(|v| v.as_u64())
.map(|v| v as u16);
Some(vpn::WireGuardPeer {
public_key: public_key.to_string(),
endpoint: endpoint.to_string(),
allowed_ips: allowed_ips.to_string(),
persistent_keepalive: keepalive,
})
} else {
None
};
let wg_config = vpn::configure_wireguard(
&self.config.data_dir,
address,
dns,
peer,
)
.await?;
info!("WireGuard VPN configured");
Ok(serde_json::json!({
"configured": true,
"provider": "wireguard",
"public_key": wg_config.public_key,
"address": wg_config.address,
}))
}
_ => {
anyhow::bail!("Unknown provider: {} (expected tailscale or wireguard)", provider);
}
}
}
/// remote.setup — One-click Tailscale remote access setup.
/// Accepts an auth key, configures Tailscale, and restricts access to ports 80/443.
pub(super) async fn handle_remote_setup(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let auth_key = params
.get("auth_key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'auth_key' — get one from https://login.tailscale.com/admin/settings/keys"))?;
// Configure Tailscale
vpn::configure_tailscale(auth_key, &self.config.data_dir).await?;
info!("Remote access: Tailscale configured");
// Set ACL-like port restrictions via iptables on tailscale0
// Allow only HTTP (80) and HTTPS (443) on the Tailscale interface
let restrict_cmds = [
"sudo iptables -D INPUT -i tailscale0 -p tcp --dport 80 -j ACCEPT 2>/dev/null; true",
"sudo iptables -D INPUT -i tailscale0 -p tcp --dport 443 -j ACCEPT 2>/dev/null; true",
"sudo iptables -D INPUT -i tailscale0 -j DROP 2>/dev/null; true",
"sudo iptables -A INPUT -i tailscale0 -p tcp --dport 80 -j ACCEPT",
"sudo iptables -A INPUT -i tailscale0 -p tcp --dport 443 -j ACCEPT",
"sudo iptables -A INPUT -i tailscale0 -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT",
"sudo iptables -A INPUT -i tailscale0 -j DROP",
];
for cmd in &restrict_cmds {
let _ = tokio::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.output()
.await;
}
info!("Remote access: Restricted Tailscale to ports 80/443");
// Get the Tailscale IP for display
let status = vpn::get_status().await;
let tailscale_ip = status.ip_address.clone().unwrap_or_default();
let hostname = status.hostname.clone().unwrap_or_default();
// Build the remote access URL
let remote_url = if !hostname.is_empty() {
format!("http://{}", hostname)
} else if !tailscale_ip.is_empty() {
format!("http://{}", tailscale_ip)
} else {
String::new()
};
Ok(serde_json::json!({
"configured": true,
"provider": "tailscale",
"tailscale_ip": tailscale_ip,
"hostname": hostname,
"remote_url": remote_url,
"ports_exposed": [80, 443],
}))
}
/// vpn.disconnect — Disable VPN.
pub(super) async fn handle_vpn_disconnect(&self) -> Result<serde_json::Value> {
let mut config = vpn::load_config(&self.config.data_dir).await?;
config.enabled = false;
vpn::save_config(&self.config.data_dir, &config).await?;
// Try to bring down the interface
match config.provider {
vpn::VpnProvider::Tailscale => {
let _ = tokio::process::Command::new("podman")
.args(["exec", "tailscale", "tailscale", "down"])
.output()
.await;
}
vpn::VpnProvider::Wireguard => {
let _ = tokio::process::Command::new("wg-quick")
.args(["down", "wg0"])
.output()
.await;
}
vpn::VpnProvider::NostrVpn => {
let _ = tokio::process::Command::new("systemctl")
.args(["stop", "nostr-vpn"])
.output()
.await;
}
}
info!("VPN disconnected");
Ok(serde_json::json!({ "disconnected": true }))
}
/// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app.
/// Optionally accepts `npub` param to add the phone as a participant in the same call.
pub(super) async fn handle_vpn_invite(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
// If an npub was provided, add it as a participant first
if let Some(ref p) = params {
if let Some(peer_npub) = p.get("npub").and_then(|v| v.as_str()) {
if !peer_npub.is_empty() {
// Reuse add-participant logic
self.handle_vpn_add_participant(Some(serde_json::json!({ "npub": peer_npub }))).await?;
}
}
}
// Read nvpn config to build invite (convert hex to npub1 if needed)
let npub = vpn::read_nvpn_config_value("nostr", "public_key").await
.map(|k| vpn::ensure_npub(&k))
.ok_or_else(|| anyhow::anyhow!("No Nostr public key in nvpn config — VPN not configured"))?;
// 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 — filter out localhost relays (unreachable from phone)
let relays = vpn::read_nvpn_config_list("nostr", "relays").await;
let reachable: Vec<String> = relays.iter()
.filter(|r| !r.contains("127.0.0.1") && !r.contains("localhost"))
.cloned()
.collect();
let invite_relays = if reachable.is_empty() {
vec!["wss://relay.damus.io".to_string(), "wss://relay.primal.net".to_string()]
} else {
reachable
};
// Build invite as base64-encoded JSON (nvpn v2 format, no padding)
use base64::Engine;
let invite_payload = serde_json::json!({
"v": 2,
"networkName": network_id,
"networkId": network_id,
"inviterNpub": npub,
"inviterNodeName": "archipelago",
"admins": [npub],
"participants": [npub],
"relays": invite_relays,
});
let invite_b64 = base64::engine::general_purpose::STANDARD_NO_PAD
.encode(invite_payload.to_string().as_bytes());
let invite_url = format!("nvpn://invite/{}", invite_b64);
// 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,
"relays": invite_relays,
}))
}
/// 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) {
// Try direct write first; fall back to sudo cp for root-owned daemon config
if tokio::fs::write(config_path, &new_content).await.is_ok() {
info!("Added participant to {}", config_path);
} else {
// Write to temp file, then sudo cp to target
let tmp = format!("/tmp/.nvpn-config-{}", std::process::id());
if tokio::fs::write(&tmp, &new_content).await.is_ok() {
let cp = tokio::process::Command::new("sudo")
.args(["cp", &tmp, config_path])
.output().await;
let _ = tokio::fs::remove_file(&tmp).await;
match cp {
Ok(ref out) if out.status.success() => {
info!("Added participant to {} (via sudo)", config_path);
}
_ => {
tracing::warn!("Failed to write {} (even with sudo)", config_path);
}
}
}
}
}
}
}
}
// Restart daemon to pick up the new participant
let _ = tokio::process::Command::new("sudo")
.args(["systemctl", "restart", "nostr-vpn"])
.output()
.await;
info!("VPN participant added: {}", npub);
Ok(serde_json::json!({ "added": true, "npub": npub }))
}
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
pub(super) async fn handle_vpn_create_peer(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or(serde_json::json!({}));
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Mobile");
// Check that wg0 is up (standalone WireGuard)
let wg0_up = tokio::process::Command::new("ip")
.args(["link", "show", "wg0"])
.output().await
.map(|o| o.status.success())
.unwrap_or(false);
if !wg0_up {
anyhow::bail!("WireGuard (wg0) is not running. Wait for first-boot to complete.");
}
// Generate a keypair for the new peer using wg genkey/pubkey
let genkey = tokio::process::Command::new("wg")
.arg("genkey")
.output().await
.map_err(|e| anyhow::anyhow!("wg genkey failed: {}", e))?;
if !genkey.status.success() {
anyhow::bail!("wg genkey failed: {}", String::from_utf8_lossy(&genkey.stderr));
}
let peer_private = String::from_utf8_lossy(&genkey.stdout).trim().to_string();
let mut pubkey_cmd = tokio::process::Command::new("wg");
pubkey_cmd.arg("pubkey");
pubkey_cmd.stdin(std::process::Stdio::piped());
pubkey_cmd.stdout(std::process::Stdio::piped());
let mut pubkey_child = pubkey_cmd.spawn()
.map_err(|e| anyhow::anyhow!("wg pubkey spawn failed: {}", e))?;
if let Some(ref mut stdin) = pubkey_child.stdin {
use tokio::io::AsyncWriteExt;
stdin.write_all(peer_private.as_bytes()).await?;
stdin.shutdown().await?;
}
let pubkey_out = pubkey_child.wait_with_output().await?;
let peer_public = String::from_utf8_lossy(&pubkey_out.stdout).trim().to_string();
// Read server's WireGuard public key (standalone WG key, then fall back to nvpn)
let server_pubkey = if let Ok(key) = tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key").await {
key.trim().to_string()
} else {
vpn::read_nvpn_config_value("node", "public_key").await
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
};
// Detect host IP — prefer config, then nvpn, then system detection
let host_ip = if self.config.host_ip != "127.0.0.1" {
self.config.host_ip.clone()
} else {
// Fallback: get public IP via external service
tokio::process::Command::new("sh")
.arg("-c")
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.config.host_ip.clone())
};
let endpoint = format!("{}:51820", host_ip);
// Allocate a peer IP (simple: hash the peer name)
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
let peer_ip = format!("10.44.0.{}/32", peer_num);
// Build WireGuard config for the mobile device
let wg_config = format!(
"[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n",
peer_private, peer_ip, server_pubkey, endpoint
);
// Generate QR code as SVG
let qr = qrcode::QrCode::new(wg_config.as_bytes())
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
let svg = qr.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
// Save peer info
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
tokio::fs::create_dir_all(&peers_dir).await.ok();
let peer_info = serde_json::json!({
"name": name,
"public_key": peer_public,
"ip": peer_ip,
"config": wg_config,
"created": chrono::Utc::now().to_rfc3339(),
});
tokio::fs::write(
peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))),
serde_json::to_string_pretty(&peer_info)?,
).await.ok();
// Add this peer to the server's WireGuard interface (managed by nvpn).
// Try add-peer first; if wg0 doesn't exist, run setup then retry.
let peer_filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
let mut peer_added = false;
for attempt in 0..2 {
let add = tokio::process::Command::new("sudo")
.args(["archipelago-wg", "add-peer", &peer_public, &peer_ip])
.output().await;
match add {
Ok(ref out) if out.status.success() => {
peer_added = true;
break;
}
Ok(ref out) => {
let err = String::from_utf8_lossy(&out.stderr);
tracing::warn!("add-peer attempt {}: {}", attempt + 1, err);
if attempt == 0 {
// wg0 may not exist yet — try creating it
let server_privkey = vpn::read_nvpn_config_value("node", "private_key").await
.unwrap_or_default();
if !server_privkey.is_empty() {
let key_path = "/tmp/.wg-server-key";
tokio::fs::write(key_path, &server_privkey).await.ok();
#[cfg(unix)] {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)).ok();
}
let _ = tokio::process::Command::new("sudo")
.args(["archipelago-wg", "setup", key_path])
.output().await;
tokio::fs::remove_file(key_path).await.ok();
}
// Brief pause before retry
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
Err(e) => {
tracing::warn!("add-peer command error: {}", e);
break;
}
}
}
if !peer_added {
let _ = tokio::fs::remove_file(peers_dir.join(&peer_filename)).await;
anyhow::bail!("Failed to register peer with WireGuard. Check that wg0 interface is up.");
}
info!("VPN peer created: {} ({})", name, peer_ip);
Ok(serde_json::json!({
"name": name,
"peer_ip": peer_ip,
"config": wg_config,
"qr_svg": svg,
"public_key": peer_public,
}))
}
/// vpn.list-peers — List configured VPN peers (WireGuard + NostrVPN participants).
pub(super) async fn handle_vpn_list_peers(&self) -> Result<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(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);
}
}
}
}
}
// 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,
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);
// 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 {
anyhow::bail!("Peer '{}' not found", name);
}
}
}