diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 8977a89a..36b476c0 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -2239,6 +2239,33 @@ app.post('/rpc/v1', (req, res) => { bytes_out: 642_889_310, } }) } + // Fake tunnel provisioning so the companion remote-access onboarding + // (CompanionIntroOverlay) walks the same steps as a real node. The keys + // are not real; importing this config yields a harmless dead tunnel. + case 'vpn.list-peers': { + return res.json({ result: { peers: mockState.vpnPeers || [] } }) + } + case 'vpn.create-peer': + case 'vpn.peer-config': { + const peerName = params?.name || 'device' + if (!mockState.vpnPeers) mockState.vpnPeers = [] + if (!mockState.vpnPeers.some((p) => p.name === peerName)) { + mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' }) + } + const config = [ + '[Interface]', + 'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=', + 'Address = 10.44.0.7/32', + 'DNS = 1.1.1.1', + '', + '[Peer]', + 'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=', + 'Endpoint = demo.archipelago-foundation.org:51820', + 'AllowedIPs = 10.44.0.0/16', + 'PersistentKeepalive = 25', + ].join('\n') + return res.json({ result: { config, peer_ip: '10.44.0.7' } }) + } // Node visibility — interactive (persisted per demo session) case 'network.get-visibility': { diff --git a/neode-ui/public/packages/wireguard.apk b/neode-ui/public/packages/wireguard.apk new file mode 100644 index 00000000..962f7b75 Binary files /dev/null and b/neode-ui/public/packages/wireguard.apk differ diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index da2e9337..f4ac9019 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -66,13 +66,130 @@ + +
+
+
+ +
+
+

Install WireGuard for remote access

+

+ WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too. +

+
+
+ + + +
+ + Download WireGuard + + +
+ + +
+ + +
+
+
+ +
+
+

Connect the tunnel

+

+ In WireGuard, tap +, scan this code, then switch the new tunnel on. +

+
+
+ +
+
+ WireGuard tunnel config QR code +
+ +
+
+
+

{{ wgError }}

