feat: NostrVPN add-device guided wizard

Replace disconnected "Generate Invite" + "Add participant" with a 2-step
wizard: enter phone npub → get invite QR + mesh details. Backend vpn.invite
now accepts optional npub param to add participant in the same call. Modal
shows network ID, node npub, and relay URLs for manual app configuration.

Also includes nostr-vpn service hardening (rate-limit restarts, reset-failed
before enable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-08 19:04:53 +02:00
parent ef69434364
commit a029a4c948
5 changed files with 78 additions and 44 deletions

View File

@ -240,7 +240,7 @@ impl RpcHandler {
"vpn.status" => self.handle_vpn_status().await, "vpn.status" => self.handle_vpn_status().await,
"vpn.configure" => self.handle_vpn_configure(params).await, "vpn.configure" => self.handle_vpn_configure(params).await,
"vpn.disconnect" => self.handle_vpn_disconnect().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.add-participant" => self.handle_vpn_add_participant(params).await,
"vpn.create-peer" => self.handle_vpn_create_peer(params).await, "vpn.create-peer" => self.handle_vpn_create_peer(params).await,
"vpn.list-peers" => self.handle_vpn_list_peers().await, "vpn.list-peers" => self.handle_vpn_list_peers().await,

View File

@ -240,7 +240,21 @@ impl RpcHandler {
} }
/// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app. /// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app.
pub(super) async fn handle_vpn_invite(&self) -> Result<serde_json::Value> { /// 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 // Read nvpn config to build invite
let npub = vpn::read_nvpn_config_value("nostr", "public_key").await let npub = vpn::read_nvpn_config_value("nostr", "public_key").await
.ok_or_else(|| anyhow::anyhow!("No Nostr public key in nvpn config"))?; .ok_or_else(|| anyhow::anyhow!("No Nostr public key in nvpn config"))?;
@ -250,7 +264,7 @@ impl RpcHandler {
// Read relays from config // Read relays from config
let relays = vpn::read_nvpn_config_list("nostr", "relays").await; 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() "wss://relay.damus.io,wss://relay.primal.net".to_string()
} else { } else {
relays.join(",") relays.join(",")
@ -259,7 +273,7 @@ impl RpcHandler {
// Build invite URL: nvpn://invite/<network_id>?npub=<npub>&relays=<csv> // Build invite URL: nvpn://invite/<network_id>?npub=<npub>&relays=<csv>
let invite_url = format!( let invite_url = format!(
"nvpn://invite/{}?npub={}&relays={}", "nvpn://invite/{}?npub={}&relays={}",
network_id, npub, relay_str network_id, npub, relay_csv
); );
// Generate QR code // Generate QR code
@ -274,6 +288,11 @@ impl RpcHandler {
"qr_svg": svg, "qr_svg": svg,
"npub": npub, "npub": npub,
"network_id": network_id, "network_id": network_id,
"relays": if relays.is_empty() {
vec!["wss://relay.damus.io".to_string(), "wss://relay.primal.net".to_string()]
} else {
relays
},
})) }))
} }

View File

@ -368,6 +368,12 @@ pub async fn configure_nostr_vpn(data_dir: &Path) -> Result<()> {
).ok(); ).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 // Enable and start the service
tokio::process::Command::new("systemctl") tokio::process::Command::new("systemctl")
.args(["enable", "--now", "nostr-vpn"]) .args(["enable", "--now", "nostr-vpn"])

View File

@ -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; }' 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 ExecStart=/usr/local/bin/nvpn daemon
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=30
StartLimitIntervalSec=300
StartLimitBurst=10
TimeoutStartSec=30 TimeoutStartSec=30
TimeoutStopSec=10 TimeoutStopSec=10

View File

