2026-03-11 12:42:05 +00:00
|
|
|
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?;
|
|
|
|
|
|
|
|
|
|
Ok(serde_json::json!({
|
|
|
|
|
"connected": status.connected,
|
|
|
|
|
"provider": status.provider,
|
|
|
|
|
"interface": status.interface,
|
|
|
|
|
"ip_address": status.ip_address,
|
|
|
|
|
"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(),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
}
|
2026-04-07 14:53:26 +01:00
|
|
|
vpn::VpnProvider::NostrVpn => {
|
|
|
|
|
let _ = tokio::process::Command::new("systemctl")
|
|
|
|
|
.args(["stop", "nostr-vpn"])
|
|
|
|
|
.output()
|
|
|
|
|
.await;
|
|
|
|
|
}
|
2026-03-11 12:42:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
info!("VPN disconnected");
|
|
|
|
|
Ok(serde_json::json!({ "disconnected": true }))
|
|
|
|
|
}
|
2026-04-07 19:44:00 +01:00
|
|
|
|
|
|
|
|
/// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-11 12:42:05 +00:00
|
|
|
}
|