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:
parent
8b744d377c
commit
b88609e0ff
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)?)
|
||||
|
||||
@ -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()
|
||||
|
||||
131
core/archipelago/src/device_tokens.rs
Normal file
131
core/archipelago/src/device_tokens.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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: []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user