Compare commits

...
Author SHA1 Message Date
DorianandClaude Fable 5 258cbad51a fix(server): web UI listens on IPv6 — the mesh could never load it
The mesh is IPv6-only; the main web listener bound 0.0.0.0 only, so a
phone reaching a node over its fips0 ULA got RST on :80
(ERR_CONNECTION_ABORTED) — UI over mesh was structurally impossible.
Confirmed 2026-07-26 on framework-pt: v4:80 = 200, v6:80 = refused.
Mirror an IPv4-any main listener with a V6ONLY [::] socket on the same
port; V6ONLY so it coexists with the v4 listener regardless of
net.ipv6.bindv6only.

Also: fips.yaml generator carries the fast-reconnect profile (validated
live on framework-pt since 2026-07-24; snapshot tests updated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 21:48:38 +01:00
Dorian 875da6c630 fix(fips): harden companion mesh joins 2026-07-24 18:47:57 +01:00
Dorian 4589e88442 chore(android): update companion apk download 2026-07-24 18:30:01 +01:00
DorianandClaude Fable 5 5862689949 perf(companion): fips fork with discovery re-fire on topology change — 0.5.15
Demo images / Build & push demo images (push) Successful in 2m56s
Pins fips to Zazawowow/fips-native fast-join-pinned (upstream pinned rev
46494a74 + one patch: pending/queued discovery lookups re-fire the
moment the tree parent lands instead of riding out timeouts started
before the mesh could answer). Measured: session lands 225ms after the
route appears; fresh-join route wait no longer stacks doomed lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:56:17 +01:00
DorianandClaude Fable 5 3995ab5cf5 perf(companion): 5s discovery timeout — fresh-join route lookups fail fast — 0.5.14
Demo images / Build & push demo images (push) Successful in 2m54s
First lookups fire before the phone's tree position settles and are
doomed; at 10s completion timeout each one cost 10s before the 1s
retry could fire (observed 19s to a route on a fresh 5G join, session
3ms after the route — discovery convergence was the whole cost).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:35:50 +01:00
DorianandClaude Fable 5 9d83ee3770 perf(companion): fast-connect mesh profile — 5G cold connect 40s+ → 5.4s — 0.5.13
Demo images / Build & push demo images (push) Successful in 2m59s
Stock fips 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. On a phone that stacked into a 40s+ first connect over
cellular. Config-only tuning in build_config (no fork changes):
discovery backoff 1s (cap 30s), session resends 400ms x1.5 (10 max),
link redial 1s (cap 30s). Measured on-device: anchor +1.2s, node
session +5.4s from cold start over 5G.

Ops note: vps2 anchor daemon had degraded again (multi-second link
RTTs after 15h uptime) — restarted, recycle tightened 1d → 6h.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:23:57 +01:00
DorianandClaude Fable 5 7e3d01f633 fix(companion): first connect no longer stalls on unresolvable .fips dial hints — 0.5.12
Demo images / Build & push demo images (push) Successful in 2m59s
A pairing QR minted while the node UI was browsed over the mesh carried
npub….fips as fhost. Android's system DNS can't resolve .fips, so every
direct handshake dial failed and first connect crawled through public-
anchor discovery (2m35s observed) instead of one LAN hop.

- node UI: resolveServerUrl treats .fips names and IPv6 ULA literals as
  phone-unreachable and substitutes the node's LAN IP (same guard as
  tailnet/localhost)
- app: QR parser falls back to the url param's host when fhost is .fips
- app: peer store never keeps .fips dial addresses
- app: peer aliases slugged for the fips host map (spaces dropped
  "Framework PT"/"Mesh anchor" from name resolution entirely)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:40:44 +01:00
