From b88609e0ff80077ef4e3afe5ba080c5f6ee5dd70 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 22 Jul 2026 21:16:27 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20instant=20companion=20pairing=20?= =?UTF-8?q?=E2=80=94=20device=20tokens,=20named=20QR,=20FIPS=20pair-info?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.createDeviceToken / listDeviceTokens / revokeDeviceToken RPCs; only SHA-256 hashes persist in data_dir/device-tokens.json - auth.login accepts {token} (same rate limiter, skips TOTP like remember-me) - pairing QR now carries name (server name, fallback "My Archipelago"), tok (instant login), and FIPS mesh params from new fips.pair-info RPC (npub, fips0 ULA, transport ports) - companion onboarding drops the WireGuard install/tunnel screens — remote access moves to the FIPS mesh embedded in the companion app - fix: fips daemon UDP bind now 2121, matching the published container port and fleet rosters (was upstream's 8668 — inbound UDP was dead on bridged installs, mesh silently rode TCP 8443) Co-Authored-By: Claude Fable 5 --- core/archipelago/src/api/rpc/auth.rs | 59 +++ core/archipelago/src/api/rpc/dispatcher.rs | 4 + core/archipelago/src/api/rpc/fips.rs | 19 + core/archipelago/src/api/rpc/mod.rs | 13 +- core/archipelago/src/device_tokens.rs | 131 ++++++ core/archipelago/src/fips/config.rs | 8 +- core/archipelago/src/fips/mod.rs | 9 + core/archipelago/src/main.rs | 1 + docs/companion-pairing-qr.md | 8 +- .../src/components/CompanionIntroOverlay.vue | 388 ++++-------------- 10 files changed, 328 insertions(+), 312 deletions(-) create mode 100644 core/archipelago/src/device_tokens.rs diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index a01c362e..fa126151 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -9,6 +9,23 @@ impl RpcHandler { params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + + // Companion device-token login: minted via auth.createDeviceToken and + // carried by the pairing QR. Verified here so it shares the login rate + // limiter with password attempts. + if let Some(token) = params.get("token").and_then(|v| v.as_str()) { + return match crate::device_tokens::verify(&self.config.data_dir, token).await { + Some(device) => { + tracing::info!("[onboarding] device-token login ({device})"); + Ok(serde_json::Value::Null) + } + None => { + tracing::warn!("[onboarding] device-token login failed"); + Err(anyhow::anyhow!("Invalid device token")) + } + }; + } + let password = params .get("password") .and_then(|v| v.as_str()) @@ -73,6 +90,48 @@ impl RpcHandler { Ok(serde_json::Value::Null) } + /// Mint a device token for the companion pairing QR. Session-gated by the + /// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI + /// can mint one. The plaintext token is returned exactly once. + pub(super) async fn handle_auth_create_device_token( + &self, + params: Option, + ) -> Result { + let name = params + .as_ref() + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("companion") + .trim() + .to_string(); + if name.is_empty() || name.len() > 64 { + return Err(anyhow::anyhow!("Device name must be 1-64 characters")); + } + let token = crate::device_tokens::create(&self.config.data_dir, &name).await?; + Ok(serde_json::json!({ "name": name, "token": token })) + } + + pub(super) async fn handle_auth_list_device_tokens(&self) -> Result { + let tokens = crate::device_tokens::list(&self.config.data_dir).await; + Ok(serde_json::json!(tokens + .iter() + .map(|t| serde_json::json!({ "name": t.name, "created": t.created })) + .collect::>())) + } + + pub(super) async fn handle_auth_revoke_device_token( + &self, + params: Option, + ) -> Result { + let name = params + .as_ref() + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing name"))?; + let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?; + Ok(serde_json::json!({ "removed": removed })) + } + pub(super) async fn handle_auth_logout(&self) -> Result { tracing::info!("[onboarding] logout"); Ok(serde_json::Value::Null) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 3a74bb95..bfc27c34 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -26,6 +26,9 @@ impl RpcHandler { "auth.onboardingComplete" => self.handle_auth_onboarding_complete().await, "auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await, "auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await, + "auth.createDeviceToken" => self.handle_auth_create_device_token(params).await, + "auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await, + "auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await, // Seed management (BIP-39 mnemonic) "seed.generate" => self.handle_seed_generate().await, @@ -487,6 +490,7 @@ impl RpcHandler { // FIPS mesh transport "fips.status" => self.handle_fips_status().await, + "fips.pair-info" => self.handle_fips_pair_info().await, "fips.check-update" => self.handle_fips_check_update().await, "fips.apply-update" => self.handle_fips_apply_update().await, "fips.install" => self.handle_fips_install().await, diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs index b70d99c8..8253abb6 100644 --- a/core/archipelago/src/api/rpc/fips.rs +++ b/core/archipelago/src/api/rpc/fips.rs @@ -16,6 +16,25 @@ impl RpcHandler { Ok(serde_json::to_value(status)?) } + /// Everything the companion app needs to join this node's mesh, embedded + /// in the pairing QR by the web UI: the daemon's npub (identity to dial), + /// the fips0 ULA (where the UI is reachable once the phone is meshed), + /// and the transport ports on this host. The QR builder supplies the + /// host/IP itself — it knows which origin the browser reached the node on. + pub(super) async fn handle_fips_pair_info(&self) -> Result { + let identity_dir = fips::identity_dir_from(&self.config.data_dir); + let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| { + anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first") + })?; + let ula = fips::iface::fips0_ula().map(|ip| ip.to_string()); + Ok(serde_json::json!({ + "npub": npub, + "ula": ula, + "udp_port": fips::PUBLISHED_UDP_PORT, + "tcp_port": fips::DEFAULT_TCP_PORT, + })) + } + pub(super) async fn handle_fips_check_update(&self) -> Result { let check = fips::update::check().await?; Ok(serde_json::to_value(check)?) diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index dd84ab80..94c72c94 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -532,9 +532,18 @@ impl RpcHandler { self.login_rate_limiter.record_failure(client_ip).await; } - // On successful login, check if 2FA is required + // On successful login, check if 2FA is required. Device-token logins + // (companion pairing QR) skip the TOTP challenge like remember-me does: + // the token was minted from an already-authenticated session, and there + // is no password with which to decrypt the TOTP secret anyway. if method == "auth.login" && rpc_resp.error.is_none() { - let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false); + let is_token_login = login_params + .as_ref() + .and_then(|p| p.get("token")) + .and_then(|v| v.as_str()) + .is_some(); + let totp_enabled = !is_token_login + && self.auth_manager.is_totp_enabled().await.unwrap_or(false); if totp_enabled { let password = login_params .as_ref() diff --git a/core/archipelago/src/device_tokens.rs b/core/archipelago/src/device_tokens.rs new file mode 100644 index 00000000..fdfaf232 --- /dev/null +++ b/core/archipelago/src/device_tokens.rs @@ -0,0 +1,131 @@ +//! Companion device tokens — long-lived bearer credentials minted from an +//! authenticated session, so the pairing QR can log a phone in without +//! carrying the admin password (which the browser never has anyway). +//! +//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the +//! data dir); the plaintext is returned exactly once at mint time and rides +//! the QR as the `tok` param. Verification goes through `auth.login`'s +//! `token` param and is covered by the same login rate limiter as passwords. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +const TOKENS_FILE: &str = "device-tokens.json"; + +/// Cap on stored tokens; re-pairing the same device name replaces its entry, +/// so this only limits the number of *distinct* device names. +const MAX_TOKENS: usize = 32; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceToken { + pub name: String, + /// Hex SHA-256 of the plaintext token. + pub hash: String, + /// Unix seconds at mint time. + pub created: u64, +} + +fn tokens_path(data_dir: &Path) -> PathBuf { + data_dir.join(TOKENS_FILE) +} + +async fn load(data_dir: &Path) -> Vec { + match fs::read(tokens_path(data_dir)).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> { + let bytes = serde_json::to_vec_pretty(tokens)?; + fs::write(tokens_path(data_dir), bytes) + .await + .context("write device-tokens.json") +} + +fn hash_hex(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Mint a new token for `name`. An existing token with the same name is +/// replaced, so re-showing the pairing QR never piles up stale entries. +/// Returns the plaintext token — the only time it ever exists outside the QR. +pub async fn create(data_dir: &Path, name: &str) -> Result { + let token_bytes: [u8; 32] = rand::random(); + let token = hex::encode(token_bytes); + + let mut tokens = load(data_dir).await; + tokens.retain(|t| t.name != name); + if tokens.len() >= MAX_TOKENS { + tokens.remove(0); + } + tokens.push(DeviceToken { + name: name.to_string(), + hash: hash_hex(&token), + created: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + }); + save(data_dir, &tokens).await?; + Ok(token) +} + +/// Verify a candidate token. Returns the device name it was minted for. +pub async fn verify(data_dir: &Path, candidate: &str) -> Option { + let candidate_hash = hash_hex(candidate); + load(data_dir) + .await + .iter() + .find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes())) + .map(|t| t.name.clone()) +} + +/// List stored tokens (hashes only — plaintexts are unrecoverable). +pub async fn list(data_dir: &Path) -> Vec { + load(data_dir).await +} + +/// Remove the token minted for `name`. Returns whether one existed. +pub async fn remove(data_dir: &Path, name: &str) -> Result { + let mut tokens = load(data_dir).await; + let before = tokens.len(); + tokens.retain(|t| t.name != name); + let removed = tokens.len() != before; + if removed { + save(data_dir, &tokens).await?; + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mint_verify_replace_remove() { + let dir = tempfile::tempdir().unwrap(); + let token = create(dir.path(), "phone").await.unwrap(); + assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone")); + assert!(verify(dir.path(), "not-a-token").await.is_none()); + + // Re-minting the same name invalidates the old token. + let token2 = create(dir.path(), "phone").await.unwrap(); + assert!(verify(dir.path(), &token).await.is_none()); + assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone")); + assert_eq!(list(dir.path()).await.len(), 1); + + assert!(remove(dir.path(), "phone").await.unwrap()); + assert!(verify(dir.path(), &token2).await.is_none()); + } +} diff --git a/core/archipelago/src/fips/config.rs b/core/archipelago/src/fips/config.rs index 77b43e90..d25c92b2 100644 --- a/core/archipelago/src/fips/config.rs +++ b/core/archipelago/src/fips/config.rs @@ -15,7 +15,7 @@ use std::path::Path; use tokio::process::Command; use super::{ - DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT, + DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT, }; /// Header prepended to the generated YAML. serde doesn't emit comments, so @@ -137,7 +137,7 @@ impl Default for FipsConfig { }, transports: TransportsSection { udp: TransportBind { - bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"), + bind_addr: format!("0.0.0.0:{PUBLISHED_UDP_PORT}"), }, tcp: TransportBind { bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"), @@ -293,7 +293,7 @@ mod tests { fn test_rendered_yaml_matches_upstream_schema() { let yaml = render_config_yaml(); assert!(yaml.contains("persistent: true")); - assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT))); + assert!(yaml.contains(&format!("0.0.0.0:{}", PUBLISHED_UDP_PORT))); assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT))); assert!(yaml.contains("udp:")); assert!(yaml.contains("tcp:")); @@ -329,7 +329,7 @@ dns: bind_addr: 127.0.0.1 transports: udp: - bind_addr: 0.0.0.0:8668 + bind_addr: 0.0.0.0:2121 tcp: bind_addr: 0.0.0.0:8443 peers: [] diff --git a/core/archipelago/src/fips/mod.rs b/core/archipelago/src/fips/mod.rs index c89f79dc..bbbcfa38 100644 --- a/core/archipelago/src/fips/mod.rs +++ b/core/archipelago/src/fips/mod.rs @@ -119,6 +119,15 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips"; /// Default UDP port the daemon listens on. pub const DEFAULT_UDP_PORT: u16 = 8668; +/// UDP port archipelago actually publishes/binds for the daemon. The +/// container publishes `2121:2121/udp` (see `package/config.rs`) and every +/// peer roster in the fleet dials `:2121`, but the rendered daemon +/// config used to bind upstream's 8668 — leaving inbound UDP dead on +/// bridged-network installs and everything silently riding TCP 8443. The +/// bind now uses this port so the published mapping, the fleet rosters, +/// and the pairing QR all agree. +pub const PUBLISHED_UDP_PORT: u16 = 2121; + /// Default TCP port the daemon listens on. Used as a fallback when a /// peer can't be reached over UDP — common on networks that block UDP /// (corporate/guest wifi) and the path the public fips.v0l.io anchor diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 0b474ea4..1da85d94 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -45,6 +45,7 @@ mod content_server; mod crash_recovery; mod credentials; mod data_model; +mod device_tokens; mod disk_monitor; mod electrs_status; mod federation; diff --git a/docs/companion-pairing-qr.md b/docs/companion-pairing-qr.md index e596ff0c..af3fb7c3 100644 --- a/docs/companion-pairing-qr.md +++ b/docs/companion-pairing-qr.md @@ -22,7 +22,7 @@ the public demo) gained a second screen: A single URI, also usable as an OS deep link: ``` -archipelago://pair?v=1&url=[&pw=] +archipelago://pair?v=1&url=&name=[&tok=][&pw=][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…] ``` Query parameters: @@ -31,7 +31,13 @@ Query parameters: |-------|----------|---------| | `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". | | `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. | +| `name`| no | Display name for the server entry. Real nodes send the configured server name, or `My Archipelago` when it's still the factory default. | +| `tok` | no | **Device token** minted via `auth.createDeviceToken` when the QR is rendered. The app logs in with `{"method":"auth.login","params":{"token":"…"}}` — same endpoint, same rate limiter, skips TOTP (the token was minted from an authenticated session). Long-lived until re-minted (re-showing the pair screen replaces the `companion` token) or revoked (`auth.revokeDeviceToken`). Scan → instantly connected, no typing. | | `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. | +| `fnpub` | no | Node's FIPS mesh identity (bech32 npub of the daemon's seed-derived key). Presence of this param means "this node speaks FIPS — mesh with it". | +| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). | +| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). | +| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). | Examples the web UI actually emits: diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index a4721c27..83e5f50b 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -66,138 +66,13 @@ - -
-
-
- -
-
-

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 }}

