fix(fips): harden companion mesh joins
This commit is contained in:
parent
4589e88442
commit
875da6c630
@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
|
|||||||
**aborts** if any is missing.
|
**aborts** if any is missing.
|
||||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
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).
|
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
|
**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
|
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
|
## Verify the published download after shipping
|
||||||
|
|
||||||
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
|
||||||
what you built and signed:
|
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
|
||||||
|
built and signed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||||
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||||
curl -sS -o /tmp/live.apk "$URL"
|
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
|
||||||
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
|
||||||
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 35
|
versionCode = 38
|
||||||
versionName = "0.5.15"
|
versionName = "0.5.18"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -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_ADDR = "146.59.87.168:8444"
|
||||||
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
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
|
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||||
* storage model as ServerPreferences (the server password lives there the
|
* 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()
|
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<PartyPeer> = try {
|
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).mapNotNull { i ->
|
(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()
|
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||||
context.fipsDataStore.edit { prefs ->
|
context.fipsDataStore.edit { prefs ->
|
||||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||||
|
|||||||
@ -156,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
|||||||
.with_context(|| format!("read {}", path.display()))?;
|
.with_context(|| format!("read {}", path.display()))?;
|
||||||
let anchors: Vec<SeedAnchor> =
|
let anchors: Vec<SeedAnchor> =
|
||||||
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
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<SeedAnchor>) -> Vec<SeedAnchor> {
|
||||||
|
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
|
/// 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"));
|
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]
|
#[tokio::test]
|
||||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||||
// Editing the anchor list (here removing one default) makes the file
|
// Editing the anchor list (here removing one default) makes the file
|
||||||
|
|||||||
@ -81,6 +81,7 @@ async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> {
|
|||||||
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
|
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
|
||||||
/// on every backend startup once the key is on disk.
|
/// on every backend startup once the key is on disk.
|
||||||
pub async fn activate(unit: &str) -> Result<()> {
|
pub async fn activate(unit: &str) -> Result<()> {
|
||||||
|
kill_stale_daemons().await?;
|
||||||
// Order matters: unmask before enable/start, otherwise enable fails
|
// Order matters: unmask before enable/start, otherwise enable fails
|
||||||
// on a masked unit.
|
// on a masked unit.
|
||||||
sudo_systemctl("unmask", unit).await?;
|
sudo_systemctl("unmask", unit).await?;
|
||||||
@ -94,9 +95,42 @@ pub async fn stop(unit: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn restart(unit: &str) -> Result<()> {
|
pub async fn restart(unit: &str) -> Result<()> {
|
||||||
|
kill_stale_daemons().await?;
|
||||||
sudo_systemctl("restart", unit).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
|
/// Resolve which systemd unit is actually supervising the fips daemon
|
||||||
/// on this host. Nodes installed from the archipelago ISO run
|
/// on this host. Nodes installed from the archipelago ISO run
|
||||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
||||||
|
|||||||
@ -150,7 +150,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen'
|
|||||||
// exposes the release-server address.
|
// exposes the release-server address.
|
||||||
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
? `${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
|
// Deep-link scheme the companion app registers; carries the server entry the
|
||||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user