lfg2025 385c950f00 Merge pull request 'fix(ecash): change proofs no longer ride along inside sent tokens — double-credit bug' (#122) from fix/cashu-double-credit into main 2026-07-24 12:34:56 +00:00
DorianandClaude Fable 5 98e15b3af0 fix(ecash): change proofs no longer ride along inside sent tokens — double-credit bug
send_token_at split the mint swap's results with
partition(send_denoms.contains(amount)) — membership, not count. When a
change denomination equaled a send denomination (guaranteed when
spending from a proof worth exactly 2x the amount: send [n], change
[n]), the change proofs were serialized INTO the token, and the
receiver redeemed double. Both the wallet send flow and peer-files
purchases mint through this path.

The split now consumes exactly one proof per required send denomination
(everything else returns to the wallet as change) and hard-fails if the
mint returns an incomplete denomination set, so a token can never again
be silently over- or under-packed.

Note: tokens minted BEFORE this fix already contain the extra proofs
and will still redeem at their inflated value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:34:48 +01:00
14 changed files with 368 additions and 26 deletions
+13 -6
View File
@@ -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
```
+2 -2
View File
@@ -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
@@ -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(),
@@ -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<PartyPeer> = try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
@@ -200,16 +251,19 @@ class FipsPreferences(private val context: Context) {
val incoming = mutableListOf<JSONObject>()
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 `<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" }
+1 -1
View File
@@ -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",
+4 -1
View File
@@ -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"
+21
View File
@@ -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).
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
}
+42 -1
View File
@@ -156,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
.with_context(|| format!("read {}", path.display()))?;
let anchors: Vec<SeedAnchor> =
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
@@ -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
+60
View File
@@ -48,6 +48,30 @@ pub struct FipsConfig {
pub struct NodeSection {
pub identity: IdentitySection,
pub discovery: DiscoverySection,
pub retry: RetrySection,
pub rate_limit: RateLimitSection,
}
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
/// doubling to 300s) are tuned for stable always-on links; a node redialing
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RetrySection {
pub base_interval_secs: u64,
pub max_backoff_secs: u64,
pub max_retries: u32,
}
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
/// gaps out to 8-16s between resends exactly when a route has just
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
/// window (phone measured session-after-route: 225ms).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RateLimitSection {
pub handshake_resend_interval_ms: u64,
pub handshake_resend_backoff: f64,
pub handshake_max_resends: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -60,6 +84,14 @@ pub struct IdentitySection {
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DiscoverySection {
/// Lookup completion timeout. Lookups fired while the tree position is
/// still settling are doomed; failing them fast (5s, not 10s) lets the
/// 1s-backoff retry find the route the moment it exists.
pub timeout_secs: u64,
pub backoff_base_secs: u64,
pub backoff_max_secs: u64,
pub retry_interval_secs: u64,
pub max_attempts: u8,
pub lan: LanDiscoverySection,
}
@@ -123,8 +155,23 @@ impl Default for FipsConfig {
node: NodeSection {
identity: IdentitySection { persistent: true },
discovery: DiscoverySection {
timeout_secs: 5,
backoff_base_secs: 1,
backoff_max_secs: 30,
retry_interval_secs: 2,
max_attempts: 3,
lan: LanDiscoverySection { enabled: true },
},
retry: RetrySection {
base_interval_secs: 1,
max_backoff_secs: 30,
max_retries: 30,
},
rate_limit: RateLimitSection {
handshake_resend_interval_ms: 400,
handshake_resend_backoff: 1.5,
handshake_max_resends: 10,
},
},
tun: TunSection {
enabled: true,
@@ -318,8 +365,21 @@ node:
identity:
persistent: true
discovery:
timeout_secs: 5
backoff_base_secs: 1
backoff_max_secs: 30
retry_interval_secs: 2
max_attempts: 3
lan:
enabled: true
retry:
base_interval_secs: 1
max_backoff_secs: 30
max_retries: 30
rate_limit:
handshake_resend_interval_ms: 400
handshake_resend_backoff: 1.5
handshake_max_resends: 10
tun:
enabled: true
name: fips0
+34
View File
@@ -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
+58
View File
@@ -988,6 +988,43 @@ impl Server {
main_addr,
));
// The mesh is IPv6-only: a phone reaching the node over its fips0
// ULA lands on port 80 over v6, where a 0.0.0.0 listener never
// answers — the UI was structurally unreachable over the mesh
// (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200,
// v6:80 = refused). Mirror an IPv4-any main listener with a
// V6ONLY [::] socket on the same port — v6-only so it coexists
// with the v4 listener regardless of net.ipv6.bindv6only.
let v4_any_port = match main_addr {
SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()),
_ => None,
};
let v6_task = if let Some(port) = v4_any_port {
let v6_addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
match bind_v6_only(v6_addr) {
Ok(listener) => {
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
Some(tokio::spawn(accept_loop(
self.api_handler.clone(),
listener,
active_connections.clone(),
false, // same semantics as the main listener
tx.subscribe(),
v6_addr,
)))
}
Err(e) => {
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
None
}
}
} else {
None
};
// Peer listener: late-binding so we don't need an archipelago
// restart when fips0 comes up after onboarding.
let peer_task = tokio::spawn(peer_late_bind_loop(
@@ -1012,6 +1049,9 @@ impl Server {
}
let _ = main_task.await;
if let Some(t) = v6_task {
let _ = t.await;
}
let _ = peer_task.await;
info!("Shutdown complete");
@@ -1019,6 +1059,24 @@ impl Server {
}
}
/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the
/// socket never claims the IPv4 side (which the main listener owns) —
/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with
/// EADDRINUSE.
fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
let socket = socket2::Socket::new(
socket2::Domain::IPV6,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)?;
socket.set_only_v6(true)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
tokio::net::TcpListener::from_std(socket.into())
}
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
/// listener and run the normal accept loop. If the bind fails (port
/// already taken, permissions), log and keep retrying. Returns on
+24 -5
View File
@@ -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<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
if !change.is_empty() {
Binary file not shown.
@@ -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<string> {
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 }>({