+ + + + + + + +
+
@@ -119,7 +236,7 @@ @@ -154,13 +271,43 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO const PAIR_SCHEME = 'archipelago://pair' const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org' +// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps +// between "I've installed it" and the pairing QR. Real nodes pair against the +// node's VPN address instead of the LAN origin; the demo walks the same steps +// with a mock tunnel config (its pairing QR still carries the public demo +// URL). Set to false to restore the original two-screen flow (instant revert). +const REMOTE_ACCESS_STEPS = true + +// The WireGuard peer config only routes the VPN subnet (AllowedIPs +// 10.44.0.0/16 — core/archipelago/src/api/rpc/vpn.rs), so away from the LAN +// the node is reachable exclusively at its tunnel address. The pairing QR +// must therefore advertise this, not the browser's LAN origin. The flow has +// the user switch the tunnel on right before pairing, so it validates. +const VPN_NODE_URL = 'http://10.44.0.1' + +// Official WireGuard Android app (com.wireguard.android 1.0.20260315, +// sha256 f92971bc…, mirrored from download.wireguard.com), served like the +// companion APK: gitea raw-on-main for nodes, own origin for the demo. +const WIREGUARD_DOWNLOAD_URL = IS_DEMO + ? `${window.location.origin}/packages/wireguard.apk` + : 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk' + +// Name of the auto-created VPN peer for the phone running the companion. +const WG_PEER_NAME = 'companion-phone' + const visible = ref(false) -const step = ref<'download' | 'pair'>('download') +const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download') const slideName = ref('slide-forward') const qrDataUrl = ref('') const pairQrDataUrl = ref('') const pairingUrl = ref('') +const wgApkQrDataUrl = ref('') +const wgQrDataUrl = ref('') +const wgConfig = ref('') +const wgLoading = ref(false) +const wgError = ref('') const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL +const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL const loginTransition = useLoginTransitionStore() @@ -232,6 +379,17 @@ watch(visible, async (isVisible) => { light: '#ffffff', }, }) + if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) { + wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, { + width: 512, + margin: 2, + errorCorrectionLevel: 'M', + color: { + dark: '#111111', + light: '#ffffff', + }, + }) + } }, { immediate: true, flush: 'post' }) /** @@ -242,6 +400,9 @@ watch(visible, async (isVisible) => { */ async function resolveServerUrl(): Promise { if (IS_DEMO) return DEMO_SERVER_URL + // Remote-access flow: the app must connect through the tunnel the user just + // switched on — the LAN origin is unreachable from outside AllowedIPs. + if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL const { hostname, origin } = window.location if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin try { @@ -285,6 +446,77 @@ function showDownloadScreen() { step.value = 'download' } +function advanceFromDownload() { + if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward') + else void showPairScreen() +} + +function showWireguardScreen(direction: 'forward' | 'back' = 'forward') { + slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back' + step.value = 'wireguard' +} + +function showWgQrScreen() { + slideName.value = 'slide-forward' + step.value = 'wgqr' + void loadWgPeer() +} + +function backFromPair() { + if (REMOTE_ACCESS_STEPS) { + slideName.value = 'slide-back' + step.value = 'wgqr' + } else { + showDownloadScreen() + } +} + +// Create (or fetch) the phone's VPN peer and render its config as a QR. +// Reuses the same RPCs as the Server page's Add Device modal; the peer is +// looked up first so reopening the modal never duplicates it. +async function loadWgPeer() { + if (wgQrDataUrl.value || wgLoading.value) return + wgLoading.value = true + wgError.value = '' + try { + const listed = await rpcClient + .call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' }) + .catch(() => ({ peers: [] as { name: string }[] })) + const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME) + const res = await rpcClient.call<{ config: string; peer_ip: string }>({ + method: exists ? 'vpn.peer-config' : 'vpn.create-peer', + params: { name: WG_PEER_NAME }, + }) + wgConfig.value = res.config + wgQrDataUrl.value = await QRCode.toDataURL(res.config, { + width: 512, + margin: 3, + errorCorrectionLevel: 'M', + color: { + dark: '#111111', + light: '#ffffff', + }, + }) + } catch (e) { + wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config' + } finally { + wgLoading.value = false + } +} + +// Same-device path: hand the config to the WireGuard app as an importable +// .conf file (a phone can't scan its own screen). +function downloadWgConfig() { + if (!wgConfig.value) return + const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'archipelago.conf' + a.click() + URL.revokeObjectURL(url) +} + function dismiss() { visible.value = false step.value = 'download' diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index b376a3e0..9d4db73d 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -11,21 +11,6 @@

{{ t('web5.connectedNodes') }}

-
- - -
@@ -174,7 +159,9 @@
-
+ +
- - -
@@ -328,7 +291,6 @@ const observers = ref(cached.observers ?? []) const loadingPeers = ref(false) const peerReachableLocal = ref>(cached.peerReachable ?? {}) const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value })) -const discovering = ref(false) // Send message modal const showSendMessageModal = ref(false) @@ -378,6 +340,12 @@ function switchToRequestsTab() { } } +// The single bottom Refresh acts on whichever tab is open. +function refreshActiveTab() { + if (nodesContainerTab.value === 'requests') void loadConnectionRequests() + else void loadPeers() +} + async function loadPeers() { const hadPeers = peers.value.length > 0 || observers.value.length > 0 loadingPeers.value = true @@ -456,28 +424,6 @@ async function sendMessage() { } } -async function discoverAndAddPeers() { - discovering.value = true - try { - const res = await rpcClient.discoverNodes() - const nodes = res.nodes || [] - for (const n of nodes) { - if (n.onion && n.pubkey) { - try { - await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey }) - } catch (e) { - if (import.meta.env.DEV) console.warn('Peer may already exist', e) - } - } - } - await loadPeers() - } catch (e) { - if (import.meta.env.DEV) console.error('Discover failed:', e) - } finally { - discovering.value = false - } -} - async function loadConnectionRequests() { const hadRequests = connectionRequests.value.length > 0 loadingRequests.value = true