feat: instant companion pairing — device tokens, named QR, FIPS pair-info

- 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 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-22 21:16:27 +01:00
parent 8b744d377c
commit b88609e0ff
10 changed files with 328 additions and 312 deletions

View File

@ -9,6 +9,23 @@ impl RpcHandler {
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value> {
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::<Vec<_>>()))
}
pub(super) async fn handle_auth_revoke_device_token(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
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<serde_json::Value> {
tracing::info!("[onboarding] logout");
Ok(serde_json::Value::Null)

View File

@ -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,

View File

@ -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<serde_json::Value> {
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<serde_json::Value> {
let check = fips::update::check().await?;
Ok(serde_json::to_value(check)?)

View File

@ -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()

View File

@ -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<DeviceToken> {
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<String> {
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<String> {
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<DeviceToken> {
load(data_dir).await
}
/// Remove the token minted for `name`. Returns whether one existed.
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
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());
}
}

View File

@ -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: []

View File

@ -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 `<host>: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

View File

@ -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;

View File

@ -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=<percent-encoded server URL>[&pw=<percent-encoded password>]
archipelago://pair?v=1&url=<origin>&name=<display name>[&tok=<device token>][&pw=<password>][&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://[<fip>]` 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:

View File

@ -66,137 +66,12 @@
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="advanceFromDownload"
>
I've installed it
</button>
</div>
</div>
<!-- Screen 2 (remote access): install WireGuard -->
<div v-else-if="step === 'wireguard'" key="wireguard">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
<p class="text-sm text-white/60 leading-relaxed">
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
</p>
</div>
</div>
<div class="hidden md:flex justify-center mb-4">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgApkQrDataUrl"
:src="wgApkQrDataUrl"
alt="WireGuard app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
<div class="flex gap-2">
<a
:href="wireguardDownloadUrl"
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
download
>
Download WireGuard
</a>
<button
type="button"
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
@click="showWgQrScreen"
>
I've installed it
</button>
</div>
<button
type="button"
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showDownloadScreen"
>
Back
</button>
</div>
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
<div v-else-if="step === 'wgqr'" key="wgqr">
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
</svg>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
<p class="text-sm text-white/60 leading-relaxed">
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
</p>
</div>
</div>
<div class="flex justify-center mb-3">
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="wgQrDataUrl"
:src="wgQrDataUrl"
alt="WireGuard tunnel config QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" 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>
</div>
</div>
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
<button
v-if="wgError && !wgLoading"
type="button"
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
@click="retryWgPeer"
>
Try again
</button>
<!-- Same-device path: a phone can't scan its own screen, so offer
the config as a file WireGuard can import. -->
<button
v-if="wgConfig"
type="button"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
@click="downloadWgConfig"
>
Download tunnel config
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
@click="showPairScreen"
>
I'm connected
</button>
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="showWireguardScreen('back')"
>
Back
I've installed it
</button>
</div>
</div>
<!-- Screen 2: pair the app with this node -->
<div v-else key="pair">
@ -212,7 +87,7 @@
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
<p class="text-sm text-white/60 leading-relaxed">
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.
</p>
</div>
</div>
@ -227,7 +102,9 @@
alt="Companion app pairing QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
<svg v-if="pairLoading" class="w-6 h-6 animate-spin text-white/40" 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>
</div>
</div>
@ -244,7 +121,7 @@
<button
type="button"
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
@click="backFromPair"
@click="showDownloadScreen"
>
Back
</button>
@ -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<typeof setInterval> | 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<string> {
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<string> {
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<string> {
const params = new URLSearchParams({ v: '1', url: await resolveServerUrl() })
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 and stay typed-by-hand in the app.
if (IS_DEMO) params.set('pw', DEMO_PASSWORD)
// 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<void>((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'