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 acf34278..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 = 35 - versionName = "0.5.15" + versionCode = 38 + versionName = "0.5.18" vectorDrawables { useSupportLibrary = true 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 3c78dec6..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 @@ -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 -> @@ -249,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] ?: "[]") 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/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index 65ef7bc1..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 09ffe886..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).