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:
Dorian 2026-04-08 15:00:00 +02:00
parent 6c1f316956
commit b0907c48b2
12 changed files with 765 additions and 102 deletions

View File

@ -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,

View File

@ -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")
// 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"))?;
// 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
.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();
.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 {

View File

@ -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",

View File

@ -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.*

View File

@ -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" && \

View File

@ -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

View File

@ -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',

View File

@ -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 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 ? `${networkData.vpnProvider} (${networkData.vpnIp})` : 'Not Connected' }}
{{ networkData.vpnConnected ? 'WireGuard / NostrVPN' : '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>
</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 */ }

View File

@ -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' },

View File

@ -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'
},

View File

@ -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"

View File

@ -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"