diff --git a/Android/COMPANION_RELEASE.md b/Android/COMPANION_RELEASE.md index 1ef25999..e508c580 100644 --- a/Android/COMPANION_RELEASE.md +++ b/Android/COMPANION_RELEASE.md @@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which: **aborts** if any is missing. 5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`, commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass). +6. The first-launch companion modal and Android "Share this app" QR point at + `http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the + repo artifact is built, mirror that exact APK to the VPS2-served path before + calling the release done. **Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path skips the clean build and the signature enforcement and is exactly how a broken @@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action. ## Verify the published download after shipping -The download served to nodes is Gitea raw-on-main. Confirm the live bytes match -what you built and signed: +The checked-in artifact is Gitea raw-on-main. The QR/App Store download served +to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you +built and signed: ```bash SERVED=neode-ui/public/packages/archipelago-companion.apk -URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED -curl -sS -o /tmp/live.apk "$URL" -shasum -a 256 "$SERVED" /tmp/live.apk # must match -apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true +GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED +QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk +curl -sS -o /tmp/live-gitea.apk "$GITEA_URL" +curl -sS -o /tmp/live-qr.apk "$QR_URL" +shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match +apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true ``` diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index cdb40cf6..45e47cac 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 31 - versionName = "0.5.11" + versionCode = 38 + versionName = "0.5.18" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt index 1af28b16..465d8838 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt @@ -88,8 +88,18 @@ object ServerQrParser { private fun parseFips(uri: Uri): FipsPairInfo? { val npub = uri.getQueryParameter("fnpub")?.trim() - val host = uri.getQueryParameter("fhost")?.trim() + var host = uri.getQueryParameter("fhost")?.trim() if (npub.isNullOrBlank() || host.isNullOrBlank()) return null + // A .fips fhost is a dead dial hint: Android's system DNS can't + // resolve .fips, so every handshake send fails and first connect + // falls back to slow anchor discovery. If the QR's `url` host is a + // real address, dial that instead (QRs minted while the node UI was + // browsed over the mesh carry npub….fips here). + if (host.endsWith(".fips")) { + val urlHost = uri.getQueryParameter("url") + ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost + } return FipsPairInfo( npub = npub, ula = uri.getQueryParameter("fip")?.trim().orEmpty(), diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt index e4392529..7065f82f 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt @@ -25,6 +25,26 @@ internal const val ARCHY_ANCHOR_NPUB = internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444" internal const val ARCHY_ANCHOR_TRANSPORT = "tcp" +/** + * Public FIPS network anchors (join.fips.network — the dual-transport TCP + * pair; keep in lockstep with core/archipelago/src/fips/anchors.rs + * fips_network_anchors()). Baked into every pairing at trailing priority so + * a degraded/unreachable vps2 anchor can never strand the phone: the mesh + * still joins the public tree and routes to the node through it. + */ +internal val PUBLIC_FIPS_ANCHORS = listOf( + Triple( + "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u", + "23.182.128.74:443", + "tcp", + ), + Triple( + "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98", + "217.77.8.91:443", + "tcp", + ), +) + /** * Mesh identity + known node peers. Follows the same plaintext-DataStore * storage model as ServerPreferences (the server password lives there the @@ -126,7 +146,7 @@ class FipsPreferences(private val context: Context) { if (peer.ip.isBlank() || peer.port <= 0) continue merged.put(JSONObject().apply { put("npub", peer.npub) - put("alias", peer.name.ifBlank { "Party phone" }) + put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" })) put("addresses", JSONArray().put(JSONObject().apply { put("transport", "udp") put("addr", "${peer.ip}:${peer.port}") @@ -145,7 +165,7 @@ class FipsPreferences(private val context: Context) { ) { merged.put(JSONObject().apply { put("npub", ARCHY_ANCHOR_NPUB) - put("alias", "Archipelago anchor") + put("alias", "archipelago-anchor") put("addresses", JSONArray().put(JSONObject().apply { put("transport", ARCHY_ANCHOR_TRANSPORT) put("addr", ARCHY_ANCHOR_ADDR) @@ -153,9 +173,40 @@ class FipsPreferences(private val context: Context) { })) }) } + // Backfill anchor peers for entries paired before newer QR/app + // releases added them. `upsertNodePeer` persists these on re-scan, but + // startup must also self-heal old DataStore state so updating the APK is + // enough to get off-LAN redundancy. + if (merged.length() > 0) { + addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40) + for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) { + val (npub, addr, transport) = anchor + addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10) + } + } return merged.toString() } + private fun addAnchorIfMissing( + peers: JSONArray, + npub: String, + alias: String, + addr: String, + transport: String, + priority: Int, + ) { + if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return + peers.put(JSONObject().apply { + put("npub", npub) + put("alias", alias) + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", transport) + put("addr", addr) + put("priority", priority) + })) + }) + } + private fun parsePartyPeers(json: String): List = try { val arr = JSONArray(json) (0 until arr.length()).mapNotNull { i -> @@ -200,16 +251,19 @@ class FipsPreferences(private val context: Context) { val incoming = mutableListOf() incoming += JSONObject().apply { put("npub", info.npub) - put("alias", alias.ifBlank { "Archipelago" }) + put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" })) val addresses = JSONArray() - if (info.udpPort > 0) { + // .fips hosts are unresolvable on Android (no system .fips DNS): + // storing one gives the mesh a dial target that fails every + // handshake and stalls first connect on anchor discovery. + if (info.udpPort > 0 && !info.host.endsWith(".fips")) { addresses.put(JSONObject().apply { put("transport", "udp") put("addr", "${info.host}:${info.udpPort}") put("priority", 10) }) } - if (info.tcpPort > 0) { + if (info.tcpPort > 0 && !info.host.endsWith(".fips")) { addresses.put(JSONObject().apply { put("transport", "tcp") put("addr", "${info.host}:${info.tcpPort}") @@ -220,9 +274,10 @@ class FipsPreferences(private val context: Context) { } for (anchor in info.anchors) { if (anchor.npub == info.npub) continue + if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue incoming += JSONObject().apply { put("npub", anchor.npub) - put("alias", "Mesh anchor") + put("alias", "mesh-anchor") put("addresses", JSONArray().put(JSONObject().apply { put("transport", anchor.transport) put("addr", anchor.addr) @@ -237,7 +292,7 @@ class FipsPreferences(private val context: Context) { ) { incoming += JSONObject().apply { put("npub", ARCHY_ANCHOR_NPUB) - put("alias", "Archipelago anchor") + put("alias", "archipelago-anchor") put("addresses", JSONArray().put(JSONObject().apply { put("transport", ARCHY_ANCHOR_TRANSPORT) put("addr", ARCHY_ANCHOR_ADDR) @@ -245,6 +300,21 @@ class FipsPreferences(private val context: Context) { })) } } + // And the public FIPS network anchors at trailing priority, so one + // degraded rendezvous (vps2, 2026-07-24) can never strand the phone. + for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) { + val (npub, addr, transport) = anchor + if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue + incoming += JSONObject().apply { + put("npub", npub) + put("alias", "fips-network-anchor-${i + 1}") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", transport) + put("addr", addr) + put("priority", 50 + i * 10) + })) + } + } val incomingNpubs = incoming.map { it.optString("npub") }.toSet() context.fipsDataStore.edit { prefs -> val current = JSONArray(prefs[peersKey] ?: "[]") @@ -258,3 +328,14 @@ class FipsPreferences(private val context: Context) { } } } + +/** + * Peer aliases feed the fips host map as `.fips` hostnames; anything + * that isn't a valid DNS label ("Framework PT" — the space) gets rejected and + * silently drops the peer from name resolution. Slug it instead of losing it. + */ +internal fun hostSafeAlias(alias: String): String = + alias.lowercase() + .replace(Regex("[^a-z0-9.-]+"), "-") + .trim('-', '.') + .ifBlank { "archipelago" } diff --git a/Android/rust/archy-fips-core/Cargo.lock b/Android/rust/archy-fips-core/Cargo.lock index a6bbd62c..b99f5ba2 100644 --- a/Android/rust/archy-fips-core/Cargo.lock +++ b/Android/rust/archy-fips-core/Cargo.lock @@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fips" version = "0.3.0-dev" -source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6" +source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f" dependencies = [ "bech32", "chacha20poly1305", diff --git a/Android/rust/archy-fips-core/Cargo.toml b/Android/rust/archy-fips-core/Cargo.toml index 199d4517..e6cc8cf4 100644 --- a/Android/rust/archy-fips-core/Cargo.toml +++ b/Android/rust/archy-fips-core/Cargo.toml @@ -23,7 +23,10 @@ crate-type = ["lib", "cdylib"] [dependencies] # default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd. -fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] } +# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 + +# discovery re-fire on topology change (fresh 5G join: first route no longer +# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes. +fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] } anyhow = "1.0" serde_json = "1.0" hex = "0.4" diff --git a/Android/rust/archy-fips-core/src/mesh.rs b/Android/rust/archy-fips-core/src/mesh.rs index 86c1058a..ed33db90 100644 --- a/Android/rust/archy-fips-core/src/mesh.rs +++ b/Android/rust/archy-fips-core/src/mesh.rs @@ -85,6 +85,27 @@ pub fn build_config(secret: &str, peers: Vec, listen_port: u16) -> C }); // TCP with no bind_addr = outbound-only (fallback when UDP is blocked). cfg.transports.tcp = TransportInstances::Single(Default::default()); + // Fast-connect profile — a phone opens the app and expects the node NOW. + // Stock pacing is tuned for always-on routers: a failed discovery backs + // off 30s, session resends gap out to 8-16s, dead links redial at + // 5s→300s. Over 5G that stacked into a ~40s first connect (observed + // 2026-07-24: anchor +10s, session +40s). Retries only fire while a + // link/session is down, so steady-state traffic is unchanged. + cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s… + cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min + cfg.node.retry.max_retries = 30; + cfg.node.rate_limit.handshake_resend_interval_ms = 400; + cfg.node.rate_limit.handshake_resend_backoff = 1.5; + cfg.node.rate_limit.handshake_max_resends = 10; + cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast + cfg.node.discovery.backoff_max_secs = 30; + cfg.node.discovery.retry_interval_secs = 2; + cfg.node.discovery.max_attempts = 3; + // Lookups launched before the tree position settles are doomed; a 10s + // completion timeout made each one cost 10s before the 1s retry could + // fire (observed: 19s to a route on a fresh join). Fail fast instead — + // the resend-within-window above still gives each attempt two shots. + cfg.node.discovery.timeout_secs = 5; cfg.peers = peers; cfg } diff --git a/core/archipelago/src/fips/anchors.rs b/core/archipelago/src/fips/anchors.rs index 52a0258b..1e33ffe8 100644 --- a/core/archipelago/src/fips/anchors.rs +++ b/core/archipelago/src/fips/anchors.rs @@ -156,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result> { .with_context(|| format!("read {}", path.display()))?; let anchors: Vec = serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?; - Ok(anchors) + Ok(repair_legacy_anchor_set(anchors)) +} + +/// Add the newer redundant public TCP anchors to legacy files that only carry +/// the Archipelago-operated vps2 anchor. A deliberately custom/private anchor +/// list remains authoritative; this only repairs the exact stale shape shipped +/// before the join.fips.network anchors became defaults. +fn repair_legacy_anchor_set(mut anchors: Vec) -> Vec { + let has_archy = anchors.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB); + let has_fips_network = fips_network_anchors() + .iter() + .any(|default| anchors.iter().any(|a| a.npub == default.npub)); + if has_archy && !has_fips_network { + for anchor in fips_network_anchors() { + anchors.push(anchor); + } + } + anchors } /// Persist the list. Overwrites atomically via write-then-rename so a @@ -338,6 +355,30 @@ mod tests { assert!(got.iter().all(|a| a.transport == "tcp")); } + #[tokio::test] + async fn load_repairs_legacy_archy_only_anchor_file() { + let dir = tempfile::tempdir().unwrap(); + save(dir.path(), &[archy_anchor()]).await.unwrap(); + + let got = load(dir.path()).await.unwrap(); + assert!(got.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB)); + for anchor in fips_network_anchors() { + assert!(got.iter().any(|a| a.npub == anchor.npub)); + } + } + + #[tokio::test] + async fn load_keeps_private_anchor_file_authoritative() { + let dir = tempfile::tempdir().unwrap(); + let private = mk("npub1private"); + save(dir.path(), std::slice::from_ref(&private)) + .await + .unwrap(); + + let got = load(dir.path()).await.unwrap(); + assert_eq!(got, vec![private]); + } + #[tokio::test] async fn removing_one_default_persists_and_keeps_the_other() { // Editing the anchor list (here removing one default) makes the file diff --git a/core/archipelago/src/fips/service.rs b/core/archipelago/src/fips/service.rs index a26953d3..1d4106c4 100644 --- a/core/archipelago/src/fips/service.rs +++ b/core/archipelago/src/fips/service.rs @@ -81,6 +81,7 @@ async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> { /// Unmask + start + enable the FIPS service. Idempotent — safe to call /// on every backend startup once the key is on disk. pub async fn activate(unit: &str) -> Result<()> { + kill_stale_daemons().await?; // Order matters: unmask before enable/start, otherwise enable fails // on a masked unit. sudo_systemctl("unmask", unit).await?; @@ -94,9 +95,42 @@ pub async fn stop(unit: &str) -> Result<()> { } pub async fn restart(unit: &str) -> Result<()> { + kill_stale_daemons().await?; sudo_systemctl("restart", unit).await } +/// Kill orphaned `fips` processes not owned by either known systemd unit. +/// +/// Field failure, 2026-07-24: a stale daemon survived outside systemd and kept +/// `0.0.0.0:8443` bound. The supervised daemon then started UDP-only, so every +/// TCP seed-anchor connect failed with "no operational transport" and phones +/// on 5G could not discover the node. We keep the cleanup narrow: preserve the +/// MainPID of both units and terminate only extra `pgrep -x fips` matches. +pub async fn kill_stale_daemons() -> Result<()> { + let script = format!( + r#"keep="$(systemctl show -p MainPID --value {managed} 2>/dev/null; systemctl show -p MainPID --value {upstream} 2>/dev/null)" +for pid in $(pgrep -x fips 2>/dev/null || true); do + case " $keep " in + *" $pid "*) ;; + *) kill "$pid" 2>/dev/null || true ;; + esac +done +"#, + managed = super::SERVICE_UNIT, + upstream = super::UPSTREAM_SERVICE_UNIT, + ); + let out = Command::new("sudo") + .args(["sh", "-c", &script]) + .output() + .await + .context("sudo stale fips cleanup failed to launch")?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + anyhow::bail!("stale fips cleanup failed: {}", stderr); + } + Ok(()) +} + /// Resolve which systemd unit is actually supervising the fips daemon /// on this host. Nodes installed from the archipelago ISO run /// `archipelago-fips.service`; nodes that were apt-installed (or had diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 49b14d45..c374ee3f 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> // Mark original proofs as spent wallet.mark_spent(&indices); - // Separate send proofs from change proofs - let (send, change): (Vec<_>, Vec<_>) = swap_result - .new_proofs - .into_iter() - .partition(|p| send_denoms.contains(&p.amount)); + // Separate send proofs from change proofs BY COUNT, not membership: + // partition(contains) put EVERY proof whose denomination appeared in + // send_denoms into the token — when change shared a denomination with + // the send (worst case: spending from a proof worth exactly 2× the + // amount, send [n] + change [n]), the change proofs rode along and the + // receiver was credited double. Consume exactly one proof per needed + // send denomination; everything else is change. + let mut send_needed = send_denoms.clone(); + let mut send: Vec = Vec::new(); + let mut change: Vec = Vec::new(); + for p in swap_result.new_proofs { + if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) { + send_needed.swap_remove(pos); + send.push(p); + } else { + change.push(p); + } + } + if !send_needed.is_empty() { + anyhow::bail!( + "Mint swap returned incomplete send denominations (missing {:?})", + send_needed + ); + } // Add change proofs back to wallet if !change.is_empty() { diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index bfa5d411..095d27b8 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index a8f4526f..c5ed8f3f 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -150,7 +150,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen' // exposes the release-server address. const DEFAULT_DOWNLOAD_URL = IS_DEMO ? `${window.location.origin}/packages/archipelago-companion.apk` - : 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk' + : 'http://146.59.87.168:2100/packages/archipelago-companion.apk' // Deep-link scheme the companion app registers; carries the server entry the // app should create (see docs/companion-pairing-qr.md for the contract). @@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean { * - a tailnet 100.x address (operator browsing over Tailscale — a scanned * QR carried one of these on 2026-07-22 and the companion sat there * dialing an IP the phone had no route to) + * - a .fips name or mesh ULA (operator browsing over the mesh — a scanned + * QR carried npub….fips as fhost on 2026-07-24; Android's system DNS + * can't resolve .fips, so the phone's direct dial died and first + * connect crawled through anchor discovery instead of the LAN) */ async function resolveServerUrl(): Promise { if (IS_DEMO) return DEMO_SERVER_URL const { hostname, origin } = window.location const phoneUnreachable = - hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname) + hostname === 'localhost' || + hostname === '127.0.0.1' || + isTailnetIp(hostname) || + hostname.endsWith('.fips') || + hostname.includes(':') // IPv6 literal — the node's mesh ULA if (!phoneUnreachable) return origin try { const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({