Merge branch 'main' of http://146.59.87.168:3000/lfg2025/archy
Demo images / Build & push demo images (push) Successful in 3m2s
Demo images / Build & push demo images (push) Successful in 3m2s
This commit is contained in:
@@ -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 = 31
|
versionCode = 38
|
||||||
versionName = "0.5.11"
|
versionName = "0.5.18"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -88,8 +88,18 @@ object ServerQrParser {
|
|||||||
|
|
||||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
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
|
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(
|
return FipsPairInfo(
|
||||||
npub = npub,
|
npub = npub,
|
||||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,7 +146,7 @@ class FipsPreferences(private val context: Context) {
|
|||||||
if (peer.ip.isBlank() || peer.port <= 0) continue
|
if (peer.ip.isBlank() || peer.port <= 0) continue
|
||||||
merged.put(JSONObject().apply {
|
merged.put(JSONObject().apply {
|
||||||
put("npub", peer.npub)
|
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("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", "udp")
|
put("transport", "udp")
|
||||||
put("addr", "${peer.ip}:${peer.port}")
|
put("addr", "${peer.ip}:${peer.port}")
|
||||||
@@ -145,7 +165,7 @@ class FipsPreferences(private val context: Context) {
|
|||||||
) {
|
) {
|
||||||
merged.put(JSONObject().apply {
|
merged.put(JSONObject().apply {
|
||||||
put("npub", ARCHY_ANCHOR_NPUB)
|
put("npub", ARCHY_ANCHOR_NPUB)
|
||||||
put("alias", "Archipelago anchor")
|
put("alias", "archipelago-anchor")
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||||
put("addr", ARCHY_ANCHOR_ADDR)
|
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()
|
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 ->
|
||||||
@@ -200,16 +251,19 @@ class FipsPreferences(private val context: Context) {
|
|||||||
val incoming = mutableListOf<JSONObject>()
|
val incoming = mutableListOf<JSONObject>()
|
||||||
incoming += JSONObject().apply {
|
incoming += JSONObject().apply {
|
||||||
put("npub", info.npub)
|
put("npub", info.npub)
|
||||||
put("alias", alias.ifBlank { "Archipelago" })
|
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
|
||||||
val addresses = JSONArray()
|
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 {
|
addresses.put(JSONObject().apply {
|
||||||
put("transport", "udp")
|
put("transport", "udp")
|
||||||
put("addr", "${info.host}:${info.udpPort}")
|
put("addr", "${info.host}:${info.udpPort}")
|
||||||
put("priority", 10)
|
put("priority", 10)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (info.tcpPort > 0) {
|
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
|
||||||
addresses.put(JSONObject().apply {
|
addresses.put(JSONObject().apply {
|
||||||
put("transport", "tcp")
|
put("transport", "tcp")
|
||||||
put("addr", "${info.host}:${info.tcpPort}")
|
put("addr", "${info.host}:${info.tcpPort}")
|
||||||
@@ -220,9 +274,10 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
for (anchor in info.anchors) {
|
for (anchor in info.anchors) {
|
||||||
if (anchor.npub == info.npub) continue
|
if (anchor.npub == info.npub) continue
|
||||||
|
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
|
||||||
incoming += JSONObject().apply {
|
incoming += JSONObject().apply {
|
||||||
put("npub", anchor.npub)
|
put("npub", anchor.npub)
|
||||||
put("alias", "Mesh anchor")
|
put("alias", "mesh-anchor")
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", anchor.transport)
|
put("transport", anchor.transport)
|
||||||
put("addr", anchor.addr)
|
put("addr", anchor.addr)
|
||||||
@@ -237,7 +292,7 @@ class FipsPreferences(private val context: Context) {
|
|||||||
) {
|
) {
|
||||||
incoming += JSONObject().apply {
|
incoming += JSONObject().apply {
|
||||||
put("npub", ARCHY_ANCHOR_NPUB)
|
put("npub", ARCHY_ANCHOR_NPUB)
|
||||||
put("alias", "Archipelago anchor")
|
put("alias", "archipelago-anchor")
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||||
put("addr", ARCHY_ANCHOR_ADDR)
|
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()
|
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] ?: "[]")
|
||||||
@@ -258,3 +328,14 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer aliases feed the fips host map as `<alias>.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" }
|
||||||
|
|||||||
Generated
+1
-1
@@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "fips"
|
name = "fips"
|
||||||
version = "0.3.0-dev"
|
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 = [
|
dependencies = [
|
||||||
"bech32",
|
"bech32",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ crate-type = ["lib", "cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
# 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"
|
anyhow = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|||||||
@@ -85,6 +85,27 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> C
|
|||||||
});
|
});
|
||||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
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.peers = peers;
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
|||||||
// Mark original proofs as spent
|
// Mark original proofs as spent
|
||||||
wallet.mark_spent(&indices);
|
wallet.mark_spent(&indices);
|
||||||
|
|
||||||
// Separate send proofs from change proofs
|
// Separate send proofs from change proofs BY COUNT, not membership:
|
||||||
let (send, change): (Vec<_>, Vec<_>) = swap_result
|
// partition(contains) put EVERY proof whose denomination appeared in
|
||||||
.new_proofs
|
// send_denoms into the token — when change shared a denomination with
|
||||||
.into_iter()
|
// the send (worst case: spending from a proof worth exactly 2× the
|
||||||
.partition(|p| send_denoms.contains(&p.amount));
|
// 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<Proof> = Vec::new();
|
||||||
|
let mut change: Vec<Proof> = 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
|
// Add change proofs back to wallet
|
||||||
if !change.is_empty() {
|
if !change.is_empty() {
|
||||||
|
|||||||
Binary file not shown.
@@ -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).
|
||||||
@@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean {
|
|||||||
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
|
* - 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
|
* QR carried one of these on 2026-07-22 and the companion sat there
|
||||||
* dialing an IP the phone had no route to)
|
* 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<string> {
|
async function resolveServerUrl(): Promise<string> {
|
||||||
if (IS_DEMO) return DEMO_SERVER_URL
|
if (IS_DEMO) return DEMO_SERVER_URL
|
||||||
const { hostname, origin } = window.location
|
const { hostname, origin } = window.location
|
||||||
const phoneUnreachable =
|
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
|
if (!phoneUnreachable) return origin
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
|
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
|
||||||
|
|||||||
Reference in New Issue
Block a user