feat: deploy-to-target supports .253 + mesh/federation/VPN updates

- Add deploy_secondary() function for deploying to multiple LAN nodes
- --both now deploys to .198 and .253 (previously .198 only)
- Fleet deploy updated for 3 LAN nodes
- Mesh DM fixes: protocol frame format, DM-via-channel routing
- Federation pending requests, discover modal
- VPN status UI improvements
- Image versions and container specs updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-18 11:07:08 -04:00
co-authored by Claude Opus 4.6
parent e210376e05
commit 9dd802998c
38 changed files with 3773 additions and 697 deletions
+119 -1
View File
@@ -15,6 +15,21 @@ export interface RPCResponse<T> {
}
}
/// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend.
export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired'
export interface PendingPeerRequest {
id: string
from_nostr_pubkey: string
from_nostr_npub: string
from_did: string
from_name: string | null
message: string | null
received_at: string
state: PendingState
outbound: boolean
}
function getCsrfToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
if (match) return match[1]!
@@ -262,7 +277,7 @@ class RPCClient {
// ─── Node Identity ───────────────────────────────────────────────
async getNodeDid(): Promise<{ did: string; pubkey: string }> {
async getNodeDid(): Promise<{ did: string; pubkey: string; nostr_pubkey?: string; nostr_npub?: string }> {
return this.call({
method: 'node.did',
params: {},
@@ -579,6 +594,21 @@ class RPCClient {
})
}
async meshContactsList(): Promise<{
contacts: Array<{ pubkey: string; alias?: string | null; notes?: string | null; pinned?: boolean; blocked?: boolean }>
}> {
return this.call({ method: 'mesh.contacts-list', params: {} })
}
async meshContactsSave(
pubkey: string,
alias?: string | null,
): Promise<{ saved: boolean; pubkey: string; alias: string | null }> {
const params: Record<string, unknown> = { pubkey }
if (alias !== undefined) params.alias = alias
return this.call({ method: 'mesh.contacts-save', params })
}
async federationListNodes(): Promise<{
nodes: Array<{
did: string
@@ -590,6 +620,7 @@ class RPCClient {
last_seen?: string
last_state?: {
timestamp: string
node_name?: string
apps: Array<{ id: string; status: string; version?: string }>
cpu_usage_percent?: number
mem_used_bytes?: number
@@ -598,6 +629,7 @@ class RPCClient {
disk_total_bytes?: number
uptime_secs?: number
tor_active?: boolean
nostr_npub?: string
}
}>
}> {
@@ -624,6 +656,92 @@ class RPCClient {
})
}
// Nostr peer-discovery — see `core/archipelago/src/nostr_handshake.rs`.
// None of these methods ever exchange the local onion address on a public
// relay. `handshake.discover` returns presence-only events (DID + npub);
// `handshake.connect` ships a NIP-44-encrypted PeerRequest with no onion;
// `handshake.poll` queues inbound requests into the federation pending
// inbox for manual approval (it does NOT auto-accept).
async nostrDiscoveryStatus(): Promise<{ enabled: boolean }> {
return this.call({ method: 'nostr.discovery-status', params: {} })
}
async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> {
return this.call({
method: 'nostr.set-discovery',
params: { enabled },
timeout: 30000,
})
}
async handshakeDiscover(): Promise<{
nodes: Array<{
nostr_pubkey: string
nostr_npub: string
did: string
version: string
}>
}> {
return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 })
}
async handshakeConnect(
recipient: string,
message?: string,
name?: string,
): Promise<{ ok: boolean; sent_to: string; id: string }> {
return this.call({
method: 'handshake.connect',
params: {
recipient_nostr_pubkey: recipient,
...(message ? { message } : {}),
...(name ? { name } : {}),
},
timeout: 30000,
})
}
async handshakePoll(): Promise<{
polled: number
new_requests: PendingPeerRequest[]
applied_invites: string[]
rejected_outbound: string[]
skipped: string[]
}> {
return this.call({ method: 'handshake.poll', params: {}, timeout: 30000 })
}
async federationListPendingRequests(): Promise<{
requests: PendingPeerRequest[]
}> {
return this.call({ method: 'federation.list-pending-requests', params: {} })
}
async federationApproveRequest(id: string): Promise<{ approved: boolean; id: string }> {
return this.call({
method: 'federation.approve-request',
params: { id },
timeout: 30000,
})
}
async federationRejectRequest(
id: string,
reason?: string,
notify = false,
): Promise<{ rejected: boolean; id: string }> {
return this.call({
method: 'federation.reject-request',
params: {
id,
...(reason ? { reason } : {}),
notify,
},
timeout: 30000,
})
}
async federationSyncState(): Promise<{
synced: number
failed: number
+143
View File
@@ -45,6 +45,54 @@
@clear-invite="inviteCode = ''"
/>
<!-- Nostr discoverability strip: opt-in toggle + Discover button.
Renders inline so the Federation page is the single place a user
manages everything related to peering. The toggle directly mutates
the `nostr_discovery_enabled` config flag backend defaults to OFF
and nothing is published until the user explicitly turns it on. -->
<div class="glass-card p-4 mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-white">Nostr discoverability</span>
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
</div>
<p class="text-xs text-white/60 mt-1">
When on, this node publishes a presence event (DID + npub only never an onion)
so other nodes can find you and request to peer. Inbound requests land in the
panel below for your approval. Off by default.
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discoveryToggling"
@click="toggleDiscovery"
>
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
</button>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!discoveryEnabled"
@click="showDiscoverModal = true"
>
Discover Nodes
</button>
</div>
</div>
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
<PendingRequestsPanel
:requests="pendingRequests"
:polling="pollingHandshake"
:busy-id="pendingBusyId"
@poll="pollHandshake"
@approve="approvePending"
@reject="rejectPending"
/>
<NodeList
:nodes="nodes"
:loading="loading"
@@ -82,6 +130,13 @@
@close="showJoinModal = false"
@join="joinFederation"
/>
<DiscoverModal
:visible="showDiscoverModal"
:outbound-sent="pendingRequests"
@close="showDiscoverModal = false"
@sent="loadPendingRequests"
/>
</div>
</template>
@@ -97,7 +152,10 @@ import QuickActions from './federation/QuickActions.vue'
import NodeList from './federation/NodeList.vue'
import NodeDetailModal from './federation/NodeDetailModal.vue'
import JoinModal from './federation/JoinModal.vue'
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
import DiscoverModal from './federation/DiscoverModal.vue'
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
import type { PendingPeerRequest } from '@/api/rpc-client'
import { nodeName, timeAgo } from './federation/utils'
const transportStore = useTransportStore()
@@ -205,6 +263,89 @@ const rotateSuccess = ref('')
// Dead node cleanup
const cleaningNodes = ref(false)
// Nostr discoverability + pending peer requests
const discoveryEnabled = ref(false)
const discoveryToggling = ref(false)
const discoveryError = ref('')
const showDiscoverModal = ref(false)
const pendingRequests = ref<PendingPeerRequest[]>([])
const pollingHandshake = ref(false)
const pendingBusyId = ref<string | null>(null)
async function loadDiscoveryState() {
try {
const result = await rpcClient.nostrDiscoveryStatus()
discoveryEnabled.value = !!result.enabled
} catch {
discoveryEnabled.value = false
}
}
async function toggleDiscovery() {
discoveryToggling.value = true
discoveryError.value = ''
const next = !discoveryEnabled.value
try {
const result = await rpcClient.nostrSetDiscovery(next)
discoveryEnabled.value = !!result.enabled
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability'
} finally {
discoveryToggling.value = false
}
}
async function loadPendingRequests() {
try {
const result = await rpcClient.federationListPendingRequests()
pendingRequests.value = result.requests
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Failed to load pending requests'
}
}
async function pollHandshake() {
pollingHandshake.value = true
discoveryError.value = ''
try {
await rpcClient.handshakePoll()
await loadPendingRequests()
// If a poll applied a PeerInvite, the federation node list also changed.
await loadNodes()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Poll failed'
} finally {
pollingHandshake.value = false
}
}
async function approvePending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
await rpcClient.federationApproveRequest(id)
await loadPendingRequests()
await loadNodes()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Approve failed'
} finally {
pendingBusyId.value = null
}
}
async function rejectPending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
await rpcClient.federationRejectRequest(id)
await loadPendingRequests()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Reject failed'
} finally {
pendingBusyId.value = null
}
}
function isOnlineCheck(node: FederatedNode): boolean {
if (!node.last_seen) return false
const lastSeen = new Date(node.last_seen).getTime()
@@ -382,6 +523,8 @@ async function rotateDid(password: string) {
onMounted(async () => {
loadNodes()
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
transportStore.fetchPeers()
try {
const result = await rpcClient.getNodeDid()
+1 -1
View File
@@ -145,7 +145,7 @@
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? (vpnStatus.provider || 'Connected') : 'Not configured' }}</span>
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'WireGuard' : 'Not configured' }}</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
+72 -204
View File
@@ -114,7 +114,7 @@
<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' }}
{{ networkData.vpnConnected ? 'WireGuard' : 'Not Connected' }}
</span>
</div>
<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">
@@ -160,8 +160,9 @@
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<!-- VPN Card -->
<div class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
<div class="glass-card p-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">
@@ -169,53 +170,20 @@
</div>
<div>
<h2 class="text-lg font-semibold text-white">VPN</h2>
<p class="text-xs text-white/50">WireGuard + NostrVPN mesh</p>
<p class="text-xs text-white/50">Standalone WireGuard VPN</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 || (networkData.vpnConnected ? 'Pair a device' : 'Not active') }}</span>
<!-- WireGuard Status -->
<div class="mb-4 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">Server Address</span>
</div>
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Starting...' }}</span>
<span v-if="networkData.wgPubkey" class="block text-xs font-mono text-white/30 mt-1 truncate">{{ networkData.wgPubkey }}</span>
</div>
<!-- Connected Devices -->
@@ -225,15 +193,14 @@
<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 v-for="peer in vpnPeers" :key="peer.name" 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>
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-300">WG</span>
<button @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
</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">
<button @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>
@@ -244,6 +211,53 @@
</div>
</div>
<!-- Network Interfaces (second column on desktop) -->
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
</div>
<button
v-if="wifiAvailable"
@click="showWifiModal = true"
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Scan WiFi
</button>
</div>
<template v-if="interfacesLoading">
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
</div>
</template>
<template v-else>
<div class="space-y-3">
<div
v-for="iface in physicalInterfaces"
:key="iface.name"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
<div>
<p class="text-sm text-white font-medium">{{ iface.name }}</p>
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} &middot; {{ iface.mac }}</p>
</div>
</div>
<div class="text-right">
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
<p v-else class="text-sm text-white/40">No IP</p>
</div>
</div>
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
</div>
</template>
</div>
</div><!-- close VPN+Network 2-col grid -->
<!-- Add Device Modal -->
<Teleport to="body">
<Transition name="modal">
@@ -268,54 +282,10 @@
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
</div>
</div>
<!-- New device: tab selection -->
<!-- New device: WireGuard config -->
<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'">
<!-- 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-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 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-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'">
<div v-if="peerQrData" class="text-center">
<div v-if="peerQrData">
<div 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>
@@ -324,8 +294,10 @@
<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>
</div>
<div v-else>
<div>
<p class="text-sm text-white/50 mb-3">Generate a 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>
@@ -337,52 +309,7 @@
</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">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
<p class="text-sm text-white/60">Detected hardware and virtual interfaces</p>
</div>
<button
v-if="wifiAvailable"
@click="showWifiModal = true"
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Scan WiFi
</button>
</div>
<template v-if="interfacesLoading">
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-14"></div>
</div>
</template>
<template v-else>
<div class="space-y-3">
<div
v-for="iface in physicalInterfaces"
:key="iface.name"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
<div>
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} &middot; {{ iface.mac }}</p>
</div>
</div>
<div class="text-right">
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
<p v-else class="text-sm text-white/40">No IP</p>
</div>
</div>
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
</div>
</template>
</div>
<div class="mb-6">
<!-- Tor Services -->
<TorServicesCard
:tor-services="torServices"
@@ -475,7 +402,7 @@ const logCount = ref(0)
const networkLoading = ref(true)
const networkData = ref({
wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A',
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', vpnHostname: '', vpnPeers: 0,
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
})
@@ -490,32 +417,11 @@ 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 ?? '').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 (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 ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
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('')
@@ -578,52 +484,14 @@ async function removePeer(name: string) {
finally { removingPeer.value = '' }
}
const deviceTab = ref<'nvpn' | 'wg'>('nvpn')
const showingNewDevice = ref(false)
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
peerQrData.value = null
inviteData.value = null
newPeerName.value = ''
peerError.value = ''
showingNewDevice.value = false
showMeshDetails.value = false
participantNpub.value = ''
}
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; 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 {
generatingInvite.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() {
@@ -43,7 +43,7 @@ export const APP_PORTS: Record<string, number> = {
'routstr': 8200,
'indeedhub': 7778,
'botfights': 9100,
'gitea': 3001,
'gitea': 3000,
'dwn': 3100,
'endurain': 8080,
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { MarketplaceApp } from './types'
const R = '23.182.128.160:3000/lfg2025'
const R = 'git.tx1138.com/lfg2025'
// ---------- Dynamic catalog from registry ----------
export interface CatalogFeatured {
@@ -24,9 +24,9 @@ const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
/** Remote catalog URLs — tried in order. First success wins. */
const CATALOG_URLS = [
// Primary: Gitea raw file (dynamic, updated without frontend rebuild)
// Legacy (down): https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json
// Fallback: direct IP if DNS fails
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json',
// Fallback: direct IP (HTTP, only works if CSP allows http://$host:*)
'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json',
// Last resort: local static file (baked into frontend build)
'/catalog.json',
@@ -0,0 +1,192 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="visible"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click.self="$emit('close')"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto relative z-10">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white">Discover Nodes</h2>
<p class="text-xs text-white/60 mt-1">
Browses Nostr presence events from configured relays. Sending a
peer request never reveals your onion only your DID + npub +
an optional message travel inside an encrypted DM.
</p>
</div>
<button @click="$emit('close')" class="text-white/40 hover:text-white/70 transition-colors">
<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 class="flex items-center gap-3 mb-4">
<button
class="px-4 py-2 glass-button rounded text-sm text-white/90 hover:text-white disabled:opacity-50"
:disabled="loading"
@click="refresh"
>
{{ loading ? 'Searching…' : 'Search Relays' }}
</button>
<span v-if="lastSearchAt" class="text-[11px] text-white/40">
Last search: {{ lastSearchAt }}
</span>
</div>
<div v-if="error" class="mb-4 text-sm text-red-400">{{ error }}</div>
<!-- Manual entry: paste an npub directly -->
<div class="mb-6 p-3 bg-white/5 rounded-lg border border-white/10">
<p class="text-xs text-white/60 mb-2">
Already know an npub? Send a peer request directly.
</p>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="manualNpub"
placeholder="npub1…"
class="flex-1 bg-black/30 text-white text-xs rounded px-3 py-2 border border-white/10 focus:border-orange-400/50 focus:outline-none font-mono"
/>
<button
class="px-4 py-2 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="!manualNpub.trim() || sendingTo === manualNpub.trim()"
@click="sendDirect()"
>
Send Request
</button>
</div>
</div>
<div v-if="nodes.length === 0 && !loading" class="text-center py-8 text-white/40 text-sm">
No discoverable nodes found. Either no peers are advertising on the
configured relays, or your discoverability hasn't been enabled long
enough for relays to gossip yours.
</div>
<div v-else class="space-y-2">
<div
v-for="node in nodes"
:key="node.nostr_pubkey"
class="p-3 bg-white/5 rounded-lg border border-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-sm text-white truncate">
{{ shortNpub(node.nostr_npub) }}
</div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
:disabled="sendingTo === node.nostr_pubkey || alreadySentTo(node.nostr_pubkey)"
@click="sendTo(node)"
>
{{ statusFor(node) }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
interface DiscoverableNode {
nostr_pubkey: string
nostr_npub: string
did: string
version: string
}
const props = defineProps<{
visible: boolean
/// Outbound rows from the parent's pending list, used to grey out
/// "Send Request" buttons for npubs we've already requested.
outboundSent: PendingPeerRequest[]
}>()
const emit = defineEmits<{
close: []
/// Fired after a successful send so the parent can refresh its
/// pending-requests list to show the new "Sent" row.
sent: []
}>()
const nodes = ref<DiscoverableNode[]>([])
const loading = ref(false)
const error = ref('')
const lastSearchAt = ref('')
const sendingTo = ref<string | null>(null)
const manualNpub = ref('')
watch(
() => props.visible,
(v) => {
if (v && nodes.value.length === 0) refresh()
},
)
async function refresh() {
loading.value = true
error.value = ''
try {
const result = await rpcClient.handshakeDiscover()
nodes.value = result.nodes
lastSearchAt.value = new Date().toLocaleTimeString()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Discovery failed'
} finally {
loading.value = false
}
}
async function sendTo(node: DiscoverableNode) {
await sendInternal(node.nostr_pubkey)
}
async function sendDirect() {
const v = manualNpub.value.trim()
if (!v) return
await sendInternal(v)
manualNpub.value = ''
}
async function sendInternal(target: string) {
sendingTo.value = target
error.value = ''
try {
await rpcClient.handshakeConnect(target)
emit('sent')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Send failed'
} finally {
sendingTo.value = null
}
}
function alreadySentTo(npubHex: string): boolean {
return props.outboundSent.some(
(r) => r.outbound && r.from_nostr_pubkey === npubHex && r.state === 'sent',
)
}
function statusFor(node: DiscoverableNode): string {
if (sendingTo.value === node.nostr_pubkey) return 'Sending…'
if (alreadySentTo(node.nostr_pubkey)) return 'Already sent'
return 'Send Request'
}
function shortNpub(npub: string): string {
if (!npub || npub.length < 16) return npub
return `${npub.slice(0, 14)}${npub.slice(-8)}`
}
</script>
@@ -0,0 +1,128 @@
<template>
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2>
<p class="text-xs text-white/60">
Inbound requests await your approval. Outbound requests show what you've sent.
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
to Trusted manually if you want them to receive state syncs.
</p>
</div>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/80 hover:text-white disabled:opacity-50"
:disabled="polling"
@click="$emit('poll')"
>
{{ polling ? 'Polling' : 'Poll Now' }}
</button>
</div>
<div class="space-y-3">
<div
v-for="req in visibleRequests"
:key="req.id"
class="p-3 bg-white/5 rounded-lg border border-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="badgeClass(req)"
>{{ badgeLabel(req) }}</span>
<span class="text-sm text-white font-medium truncate">
{{ req.from_name || shortNpub(req.from_nostr_npub) }}
</span>
</div>
<div class="text-[11px] text-white/50 font-mono truncate">{{ req.from_nostr_npub }}</div>
<div v-if="req.from_did" class="text-[11px] text-white/40 font-mono truncate">{{ req.from_did }}</div>
<p v-if="req.message" class="mt-2 text-xs text-white/70 italic">"{{ req.message }}"</p>
<p class="mt-1 text-[10px] text-white/40">{{ relative(req.received_at) }}</p>
</div>
<div v-if="req.state === 'pending' && !req.outbound" class="flex flex-col gap-2 shrink-0">
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-green-300 hover:text-green-200 disabled:opacity-50"
:disabled="busyId === req.id"
@click="$emit('approve', req.id)"
>
{{ busyId === req.id ? '' : 'Approve' }}
</button>
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-red-300 hover:text-red-200 disabled:opacity-50"
:disabled="busyId === req.id"
@click="$emit('reject', req.id)"
>
Reject
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PendingPeerRequest } from '@/api/rpc-client'
const props = defineProps<{
requests: PendingPeerRequest[]
polling: boolean
busyId: string | null
}>()
defineEmits<{
poll: []
approve: [id: string]
reject: [id: string]
}>()
// Hide already-handled rows older than 24h to keep the panel from growing
// indefinitely. The backend keeps the full audit trail in the file; the UI
// only shows what's actionable or recent.
const visibleRequests = computed(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000
return props.requests.filter((r) => {
if (r.state === 'pending' || r.state === 'sent') return true
const ts = new Date(r.received_at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
})
function badgeClass(r: PendingPeerRequest): string {
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
return 'bg-white/10 text-white/50'
}
function badgeLabel(r: PendingPeerRequest): string {
if (r.outbound && r.state === 'sent') return 'Sent'
if (r.outbound && r.state === 'approved') return 'They approved'
if (r.outbound && r.state === 'rejected') return 'They rejected'
if (r.outbound) return r.state
if (r.state === 'pending') return 'Inbound'
return r.state
}
function shortNpub(npub: string): string {
if (!npub || npub.length < 16) return npub
return `${npub.slice(0, 12)}${npub.slice(-6)}`
}
function relative(iso: string): string {
const ts = new Date(iso).getTime()
if (!Number.isFinite(ts)) return iso
const diff = Date.now() - ts
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.floor(hr / 24)
return `${day}d ago`
}
</script>
@@ -70,7 +70,7 @@ onMounted(fetchVpnStatus)
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Provider</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'nostr-vpn' }}</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
</div>
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Peers</div>