feat: NostrVPN mesh + VPN card UI + nvpn v0.3.7
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
- 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:
@@ -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',
|
||||
|
||||
@@ -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 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' }}
|
||||
</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 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 ? 'WireGuard / NostrVPN' : 'Not Connected' }}
|
||||
</span>
|
||||
</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 */ }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user