diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 6880203f..08e558cb 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -240,7 +240,7 @@ 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.invite" => self.handle_vpn_invite(params).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, diff --git a/core/archipelago/src/api/rpc/vpn.rs b/core/archipelago/src/api/rpc/vpn.rs index d582b888..01d605b8 100644 --- a/core/archipelago/src/api/rpc/vpn.rs +++ b/core/archipelago/src/api/rpc/vpn.rs @@ -240,7 +240,21 @@ impl RpcHandler { } /// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app. - pub(super) async fn handle_vpn_invite(&self) -> Result { + /// 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, + ) -> Result { + // 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 let npub = vpn::read_nvpn_config_value("nostr", "public_key").await .ok_or_else(|| anyhow::anyhow!("No Nostr public key in nvpn config"))?; @@ -250,7 +264,7 @@ impl RpcHandler { // Read relays from config let relays = vpn::read_nvpn_config_list("nostr", "relays").await; - let relay_str = if relays.is_empty() { + let relay_csv = if relays.is_empty() { "wss://relay.damus.io,wss://relay.primal.net".to_string() } else { relays.join(",") @@ -259,7 +273,7 @@ impl RpcHandler { // Build invite URL: nvpn://invite/?npub=&relays= let invite_url = format!( "nvpn://invite/{}?npub={}&relays={}", - network_id, npub, relay_str + network_id, npub, relay_csv ); // Generate QR code @@ -274,6 +288,11 @@ impl RpcHandler { "qr_svg": svg, "npub": npub, "network_id": network_id, + "relays": if relays.is_empty() { + vec!["wss://relay.damus.io".to_string(), "wss://relay.primal.net".to_string()] + } else { + relays + }, })) } diff --git a/core/archipelago/src/vpn.rs b/core/archipelago/src/vpn.rs index 429ffacf..e7461c4b 100644 --- a/core/archipelago/src/vpn.rs +++ b/core/archipelago/src/vpn.rs @@ -368,6 +368,12 @@ pub async fn configure_nostr_vpn(data_dir: &Path) -> Result<()> { ).ok(); } + // Reset any previous failure state (systemd rate-limits restarts before onboarding) + let _ = tokio::process::Command::new("systemctl") + .args(["reset-failed", "nostr-vpn"]) + .output() + .await; + // Enable and start the service tokio::process::Command::new("systemctl") .args(["enable", "--now", "nostr-vpn"]) diff --git a/image-recipe/configs/nostr-vpn.service b/image-recipe/configs/nostr-vpn.service index 217dccec..235e1b85 100644 --- a/image-recipe/configs/nostr-vpn.service +++ b/image-recipe/configs/nostr-vpn.service @@ -12,7 +12,9 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/nostr-vpn /var/lib/archipelago/nostr-v ExecStartPre=/bin/bash -c 'test -f /var/lib/archipelago/nostr-vpn/env || { echo "NostrVPN not configured — waiting for onboarding"; exit 1; }' ExecStart=/usr/local/bin/nvpn daemon Restart=on-failure -RestartSec=10 +RestartSec=30 +StartLimitIntervalSec=300 +StartLimitBurst=10 TimeoutStartSec=30 TimeoutStopSec=10 diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 99302f71..1ccbe4ff 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -275,25 +275,43 @@
+
+

Step 2 of 2 — Join the mesh

-

Scan with the NostrVPN app

-

Or paste the invite link

+

Scan with the NostrVPN app

+ +
+ +
+
+
Network{{ inviteData.network_id }}
+ +
+
+
Node npub{{ inviteData.npub }}
+ +
+
+
Relay {{ (inviteData.relays?.length || 0) > 1 ? i + 1 : '' }}{{ relay }}
+ +
+
+
- +
+
-

Generate an invite for the NostrVPN mobile app. Devices join the mesh automatically via Nostr relay discovery.

- -
-

Or add a participant directly by npub

-
- - -
-
+

Step 1 of 2 — Enter your phone's npub

+

Open the NostrVPN app on your phone, go to Settings, and copy your npub.

+ +
@@ -562,9 +580,11 @@ async function removePeer(name: string) { const deviceTab = ref<'nvpn' | 'wg'>('nvpn') const showingNewDevice = ref(false) -const inviteData = ref<{ invite_url: string; qr_svg: string; npub: string } | null>(null) +const showMeshDetails = ref(false) +const inviteData = ref<{ invite_url: string; qr_svg: string; npub: string; network_id: string; relays?: string[] } | null>(null) const generatingInvite = ref(false) const copiedInvite = ref(false) +const participantNpub = ref('') function closeDeviceModal() { showAddDeviceModal.value = false @@ -573,14 +593,25 @@ function closeDeviceModal() { newPeerName.value = '' peerError.value = '' showingNewDevice.value = false + showMeshDetails.value = false + participantNpub.value = '' } -async function generateInvite() { +async function generateInviteWithNpub() { + const npub = participantNpub.value.trim() + if (!npub.startsWith('npub1')) return generatingInvite.value = true peerError.value = '' try { - const res = await rpcClient.call<{ invite_url: string; qr_svg: string; npub: string }>({ method: 'vpn.invite' }) + const res = await rpcClient.call<{ invite_url: string; qr_svg: string; npub: string; network_id: string; relays?: string[] }>({ + method: 'vpn.invite', + params: { npub }, + }) inviteData.value = res + // Add to device list immediately + const short = npub.length > 20 ? `${npub.slice(0, 12)}...${npub.slice(-6)}` : npub + vpnPeers.value.push({ name: short, ip: 'mesh', type: 'nostrvpn', npub }) + loadVpnPeers() } catch (e) { peerError.value = e instanceof Error ? e.message : 'Failed to generate invite' } finally { @@ -588,30 +619,6 @@ async function generateInvite() { } } -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 */ }