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:
parent
ef69434364
commit
a029a4c948
@ -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,
|
||||
|
||||
@ -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<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
|
||||
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/<network_id>?npub=<npub>&relays=<csv>
|
||||
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
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
</div>
|
||||
<div v-if="deviceTab === 'nvpn'">
|
||||
<!-- Step 2: QR + mesh details -->
|
||||
<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>
|
||||
<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>
|
||||
<p class="text-sm text-white/70 mb-4">Scan with the <strong>NostrVPN</strong> app</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Step 1: Enter phone npub -->
|
||||
<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>
|
||||
<p class="text-xs text-white/40 mb-3">Step 1 of 2 — Enter your phone's npub</p>
|
||||
<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>
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="deviceTab === 'wg'">
|
||||
@ -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 */ }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user