@ -275,25 +275,43 @@
<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> <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>
<div v-if="deviceTab === 'nvpn'"> <div v-if="deviceTab === 'nvpn'">
<!-- Step 2: QR + mesh details -->
<div v-if="inviteData" class="text-center"> <div v-if="inviteData" class="text-center">
<p class="text-xs text-white/40 mb-3">Step 2 of 2 Join the mesh</p>
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="inviteData.qr_svg"></div> <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-sm text-white/70 mb-4">Scan with the <strong>NostrVPN</strong> app</p>
<p class="text-xs text-white/40 mb-4">Or paste the invite link</p> <!-- Manual entry details -->
<div class="border-t border-white/10 pt-3 mb-4 text-left">
<button @click="showMeshDetails = !showMeshDetails" class="text-xs text-white/40 hover:text-white/60 transition-colors mb-2 flex items-center gap-1">
<svg class="w-3 h-3 transition-transform" :class="showMeshDetails ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
Or enter manually in the app
</button>
<div v-if="showMeshDetails" class="space-y-2">
<div class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Network</span><span class="text-xs font-mono text-white/70 truncate block">{{ inviteData.network_id }}</span></div>
<button @click="copyText(inviteData.network_id, 'net')" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'net' ? 'Copied' : 'Copy' }}</button>
</div>
<div class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Node npub</span><span class="text-xs font-mono text-white/70 truncate block">{{ inviteData.npub }}</span></div>
<button @click="copyText(inviteData.npub, 'invnpub')" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'invnpub' ? 'Copied' : 'Copy' }}</button>
</div>
<div v-for="(relay, i) in (inviteData.relays || [])" :key="relay" class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Relay {{ (inviteData.relays?.length || 0) > 1 ? i + 1 : '' }}</span><span class="text-xs font-mono text-white/70 truncate block">{{ relay }}</span></div>
<button @click="copyText(relay, 'relay' + i)" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'relay' + i ? 'Copied' : 'Copy' }}</button>
</div>
</div>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="copyInvite" class="flex-1 glass-button py-2 text-xs">{{ copiedInvite ? 'Copied!' : 'Copy Invite' }}</button> <button @click="copyInvite" class="flex-1 glass-button py-2 text-xs">{{ copiedInvite ? 'Copied!' : 'Copy Invite Link' }}</button>
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button> <button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
</div> </div>
</div> </div>
<!-- Step 1: Enter phone npub -->
<div v-else> <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> <p class="text-xs text-white/40 mb-3">Step 1 of 2 Enter your phone's npub</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> <p class="text-sm text-white/50 mb-3">Open the <strong class="text-white/70">NostrVPN</strong> app on your phone, go to <strong class="text-white/70">Settings</strong>, and copy your npub.</p>
<div class="border-t border-white/10 pt-3"> <input v-model="participantNpub" type="text" placeholder="npub1..." 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 font-mono mb-3" @keyup.enter="generateInviteWithNpub" />
<p class="text-xs text-white/40 mb-2">Or add a participant directly by npub</p> <button @click="generateInviteWithNpub" :disabled="generatingInvite || !participantNpub.trim().startsWith('npub1')" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ generatingInvite ? 'Setting up...' : 'Next →' }}</button>
<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> </div>
<div v-if="deviceTab === 'wg'"> <div v-if="deviceTab === 'wg'">
@ -562,9 +580,11 @@ async function removePeer(name: string) {
const deviceTab = ref<'nvpn' | 'wg'>('nvpn') const deviceTab = ref<'nvpn' | 'wg'>('nvpn')
const showingNewDevice = ref(false) 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 generatingInvite = ref(false)
const copiedInvite = ref(false) const copiedInvite = ref(false)
const participantNpub = ref('')
function closeDeviceModal() { function closeDeviceModal() {
showAddDeviceModal.value = false showAddDeviceModal.value = false
@ -573,14 +593,25 @@ function closeDeviceModal() {
newPeerName.value = '' newPeerName.value = ''
peerError.value = '' peerError.value = ''
showingNewDevice.value = false 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 generatingInvite.value = true
peerError.value = '' peerError.value = ''
try { 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 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) { } catch (e) {
peerError.value = e instanceof Error ? e.message : 'Failed to generate invite' peerError.value = e instanceof Error ? e.message : 'Failed to generate invite'
} finally { } 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() { async function copyInvite() {
if (!inviteData.value?.invite_url) return if (!inviteData.value?.invite_url) return
try { await navigator.clipboard.writeText(inviteData.value.invite_url) } catch { /* fallback */ } try { await navigator.clipboard.writeText(inviteData.value.invite_url) } catch { /* fallback */ }