feat: VPN peer QR code UI, consolidate CI workflows
- Add vpn.create-peer, vpn.list-peers, vpn.remove-peer RPC methods - Generate WireGuard config + QR code (SVG) for mobile device connection - Add "Add Device" modal on Network page with QR scanner support - Remove old build-iso.yml (replaced by build-iso-dev.yml) - Remove container-tests.yml (tests run in dev workflow) - Remove container orchestration tests from dev workflow (redundant) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -240,6 +240,9 @@ 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.create-peer" => self.handle_vpn_create_peer(params).await,
|
||||
"vpn.list-peers" => self.handle_vpn_list_peers().await,
|
||||
"vpn.remove-peer" => self.handle_vpn_remove_peer(params).await,
|
||||
"remote.setup" => self.handle_remote_setup(params).await,
|
||||
|
||||
// Marketplace
|
||||
|
||||
@@ -201,4 +201,149 @@ impl RpcHandler {
|
||||
info!("VPN disconnected");
|
||||
Ok(serde_json::json!({ "disconnected": true }))
|
||||
}
|
||||
|
||||
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||
pub(super) async fn handle_vpn_create_peer(
|
||||
&self,
|
||||
params: Option<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");
|
||||
|
||||
// Get server status for endpoint info
|
||||
let status = vpn::get_status().await;
|
||||
if !status.connected {
|
||||
anyhow::bail!("NostrVPN is not running. Start VPN first.");
|
||||
}
|
||||
|
||||
// Generate a keypair for the new peer via nvpn keygen
|
||||
let keygen = tokio::process::Command::new("nvpn")
|
||||
.arg("keygen")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("nvpn keygen failed: {}", e))?;
|
||||
|
||||
if !keygen.status.success() {
|
||||
anyhow::bail!("nvpn keygen failed: {}", String::from_utf8_lossy(&keygen.stderr));
|
||||
}
|
||||
|
||||
let keygen_output = String::from_utf8_lossy(&keygen.stdout);
|
||||
let lines: Vec<&str> = keygen_output.lines().collect();
|
||||
|
||||
// Parse private and public keys from keygen output
|
||||
let (peer_private, peer_public) = if lines.len() >= 2 {
|
||||
(lines[0].trim().to_string(), lines[1].trim().to_string())
|
||||
} else {
|
||||
anyhow::bail!("Unexpected keygen output: {}", keygen_output);
|
||||
};
|
||||
|
||||
// Get server's public key from nvpn render-wg
|
||||
let render = tokio::process::Command::new("nvpn")
|
||||
.arg("render-wg")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("nvpn render-wg failed: {}", e))?;
|
||||
let render_output = String::from_utf8_lossy(&render.stdout);
|
||||
let server_privkey = render_output.lines()
|
||||
.find(|l| l.starts_with("PrivateKey"))
|
||||
.and_then(|l| l.split('=').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Derive server public key from private key
|
||||
let server_pubkey_cmd = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("echo '{}' | wg pubkey", server_privkey))
|
||||
.output()
|
||||
.await;
|
||||
let server_pubkey = server_pubkey_cmd
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Detect host IP for endpoint
|
||||
let host_ip = self.config.host_ip.clone();
|
||||
let endpoint = format!("{}:51820", host_ip);
|
||||
|
||||
// Allocate a peer IP (simple: hash the peer name)
|
||||
let peer_num = (name.bytes().map(|b| b as u32).sum::<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,
|
||||
"created": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
tokio::fs::write(
|
||||
peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))),
|
||||
serde_json::to_string_pretty(&peer_info)?,
|
||||
).await.ok();
|
||||
|
||||
info!("VPN peer created: {} ({})", name, peer_ip);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"name": name,
|
||||
"peer_ip": peer_ip,
|
||||
"config": wg_config,
|
||||
"qr_svg": svg,
|
||||
"public_key": peer_public,
|
||||
}))
|
||||
}
|
||||
|
||||
/// vpn.list-peers — List configured VPN peers.
|
||||
pub(super) async fn handle_vpn_list_peers(&self) -> Result<serde_json::Value> {
|
||||
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||
let mut peers = Vec::new();
|
||||
|
||||
if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if entry.path().extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Ok(content) = tokio::fs::read_to_string(entry.path()).await {
|
||||
if let Ok(peer) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
peers.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
/// vpn.remove-peer — Remove a VPN peer by name.
|
||||
pub(super) async fn handle_vpn_remove_peer(
|
||||
&self,
|
||||
params: Option<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);
|
||||
|
||||
if tokio::fs::remove_file(&peer_file).await.is_ok() {
|
||||
info!("VPN peer removed: {}", name);
|
||||
Ok(serde_json::json!({ "removed": true }))
|
||||
} else {
|
||||
anyhow::bail!("Peer '{}' not found", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user