- - - - - - - - -
-
@@ -212,7 +87,7 @@

Connect your app

- In the companion app, choose “Scan Node's QR” and point your phone here — the server details fill in automatically. + In the companion app, choose “Scan Node's QR” and point your phone here — it connects instantly and sets up secure remote access over the mesh.

@@ -227,7 +102,9 @@ alt="Companion app pairing QR code" class="block w-full max-w-full h-auto rounded-lg bg-white" /> -
+
+ +
@@ -244,7 +121,7 @@ @@ -262,6 +139,7 @@ import * as QRCode from 'qrcode' import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro' import { companionIntroRequested } from '@/composables/useCompanionIntro' import { useLoginTransitionStore } from '@/stores/loginTransition' +import { useServerStore } from '@/stores/server' import { rpcClient } from '@/api/rpc-client' const STORAGE_KEY = 'neode_companion_intro_seen' @@ -279,45 +157,24 @@ 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 +// Fallback display name when the node still has the factory server name. +const DEFAULT_PAIR_NAME = 'My Archipelago' -// 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' +// Device-token entry name; re-minting replaces the previous token so +// re-showing this screen never piles up credentials server-side. +const DEVICE_TOKEN_NAME = 'companion' const visible = ref(false) -const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download') +const step = ref<'download' | '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 pairLoading = ref(false) 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() +const serverStore = useServerStore() // Base delay before the popup may appear, and extra breathing room after the // dashboard entrance cinematic ends so the popup never cuts into the reveal. @@ -328,9 +185,7 @@ let calmTicker: ReturnType | null = null // Running inside the companion app's own WebView (it injects this JS bridge). // The "get the companion app" pitch is nonsense there — the user is already in -// it — and the WG steps mid-pairing race the phone's changing network (the -// "Failed to fetch" dead-end QR). Server/tunnel management for connected -// companions lives in the NESMenu instead. +// it. Server management for connected companions lives in the NESMenu instead. const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined' onMounted(() => { @@ -395,17 +250,6 @@ 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' }) /** @@ -416,9 +260,6 @@ 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 { @@ -430,18 +271,78 @@ async function resolveServerUrl(): Promise { return origin } +/** + * Pairing payload the companion app consumes (docs/companion-pairing-qr.md): + * v contract version (1) + * url where the app connects right now (LAN origin / mDNS) + * name what the server is called in the app + * tok device token — the app logs in with it instantly (real nodes) + * pw shared demo password (demo only) + * fnpub node's FIPS mesh identity — the phone's embedded FIPS dials it + * fip node's fips0 ULA — where the UI stays reachable once meshed + * fhost host the phone dials for the mesh (same host `url` resolved to) + * fudp / ftcp mesh transport ports on fhost + * + * Token and mesh params are best-effort: without them the QR still pairs the + * old way (manual password, LAN only), so a mid-onboarding backend hiccup + * never produces a dead QR. + */ async function buildPairingUrl(): Promise { - const params = new URLSearchParams({ v: '1', url: await resolveServerUrl() }) - // Only the shared demo password ever rides in the QR; real node passwords - // are never available to the frontend and stay typed-by-hand in the app. - if (IS_DEMO) params.set('pw', DEMO_PASSWORD) + const serverUrl = await resolveServerUrl() + const params = new URLSearchParams({ v: '1', url: serverUrl }) + + const name = serverStore.serverName + params.set('name', !name || name === 'Archipelago' ? DEFAULT_PAIR_NAME : name) + + if (IS_DEMO) { + // Only the shared demo password ever rides in the QR; real node passwords + // are never available to the frontend. + params.set('pw', DEMO_PASSWORD) + return `${PAIR_SCHEME}?${params.toString()}` + } + + try { + const res = await rpcClient.call<{ token?: string }>({ + method: 'auth.createDeviceToken', + params: { name: DEVICE_TOKEN_NAME }, + }) + if (res?.token) params.set('tok', res.token) + } catch { + // Older backend or transient failure — app falls back to password entry. + } + + try { + const info = await rpcClient.call<{ + npub?: string + ula?: string | null + udp_port?: number + tcp_port?: number + }>({ method: 'fips.pair-info' }) + if (info?.npub) { + params.set('fnpub', info.npub) + if (info.ula) params.set('fip', info.ula) + try { + params.set('fhost', new URL(serverUrl).hostname) + } catch { + params.set('fhost', window.location.hostname) + } + if (info.udp_port) params.set('fudp', String(info.udp_port)) + if (info.tcp_port) params.set('ftcp', String(info.tcp_port)) + } + } catch { + // FIPS not provisioned yet — LAN pairing still works; the app can mesh + // later from a re-scan. + } + return `${PAIR_SCHEME}?${params.toString()}` } async function showPairScreen() { slideName.value = 'slide-forward' step.value = 'pair' - if (!pairQrDataUrl.value) { + if (pairQrDataUrl.value || pairLoading.value) return + pairLoading.value = true + try { pairingUrl.value = await buildPairingUrl() // Large source + a real quiet zone; this QR is scanned by the companion // app's camera, so give it every advantage (see download QR note above). @@ -454,6 +355,8 @@ async function showPairScreen() { light: '#ffffff', }, }) + } finally { + pairLoading.value = false } } @@ -462,131 +365,6 @@ 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() - } -} - -// Network-class failures: the node itself was unreachable (as opposed to the -// backend answering with an RPC error). Includes the client's own timeout. -const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i - -// Auto-retry ladder for network-class failures. On a first install this step -// is often reached while the backend is still settling (services starting, -// backend restarting during container orchestration) — a single failed fetch -// left a permanently blank QR unless the user spotted the retry button. -const WG_RETRY_DELAYS_MS = [2000, 4000, 8000] - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) -const stillOnWgStep = () => visible.value && step.value === 'wgqr' - -// 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 = '' - for (let attempt = 0; ; attempt++) { - try { - await provisionWgPeer() - break - } catch (e) { - const raw = e instanceof Error ? e.message : '' - const isNetworkErr = WG_NETWORK_ERR.test(raw) - const retryDelay = WG_RETRY_DELAYS_MS[attempt] - if (isNetworkErr && retryDelay !== undefined && stillOnWgStep()) { - await sleep(retryDelay) - if (stillOnWgStep()) continue - } - // fetch()'s raw "Failed to fetch" means the node itself was unreachable — - // after the retry ladder that's usually the phone's network mid-change - // (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16). - // Say so, and leave a Retry path instead of a dead end. - wgError.value = isNetworkErr - ? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again." - : raw || 'Failed to generate the tunnel config' - break - } - } - wgLoading.value = false -} - -async function provisionWgPeer() { - const listed = await rpcClient - .call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' }) - .catch(() => null) - // list-peers unreachable → don't guess "doesn't exist": create-peer on an - // existing name would fail. Try create first, fall back to peer-config. - const exists = listed === null - ? null - : (listed.peers || []).some((p) => p.name === WG_PEER_NAME) - let res: { config: string; peer_ip: string } - if (exists === true) { - res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) - } else { - try { - res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } }) - } catch (e) { - // Peer already provisioned on a previous visit (list failed or raced). - const msg = e instanceof Error ? e.message : '' - if (/exist|duplicate/i.test(msg)) { - res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } }) - } else { - throw e - } - } - } - wgConfig.value = res.config - wgQrDataUrl.value = await QRCode.toDataURL(res.config, { - width: 512, - margin: 3, - errorCorrectionLevel: 'M', - color: { - dark: '#111111', - light: '#ffffff', - }, - }) -} - -function retryWgPeer() { - wgError.value = '' - void loadWgPeer() -} - -// 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'