Merge remote-tracking branch 'origin/main' into ark-merge

This commit is contained in:
Dorian
2026-07-14 22:08:55 +01:00
142 changed files with 7738 additions and 1336 deletions
+111 -56
View File
@@ -326,75 +326,93 @@ impl RpcHandler {
}
/// Get all fleet nodes' latest reports.
/// Reads all {node_id}.json files from telemetry-fleet/ (excluding *-history.json).
///
/// Primary source: TRUSTED federated nodes from nodes.json — their
/// `last_state` snapshot (kept fresh by federation state-sync) already
/// carries everything the Fleet UI renders. Observer ("peer") and
/// Untrusted nodes are deliberately excluded from Fleet.
///
/// Secondary source: telemetry-fleet/*.json collector reports (opt-in
/// anonymous telemetry, includes this node's own report) — merged in for
/// back-compat with nodes that push telemetry but aren't federated.
pub(super) async fn handle_telemetry_fleet_status(&self) -> Result<serde_json::Value> {
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
if !fleet_dir.exists() {
return Ok(serde_json::json!({ "nodes": [] }));
let mut nodes: Vec<serde_json::Value> = Vec::new();
// ── Trusted federation nodes ─────────────────────────────────────
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
for n in fed_nodes
.iter()
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
{
let state = n.last_state.as_ref();
let pct = |used: Option<u64>, total: Option<u64>| -> serde_json::Value {
match (used, total) {
(Some(u), Some(t)) if t > 0 => {
serde_json::json!((u as f64 / t as f64 * 100.0).round())
}
_ => serde_json::json!(0),
}
};
let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]);
let reported_at = state
.map(|s| s.timestamp.clone())
.or_else(|| n.last_seen.clone())
.unwrap_or_else(|| n.added_at.clone());
let mut report = serde_json::json!({
"node_id": n.did,
"node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()),
"uptime_secs": state.and_then(|s| s.uptime_secs).unwrap_or(0),
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0),
"mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)),
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
"container_count": apps.len(),
"running_count": apps.iter().filter(|a| a.status == "running").count(),
"federation_peers": state.map(|s| s.federated_peers.len()).unwrap_or(0),
"containers": apps.iter().map(|a| serde_json::json!({
"id": a.id,
"state": a.status,
"version": a.version.clone().unwrap_or_default(),
})).collect::<Vec<_>>(),
"reported_at": reported_at,
"trust_level": n.trust_level.to_string(),
"source": "federation",
});
annotate_fleet_report(&mut report);
nodes.push(report);
}
let mut nodes: Vec<serde_json::Value> = Vec::new();
let mut entries = tokio::fs::read_dir(&fleet_dir)
.await
.context("Failed to read telemetry-fleet directory")?;
// ── Opt-in telemetry collector reports ───────────────────────────
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
if fleet_dir.exists() {
let mut entries = tokio::fs::read_dir(&fleet_dir)
.await
.context("Failed to read telemetry-fleet directory")?;
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
// Skip history files and non-JSON files
if name.ends_with("-history.json") || !name.ends_with(".json") {
continue;
}
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
// Skip history files and non-JSON files
if name.ends_with("-history.json") || !name.ends_with(".json") {
continue;
}
match tokio::fs::read_to_string(entry.path()).await {
Ok(data) => {
match serde_json::from_str::<serde_json::Value>(&data) {
match tokio::fs::read_to_string(entry.path()).await {
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
Ok(mut report) => {
// Compute online/offline status from reported_at
let is_online = report
.get("reported_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt);
age.num_minutes() < 30
})
.unwrap_or(false);
// Compute human-readable last_seen
let last_seen = report
.get("reported_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt);
let mins = age.num_minutes();
if mins < 1 {
"just now".to_string()
} else if mins < 60 {
format!("{}m ago", mins)
} else if mins < 1440 {
format!("{}h ago", mins / 60)
} else {
format!("{}d ago", mins / 1440)
}
})
.unwrap_or_else(|| "unknown".to_string());
if let Some(obj) = report.as_object_mut() {
obj.insert("online".to_string(), serde_json::json!(is_online));
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
}
annotate_fleet_report(&mut report);
nodes.push(report);
}
Err(e) => {
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
}
},
Err(e) => {
warn!(file = %name, error = %e, "Failed to read fleet report");
}
}
Err(e) => {
warn!(file = %name, error = %e, "Failed to read fleet report");
}
}
}
@@ -531,3 +549,40 @@ fn local_server_url(host_ip: &str) -> Option<String> {
Some(format!("https://{host_ip}"))
}
}
/// Stamp a fleet report with computed `online` and human-readable `last_seen`
/// derived from its `reported_at` timestamp (online = reported <30min ago).
fn annotate_fleet_report(report: &mut serde_json::Value) {
let reported = report
.get("reported_at")
.and_then(|v| v.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok());
let is_online = reported
.map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt);
age.num_minutes() < 30
})
.unwrap_or(false);
let last_seen = reported
.map(|dt| {
let age = chrono::Utc::now().signed_duration_since(dt);
let mins = age.num_minutes();
if mins < 1 {
"just now".to_string()
} else if mins < 60 {
format!("{}m ago", mins)
} else if mins < 1440 {
format!("{}h ago", mins / 60)
} else {
format!("{}d ago", mins / 1440)
}
})
.unwrap_or_else(|| "unknown".to_string());
if let Some(obj) = report.as_object_mut() {
obj.insert("online".to_string(), serde_json::json!(is_online));
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
}
}
+1 -1
View File
@@ -337,7 +337,7 @@ impl RpcHandler {
}
// Federation
"federation.invite" => self.handle_federation_invite().await,
"federation.invite" => self.handle_federation_invite(params).await,
"federation.join" => self.handle_federation_join(params).await,
"federation.list-nodes" => self.handle_federation_list_nodes().await,
"federation.remove-node" => self.handle_federation_remove_node(params).await,
@@ -53,7 +53,24 @@ impl RpcHandler {
impl RpcHandler {
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
/// Optional param `trust_level`: "trusted" (default, "Link Your Nodes") or
/// "observer" ("Invite a Peer") — the level BOTH sides assign for this invite.
pub(in crate::api::rpc) async fn handle_federation_invite(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let trust_level = params
.as_ref()
.and_then(|p| p.get("trust_level"))
.and_then(|v| v.as_str())
.map(|s| {
TrustLevel::parse(s).ok_or_else(|| {
anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)")
})
})
.transpose()?
.unwrap_or(TrustLevel::Trusted);
let (data, _) = self.state_manager.get_snapshot().await;
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let onion = data.server_info.tor_address.clone().unwrap_or_default();
@@ -72,14 +89,16 @@ impl RpcHandler {
&onion,
&pubkey,
fips_npub.as_deref(),
trust_level,
)
.await?;
info!(did = %did, fips_advertised = fips_npub.is_some(), "Generated federation invite");
info!(did = %did, trust = %trust_level, fips_advertised = fips_npub.is_some(), "Generated federation invite");
Ok(serde_json::json!({
"code": code,
"did": did,
"onion": onion,
"trust_level": trust_level.to_string(),
}))
}
@@ -511,6 +530,36 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Resolve the trust level granted by this join. Authoritative source:
// the acceptor echoes the invite's random token, which we match against
// OUR stored outgoing invites — the level we minted the code with wins.
// Fallback: the peer's (unsigned) "trust" claim, honored only as a
// DOWNGRADE from Trusted so it can never escalate. Legacy peers send
// neither → Trusted, matching pre-threading behavior.
let claimed_trust = params
.get("trust")
.and_then(|v| v.as_str())
.and_then(TrustLevel::parse)
.unwrap_or(TrustLevel::Trusted);
let invite_trust = match params.get("invite_token").and_then(|v| v.as_str()) {
Some(token) => federation::load_invites(&self.config.data_dir)
.await
.ok()
.and_then(|invites| {
invites.outgoing.iter().find_map(|inv| {
federation::parse_invite(&inv.code)
.ok()
.filter(|p| p.token == token)
.map(|_| inv.trust_level)
})
}),
None => None,
};
let granted_trust = match invite_trust {
Some(level) => level,
None => TrustLevel::Trusted.min(claimed_trust),
};
// Reject self-peering. If somehow our own did / onion / pubkey
// comes back at us (misconfigured invite, gossip loop), adding
// the entry causes sync loops where the node syncs with itself
@@ -603,7 +652,7 @@ impl RpcHandler {
pubkey: pubkey.to_string(),
onion: onion.to_string(),
name: incoming_name.clone(),
trust_level: TrustLevel::Trusted,
trust_level: granted_trust,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
@@ -613,7 +662,7 @@ impl RpcHandler {
};
federation::add_node(&self.config.data_dir, node).await?;
info!(peer_did = %did, "Peer joined our federation");
info!(peer_did = %did, trust = %granted_trust, "Peer joined our federation");
// Mirror into mesh state so the inbound peer is addressable from
// the chat UI without waiting for the next mesh restart.
@@ -1046,12 +1095,16 @@ impl RpcHandler {
// ciphertext below.
let identity_dir = self.config.data_dir.join("identity");
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
// Discovery/connection-request approvals admit the requester as
// Observer — the invite itself now carries that level, so both
// sides converge on Observer without post-hoc demotion.
let invite_code = federation::create_invite(
&self.config.data_dir,
&local_did,
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
TrustLevel::Observer,
)
.await?;
+4 -2
View File
@@ -298,8 +298,10 @@ impl RpcHandler {
Ok(node) => {
// Approved-by-them: their box already has us as Observer
// (their approval handler added us under that trust level
// before sending the invite). Demote our local entry to
// Observer too — accept_invite hardcodes Trusted, but the
// before sending the invite). Discovery invites are now
// minted with trust=observer, so accept_invite already
// lands on Observer; keep this explicit demotion as a
// safety net for legacy Trusted-only invite codes — the
// discovery flow should never auto-trust.
let _ = crate::federation::set_trust_level(
&self.config.data_dir,
+1 -3
View File
@@ -292,9 +292,7 @@ impl RpcHandler {
let r_hash_hex = inv
.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| {
base64::engine::general_purpose::STANDARD.decode(b64).ok()
})
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default();
transactions.push(serde_json::json!({
@@ -56,9 +56,8 @@ impl RpcHandler {
let words =
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
node_secret.zeroize();
let words = words.map_err(|_| {
anyhow::anyhow!("Could not decrypt the saved Lightning seed backup")
})?;
let words = words
.map_err(|_| anyhow::anyhow!("Could not decrypt the saved Lightning seed backup"))?;
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
@@ -145,6 +145,32 @@ impl RpcHandler {
{
config.receive_block_headers = receive;
}
// LoRa region (Meshtastic): validated against the driver's region
// table so a typo can't be persisted and silently ignored on connect.
// Empty string clears the setting (radio keeps/uses its own region).
if let Some(region) = params.get("lora_region").and_then(|v| v.as_str()) {
let trimmed = region.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
config.lora_region = None;
} else if mesh::meshtastic_region_is_valid(trimmed) {
config.lora_region = Some(trimmed.to_uppercase());
} else {
anyhow::bail!("Unknown LoRa region: {trimmed}");
}
}
// Firmware pin: probe only the named firmware on the port ("auto"/""
// clears the pin and restores strict-probe auto-detect).
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
config.device_kind = match kind.trim().to_lowercase().as_str() {
"" | "auto" => None,
"meshcore" => Some(mesh::types::DeviceType::Meshcore),
"meshtastic" => Some(mesh::types::DeviceType::Meshtastic),
"reticulum" | "rnode" => Some(mesh::types::DeviceType::Reticulum),
other => anyhow::bail!(
"Unknown device_kind: {other} (expected auto|meshcore|meshtastic|reticulum)"
),
};
}
mesh::save_config(&self.config.data_dir, &config).await?;
@@ -161,6 +187,8 @@ impl RpcHandler {
"device_path": config.device_path,
"announce_block_headers": config.announce_block_headers,
"receive_block_headers": config.receive_block_headers,
"lora_region": config.lora_region,
"device_kind": config.device_kind.map(|k| k.to_string()),
}))
}
}
@@ -37,6 +37,24 @@ impl RpcHandler {
"receive_block_headers".into(),
config.receive_block_headers.into(),
);
// Persisted config values the settings UI edits (distinct from the
// live radio-reported `region`): the configured LoRa region and
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
obj.insert("lora_region".into(), config.lora_region.clone().into());
obj.insert(
"device_kind".into(),
config
.device_kind
.map(|k| k.to_string().to_lowercase())
.into(),
);
// USB identity per detected port so the setup modal can show the
// actual board (product string on native-USB boards, vid:pid as
// the fallback for bridge chips).
obj.insert(
"detected_device_info".into(),
serde_json::to_value(mesh::detect_devices_info().await).unwrap_or_default(),
);
// Raw serial-device presence, in BOTH branches. MeshStatus has no
// such field, so while the service was running the UI couldn't
// tell "no radio plugged in" from "radio present but the session
@@ -478,7 +478,8 @@ impl RpcHandler {
bytes,
};
let payload = message_types::encode_payload(&content)?;
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let envelope =
TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let wire = envelope.to_wire()?;
if use_resource_transfer {
svc.send_content_resource(
@@ -583,8 +584,7 @@ impl RpcHandler {
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let est_seconds =
(size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
let (tier, reason) = if size <= MESH_AUTO_MAX {
@@ -596,7 +596,10 @@ impl RpcHandler {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
("resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer")
(
"resource-mesh",
"Sending directly over LoRa via a Reticulum resource transfer",
)
} else if size <= TOR_LARGE_WARN {
if has_tor {
("tor-only", "Too large for mesh — Tor only")
+4 -6
View File
@@ -214,8 +214,9 @@ pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
Some(ip) => ip,
// No socket info recorded (shouldn't happen in the server path);
// fall back to the pre-extension behavior.
None => forwarded_client_ip(&parts.headers)
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
None => {
forwarded_client_ip(&parts.headers).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
}
}
}
@@ -234,10 +235,7 @@ mod client_ip_tests {
use super::*;
use std::net::SocketAddr;
fn parts_with(
peer: Option<&str>,
real_ip: Option<&str>,
) -> hyper::http::request::Parts {
fn parts_with(peer: Option<&str>, real_ip: Option<&str>) -> hyper::http::request::Parts {
let mut builder = hyper::Request::builder().uri("/rpc/v1");
if let Some(ip) = real_ip {
builder = builder.header("x-real-ip", ip);
+1 -1
View File
@@ -55,11 +55,11 @@ use hyper::{Request, Response, StatusCode};
use std::sync::Arc;
use tracing::{debug, error};
pub use middleware::PeerAddr;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
};
pub use middleware::PeerAddr;
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Default dev password when no user is set up (matches mock-backend).
+133 -48
View File
@@ -1,13 +1,12 @@
use super::RpcHandler;
use crate::network::router as net_router;
use anyhow::Result;
use archipelago_openwrt::{
detect,
router::Router,
tollgate::{self, TollGateConfig},
wan,
wifi_scan,
wan, wifi_scan,
};
use crate::network::router as net_router;
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
const LOCAL_MINT_PORT: u16 = 3338;
@@ -23,7 +22,9 @@ impl RpcHandler {
) -> Result<serde_json::Value> {
let p = params.unwrap_or_default();
let subnet: [u8; 4] = parse_ipv4(
p.get("subnet").and_then(|v| v.as_str()).unwrap_or("192.168.1.0"),
p.get("subnet")
.and_then(|v| v.as_str())
.unwrap_or("192.168.1.0"),
)?;
let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8;
let ssh_user = p
@@ -59,8 +60,18 @@ impl RpcHandler {
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
@@ -92,11 +103,14 @@ impl RpcHandler {
None,
Some(&ssh_user),
Some(&ssh_password),
).await;
)
.await;
}
// System info
let release = router.run_ok("cat /etc/openwrt_release").unwrap_or_default();
let release = router
.run_ok("cat /etc/openwrt_release")
.unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
@@ -170,8 +184,18 @@ impl RpcHandler {
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
@@ -200,10 +224,7 @@ impl RpcHandler {
.get("step_size_ms")
.and_then(|v| v.as_u64())
.unwrap_or(60_000),
min_steps: p
.get("min_steps")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
};
@@ -229,13 +250,34 @@ impl RpcHandler {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.password.clone()).unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.username.clone())
.unwrap_or_else(|| "root".to_string());
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.password.clone())
.unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
@@ -243,13 +285,15 @@ impl RpcHandler {
let networks = wifi_scan::scan_networks(&router)?;
let result: Vec<serde_json::Value> = networks
.iter()
.map(|n| serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
}))
.map(|n| {
serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
})
})
.collect();
Ok(serde_json::json!({ "networks": result }))
@@ -265,18 +309,50 @@ impl RpcHandler {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.password.clone()).unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
if saved.configured {
Some(saved.address.clone())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"No router configured — provide host or call router.configure first"
)
})?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.username.clone())
.unwrap_or_else(|| "root".to_string());
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.password.clone())
.unwrap_or_default();
let ssid = p.get("ssid").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string();
let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string();
let ssid = p
.get("ssid")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?
.to_string();
let password = p
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let encryption = p
.get("encryption")
.and_then(|v| v.as_str())
.unwrap_or("psk2")
.to_string();
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -284,7 +360,14 @@ impl RpcHandler {
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let config = wan::WispConfig { ssid: ssid.clone(), password, encryption, dhcp_start, dhcp_limit, masq };
let config = wan::WispConfig {
ssid: ssid.clone(),
password,
encryption,
dhcp_start,
dhcp_limit,
masq,
};
wan::configure_wisp(&router, &config)?;
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
@@ -325,14 +408,16 @@ fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
let mut ifaces: Vec<serde_json::Value> = sections
.into_iter()
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
.map(|(name, f)| serde_json::json!({
"section": name,
"ssid": f.get("ssid").cloned().unwrap_or_default(),
"device": f.get("device").cloned().unwrap_or_default(),
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
"network": f.get("network").cloned().unwrap_or_default(),
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
}))
.map(|(name, f)| {
serde_json::json!({
"section": name,
"ssid": f.get("ssid").cloned().unwrap_or_default(),
"device": f.get("device").cloned().unwrap_or_default(),
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
"network": f.get("network").cloned().unwrap_or_default(),
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
})
})
.collect();
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
@@ -114,6 +114,16 @@ impl RpcHandler {
Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e);
install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e)).await;
// handle_package_install saves the catalog-provided
// dynamic app config to /var/lib/archipelago/app-configs
// BEFORE the install pipeline runs, so a failure can
// strand that file (and the optimistic state entry) with
// no container behind it. Probe once here; both cleanup
// branches below only fire when the app has no footprint.
// A retry re-saves the config (the frontend sends
// containerConfig on every install), so removal is safe.
let left_container =
failed_install_left_container(&handler, &package_id_spawn).await;
// Dependency-gate rejections happen BEFORE any resource
// (container/image/data dir) exists for this package, so
// keeping the optimistic entry would leave a phantom
@@ -123,30 +133,47 @@ impl RpcHandler {
// surface the reason as a notification instead.
if let Some(gate) = e.downcast_ref::<super::dependencies::DependencyGateError>()
{
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(&package_id_spawn);
data.notifications.push(crate::data_model::Notification {
id: format!("install-deps-{package_id_spawn}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id_spawn}"),
message: gate.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id_spawn.clone()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
if !left_container {
remove_dynamic_app_config(&package_id_spawn).await;
}
handler.state_manager.update_data(data).await;
remove_entry_with_notification(
&handler,
&package_id_spawn,
"install-deps",
&gate.to_string(),
)
.await;
return;
}
// Don't remove the entry — that's what made the card
// A failed install that left NO container behind has no
// real footprint either — keeping the entry would leave
// the same phantom "Stopped" tile in My Apps (and the
// scanner-side absence eviction takes 3 scans to catch
// it). Remove the saved config + entry and surface the
// failure as a notification, exactly like the gate case.
if !left_container {
remove_dynamic_app_config(&package_id_spawn).await;
remove_entry_with_notification(
&handler,
&package_id_spawn,
"install-failed",
&format!("Install failed: {:#}", e),
)
.await;
return;
}
// A container exists (crash-after-start kept for
// visibility, retry over an existing install, upgrade) —
// don't remove the entry, that's what made the card
// vanish from My Apps mid-install / between retry-loop
// attempts (e.g. tailscale's entrypoint failure). Leave
// the entry visible with state=Stopped + the install
// error in install_progress.message so the user can see
// what went wrong and decide whether to retry or
// uninstall. clear_install_progress would erase the
// message, so we set it explicitly here instead.
// message, so we set it explicitly here instead. The
// phase is cleared (None) so no stale InstallPhase
// lingers on the card.
let err_msg = format!("Install failed: {:#}", e);
let (mut data, _) = handler.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
@@ -384,6 +411,77 @@ async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
state_manager.update_data(data).await;
}
/// True when the failed install still has a real footprint: any container
/// belonging to `package_id` exists (any state — created/exited count too;
/// the install-crash path deliberately keeps the exited container visible),
/// or the app carries a user-stopped marker (Quadlet units run with `--rm`,
/// so a cleanly user-stopped app legitimately has no podman record). Errors
/// from the podman probe count as "exists" — never clean up on an uncertain
/// reading.
async fn failed_install_left_container(handler: &RpcHandler, package_id: &str) -> bool {
if crate::crash_recovery::load_user_stopped(&handler.config.data_dir)
.await
.contains(package_id)
{
return true;
}
match super::config::get_containers_for_app(package_id).await {
Ok(containers) => !containers.is_empty(),
Err(e) => {
warn!(
"install cleanup {}: container probe failed ({:#}); keeping saved config",
package_id, e
);
true
}
}
}
/// Remove the catalog-provided dynamic app config that
/// `handle_package_install` saved before the pipeline ran (mirror of the
/// write in install.rs). Only called when the app has no container — for an
/// existing install (retry/upgrade) the file is still the app's live runtime
/// config and must be kept.
async fn remove_dynamic_app_config(package_id: &str) {
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", package_id);
match tokio::fs::remove_file(&config_path).await {
Ok(()) => info!(
"Removed dynamic app config for {} after failed install (no container)",
package_id
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!(
"Failed to remove dynamic app config for {}: {}",
package_id, e
),
}
}
/// Remove the package's optimistic state entry (clearing any pending install
/// phase with it) so the card reverts to installable, and surface the failure
/// reason as an error notification instead.
async fn remove_entry_with_notification(
handler: &RpcHandler,
package_id: &str,
id_prefix: &str,
message: &str,
) {
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
handler.state_manager.update_data(data).await;
}
/// Flip an existing entry's state and return the pre-flip value (or None if
/// no entry existed). Used for revert-on-failure.
async fn flip_package_state(
@@ -378,9 +378,11 @@ where
// Fail fast if any missing dependency has no installed container
// under any name variant — waiting cannot satisfy it.
let some_dep_not_installed = missing
.iter()
.any(|dep| !dep.containers.iter().any(|c| existing.iter().any(|e| e == c)));
let some_dep_not_installed = missing.iter().any(|dep| {
!dep.containers
.iter()
.any(|c| existing.iter().any(|e| e == c))
});
if some_dep_not_installed {
let msg = match check_install_deps(package_id, &running) {
Err(e) => e.to_string(),
@@ -879,8 +881,10 @@ mod tests {
}
/// Collects "Waiting for X to start…" labels emitted during the wait.
fn label_sink() -> (Arc<Mutex<Vec<String>>>, impl FnMut(String) -> std::future::Ready<()>)
{
fn label_sink() -> (
Arc<Mutex<Vec<String>>>,
impl FnMut(String) -> std::future::Ready<()>,
) {
let labels = Arc::new(Mutex::new(Vec::new()));
let sink = {
let labels = Arc::clone(&labels);
@@ -930,7 +934,8 @@ mod tests {
// so async_lifecycle removes the optimistic Installing entry.
assert!(err.downcast_ref::<DependencyGateError>().is_some());
assert!(
err.to_string().contains("LND requires a running Bitcoin node"),
err.to_string()
.contains("LND requires a running Bitcoin node"),
"unexpected message: {err}"
);
}
+137 -2
View File
@@ -816,6 +816,15 @@ impl RpcHandler {
};
run_args.push(&effective_image);
// Bitcoin-dependent apps (LND, electrs, BTCPay…) exit immediately if
// bitcoind's RPC isn't answering when they start; the 60s post-start
// poll then reads that exit as INSTALL CRASH and the whole install
// fails — the "LND took 5 attempts" failure mode on fresh installs.
// Gate the container start on the RPC actually responding (IBD is
// fine — getblockchaininfo answers during sync) with a generous wait,
// and fail with an actionable message instead of a crash-looping app.
wait_for_bitcoin_rpc_gate(package_id).await?;
install_log(&format!(
"INSTALL RUN: {} — podman run {} (image: {})",
package_id, container_name, effective_image
@@ -1380,17 +1389,43 @@ impl RpcHandler {
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
// rpcbind=0.0.0.0 is REQUIRED inside a container: with rpcallowip set
// but no rpcbind, bitcoind binds RPC to 127.0.0.1 in the container
// netns only — LND / the Bitcoin UI dialing bitcoin-knots:8332 over
// the bridge get connection refused (fresh-install LND crash-loop +
// bitcoin-rpc 502, seen on the 1.7.99 ISO). The port publish stays
// 127.0.0.1-only on the host, so exposure is unchanged.
// Prune sized to the data volume. A full archive needs ~810 GB and
// grows; silently writing an unpruned config onto a small disk fills
// it mid-IBD (framework node 2026-07-14: unpruned mainnet on a 205 GB
// volume). Volumes with real archival headroom (≥1.2 TB) stay full
// archive; smaller ones get prune = 25% of the volume, clamped to
// [550 MB, 100 GB], leaving room for LND/apps sharing the disk.
let prune_line = match bitcoin_data_volume_gb().await {
Some(total_gb) if total_gb > 0 && total_gb < 1200 => {
let prune_mb = ((total_gb as f64 * 0.25 * 1024.0) as u64).clamp(550, 100_000);
info!(
volume_gb = total_gb,
prune_mb, "Data volume below archival size — enabling sized bitcoin prune"
);
format!("prune={}\n", prune_mb)
}
_ => String::new(),
};
let bitcoin_conf = format!(
"\
# rpcauth: salted hash only - no plaintext password in config or CLI\n\
{}\n\
server=1\n\
rpcbind=0.0.0.0\n\
rpcallowip=0.0.0.0/0\n\
listen=1\n\
rpcthreads=16\n\
rpcworkqueue=256\n\
printtoconsole=0\n",
rpcauth_line
printtoconsole=0\n\
{}",
rpcauth_line, prune_line
);
tokio::fs::create_dir_all(bitcoin_dir)
.await
@@ -2475,6 +2510,105 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
))
}
/// Total size (GB) of the filesystem holding the bitcoin data dir, via
/// `df -k`. None when df fails (containers, exotic mounts) — callers treat
/// unknown as "don't prune" to preserve archival defaults on big iron.
async fn bitcoin_data_volume_gb() -> Option<u64> {
let target = if std::path::Path::new("/var/lib/archipelago").exists() {
"/var/lib/archipelago"
} else {
"/"
};
let output = tokio::process::Command::new("df")
.args(["-k", target])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().nth(1)?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024 / 1024)
}
/// One-shot probe: does bitcoind answer an authenticated getblockchaininfo?
/// Works during IBD (the call answers with progress while syncing). Goes via
/// the host-published RPC port, which fails in exactly the same conditions
/// as the container-network path (bitcoind down, still binding, bad rpcbind).
async fn bitcoin_rpc_answering() -> bool {
let (user, pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "install-gate",
"method": "getblockchaininfo",
"params": [],
});
match client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(&user, Some(&pass))
.json(&body)
.send()
.await
{
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
/// Hold the install of a bitcoin-dependent app until bitcoind's RPC answers,
/// up to 3 minutes. No-op for apps that don't need bitcoin at start.
async fn wait_for_bitcoin_rpc_gate(package_id: &str) -> Result<()> {
if !matches!(
package_id,
"lnd" | "electrumx" | "electrs" | "mempool-electrs" | "btcpay-server" | "btcpayserver"
) {
return Ok(());
}
let deadline = tokio::time::Instant::now() + Duration::from_secs(180);
let mut announced = false;
while !bitcoin_rpc_answering().await {
if tokio::time::Instant::now() >= deadline {
install_log(&format!(
"INSTALL FAIL: {} — Bitcoin RPC not answering after 180s; refusing to start a container that would crash-loop",
package_id
))
.await;
anyhow::bail!(
"Bitcoin's RPC is not responding, and {} needs it to start. \
Bitcoin may still be starting up wait a minute and try again. \
If this persists, check the Bitcoin app logs.",
package_id
);
}
if !announced {
install_log(&format!(
"INSTALL WAIT: {} — waiting for Bitcoin RPC to become ready (up to 3 min)",
package_id
))
.await;
announced = true;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
if announced {
install_log(&format!(
"INSTALL WAIT OK: {} — Bitcoin RPC is answering, starting container",
package_id
))
.await;
}
Ok(())
}
async fn ensure_bitcoin_rpc_config() -> Result<bool> {
let script = r#"
set -eu
@@ -2502,6 +2636,7 @@ ensure_line() {
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
ensure_line rpcthreads=16
@@ -63,7 +63,9 @@ impl RpcHandler {
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()]
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
} else if let Some(members) =
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
{
members
} else {
ordered_containers_for_start(package_id).await?
@@ -170,11 +172,12 @@ impl RpcHandler {
// fallback to a raw `podman stop` that races systemd over the unit
// (immich, gate 2026-07-09).
let to_stop_ids = if !single_orchestrator_app {
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
.map(|mut members| {
orchestrator_stack_members(self.orchestrator.is_some(), package_id).map(
|mut members| {
members.reverse();
members
})
},
)
} else {
None
};
@@ -280,7 +283,9 @@ impl RpcHandler {
let companion_app_id = package_id_owned.clone();
let to_restart = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()]
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
} else if let Some(members) =
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
{
// Restart stacks via member APP ids: restarting by live container
// name podman-stops the quadlet container (systemd --rm removes
// it) and the start half then finds no such container — a 5-min
@@ -2160,7 +2165,9 @@ mod tests {
assert!(is_missing_container_error(
"Error: no such object: \"mempool\""
));
assert!(is_missing_container_error("Error: no such container mempool"));
assert!(is_missing_container_error(
"Error: no such container mempool"
));
assert!(is_missing_container_error(
"Error: no container with name or id \"x\" found"
));
@@ -100,7 +100,8 @@ impl RpcHandler {
}
let location_file = self.config.data_dir.join("server-location.json");
let payload = serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
let payload =
serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
tokio::fs::write(&location_file, serde_json::to_vec(&payload)?)
.await
.context("Failed to write server location")?;
@@ -130,7 +131,9 @@ impl RpcHandler {
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
/// Lets Settings show users where to reach this node over HTTPS for
/// features (mic/camera access) that require a secure context.
pub(in crate::api::rpc) async fn handle_system_get_hostname(&self) -> Result<serde_json::Value> {
pub(in crate::api::rpc) async fn handle_system_get_hostname(
&self,
) -> Result<serde_json::Value> {
let hostname = tokio::fs::read_to_string("/etc/hostname")
.await
.map(|s| s.trim().to_string())
@@ -401,7 +404,8 @@ async fn set_system_hostname(hostname: &str) -> Result<()> {
/// top once a node has been renamed away from the install-time default.
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
let san = format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
let san =
format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
let output = tokio::process::Command::new("/usr/bin/sudo")
.args([
"-n",
+1 -3
View File
@@ -53,9 +53,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
// The legacy umbrella id maps to the split stack (the orchestrator's
// umbrella alias handles this too; listing it here keeps the RPC
// layer's fan-out explicit).
"mempool" | "mempool-web" => {
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
}
"mempool" | "mempool-web" => &["archy-mempool-db", "mempool-api", "archy-mempool-web"],
_ => &[],
}
}
+46
View File
@@ -130,6 +130,13 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
match run_apps_dir_repair().await {
Ok(true) => {
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
}
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
match run_journald_dropin().await {
Ok(true) => info!("Installed journald log-volume policy drop-in"),
Ok(false) => debug!("journald log-volume policy already in place"),
@@ -402,6 +409,39 @@ fn path_dot(path: &Path) -> String {
p.to_string_lossy().to_string()
}
/// ISO installs before the auto-install.sh path fix copied the app manifests
/// to /etc/archipelago/apps while the backend loads them from
/// /opt/archipelago/apps — so fresh nodes had ZERO disk manifests and only
/// catalog-covered apps could install (netbird "manifests not available",
/// framework node 2026-07-14). Self-heal: when /opt has no manifests and the
/// installer copy exists, populate /opt from /etc. Never overwrites existing
/// /opt manifests (OTA runtime-assets sync owns those afterwards).
async fn run_apps_dir_repair() -> Result<bool> {
let script = r#"
set -eu
src=/etc/archipelago/apps
dst=/opt/archipelago/apps
[ -d "$src" ] || exit 0
# Only heal when the destination has no manifests at all.
if [ -d "$dst" ] && [ -n "$(ls -A "$dst" 2>/dev/null)" ]; then exit 0; fi
ls "$src"/*/manifest.yml >/dev/null 2>&1 || exit 0
mkdir -p "$dst"
cp -r "$src"/. "$dst"/
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("populate /opt/archipelago/apps from installer copy")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => Ok(true),
_ => {
warn!("Apps dir repair helper exited with {}", status);
Ok(false)
}
}
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
@@ -421,6 +461,12 @@ ensure_line() {
fi
}
ensure_line server=1
# rpcbind=0.0.0.0 is required inside the container: with rpcallowip set but
# no rpcbind, bitcoind binds RPC to the container's loopback only and every
# dial over the container network (LND, bitcoin-ui) is refused the fresh-
# install "LND took 5 attempts" / bitcoin-rpc 502 failure (host publish stays
# 127.0.0.1-only, so exposure is unchanged).
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
@@ -400,10 +400,7 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
}
async fn fetch_one(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<(AppCatalog, String)> {
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
@@ -510,7 +507,10 @@ mod tests {
// on the apps HashMap's nondeterministic key order (seen live on .228).
let dir = tempfile::tempdir().unwrap();
let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#;
assert!(write_cache(dir.path(), body).unwrap(), "first write is a change");
assert!(
write_cache(dir.path(), body).unwrap(),
"first write is a change"
);
assert!(
!write_cache(dir.path(), body).unwrap(),
"identical rewrite is not a change"
@@ -547,48 +547,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://botfights.net".to_string(),
tier: "",
},
"nwnn" => AppMetadata {
title: "Next Web News Network".to_string(),
description: "Decentralized news and link aggregator, synced from Telegram".to_string(),
icon: "/assets/img/app-icons/nwnn.png".to_string(),
repo: "https://nwnn.l484.com".to_string(),
tier: "",
},
"484-kitchen" => AppMetadata {
title: "484 Kitchen".to_string(),
description: "K484 application platform".to_string(),
icon: "/assets/img/app-icons/484-kitchen.png".to_string(),
repo: "https://484.kitchen".to_string(),
tier: "",
},
"call-the-operator" => AppMetadata {
title: "Call the Operator".to_string(),
description: "Escape the Matrix — explore decentralized alternatives".to_string(),
icon: "/assets/img/app-icons/call-the-operator.png".to_string(),
repo: "https://cta.tx1138.com".to_string(),
tier: "",
},
"arch-presentation" => AppMetadata {
title: "Arch Presentation".to_string(),
description: "Archipelago: The Future of Decentralized Infrastructure".to_string(),
icon: "/assets/img/app-icons/arch-presentation.png".to_string(),
repo: "https://present.l484.com".to_string(),
tier: "",
},
"syntropy-institute" => AppMetadata {
title: "Syntropy Institute".to_string(),
description: "Medicine Reimagined — frequency analysis-therapy and digital homeopathy".to_string(),
icon: "/assets/img/app-icons/syntropy-institute.png".to_string(),
repo: "https://syntropy.institute".to_string(),
tier: "",
},
"t-zero" => AppMetadata {
title: "T-0".to_string(),
description: "Documentary series on decentralization, Bitcoin, and the ungovernable future".to_string(),
icon: "/assets/img/app-icons/t-zero.png".to_string(),
repo: "https://teeminuszero.net".to_string(),
tier: "",
},
_ => AppMetadata {
title: app_id.to_string(),
description: format!("{} application", app_id),
@@ -7,12 +7,8 @@
/// Registries images may be pulled from with an explicit host part.
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
/// never be pulled through again.)
pub const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io",
"ghcr.io",
"localhost",
"146.59.87.168:3000",
];
pub const TRUSTED_REGISTRIES: &[&str] =
&["docker.io", "ghcr.io", "localhost", "146.59.87.168:3000"];
/// Validate a container image reference.
///
+14 -8
View File
@@ -727,14 +727,12 @@ pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path)
Ok(s) => s,
Err(_) => return Ok(()), // LND not installed/provisioned yet
};
let thumbprint =
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let thumbprint = cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
// Fast path (no sudo): existing secret already pins the current cert.
if let Ok(existing) = fs::read_to_string(&target).await {
if !existing.trim().is_empty()
&& existing.contains(&format!("certthumbprint={thumbprint}"))
if !existing.trim().is_empty() && existing.contains(&format!("certthumbprint={thumbprint}"))
{
return Ok(());
}
@@ -782,7 +780,9 @@ mod tests {
conf_path: tmp.path().join("lnd/lnd.conf"),
};
let out = ensure_config(&paths, "secret", "bitcoin-knots").await.unwrap();
let out = ensure_config(&paths, "secret", "bitcoin-knots")
.await
.unwrap();
assert_eq!(out, EnsureOutcome::Written);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.active=true"));
@@ -801,11 +801,15 @@ mod tests {
};
assert_eq!(
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "first", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
assert_eq!(
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "second", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
@@ -854,7 +858,9 @@ mod tests {
.unwrap();
assert_eq!(
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "repaired", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
@@ -50,6 +50,18 @@ use crate::update::host_sudo;
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
/// Apps expected to exist from first boot on every node — the ONLY apps the
/// boot reconciler may install from nothing. Every other app needs
/// installation evidence (an existing container, or the was-running snapshot
/// handled by the caller's desired-state recovery). Without this gate,
/// "manifest loaded" counted as "installed" — and since the catalog + ISO ship
/// manifests for EVERY app, a fresh node mass-installed the entire catalog
/// (framework node 2026-07-14: portainer/vaultwarden/searxng/strfry/mempool/
/// fedimint appeared uninvited within an hour of first boot).
fn is_required_baseline_app(app_id: &str) -> bool {
matches!(app_id, "filebrowser" | "fedimint-clientd")
}
fn is_restart_sensitive_app(app_id: &str) -> bool {
matches!(
app_id,
@@ -1456,9 +1468,7 @@ impl ProdContainerOrchestrator {
);
}
}
for (port, a, b) in
host_port_collisions(state.manifests.values().map(|lm| &lm.manifest))
{
for (port, a, b) in host_port_collisions(state.manifests.values().map(|lm| &lm.manifest)) {
tracing::error!(
port,
app_a = %a,
@@ -1654,7 +1664,10 @@ impl ProdContainerOrchestrator {
// and left the unit down for minutes (.228 mempool frontend, gate
// 2026-07-09). Skip this cycle; the worker owns the outcome.
if crate::app_ops::lifecycle_op_in_flight(&app_id) {
report.record(&app_id, ReconcileAction::Left("lifecycle-op-in-flight".into()));
report.record(
&app_id,
ReconcileAction::Left("lifecycle-op-in-flight".into()),
);
crate::crash_recovery::pending_boot_start_done(&app_id);
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
@@ -1761,10 +1774,9 @@ impl ProdContainerOrchestrator {
{
let state = self.state.read().await;
for (app, dep) in degraded_running_apps(
&report,
state.manifests.values().map(|lm| &lm.manifest),
) {
for (app, dep) in
degraded_running_apps(&report, state.manifests.values().map(|lm| &lm.manifest))
{
tracing::error!(
app_id = %app,
dependency = %dep,
@@ -2197,20 +2209,25 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Started);
}
// By this point `app_id` is neither user-stopped nor
// user-uninstalled (both checked earlier in this fn) and its
// manifest is still loaded — i.e. it's a genuinely-installed
// app whose container is simply gone (crash, lost record,
// wedged teardown cleared by reboot). It must self-heal
// regardless of whether it happens to be one of the hardcoded
// "required baseline" apps: an app the user installed and
// never removed should come back on its own, the same as
// baseline services always have. `is_required_baseline_app`
// used to gate this and left every other installed-but-absent
// app (e.g. a stack's backend containers) stuck forever.
// Container absent. A loaded manifest is NOT installation
// evidence — the catalog and the ISO ship manifests for every
// app, installed or not. In ExistingOnly (boot) mode only two
// things may create a container from nothing:
// 1. required-baseline apps (first-boot bootstrap), here;
// 2. desired-state recovery for apps whose container was
// running at the last snapshot — the caller matches
// Left("absent") against the snapshot and recreates
// (this is what heals a stack backend that vanished:
// the indeedhub/immich cases).
// Everything else stays absent. Installing on manifest
// presence alone mass-installed the whole catalog on a fresh
// node (framework, 2026-07-14).
if mode == ReconcileMode::ExistingOnly {
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
if is_required_baseline_app(&app_id) {
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
return Ok(ReconcileAction::Left("absent".to_string()));
}
self.install_fresh(lm).await?;
Ok(ReconcileAction::Installed)
@@ -3167,8 +3184,7 @@ impl ProdContainerOrchestrator {
// `optional` secret_env — btcpay must still start when LND is
// absent or the derivation fails, so log-and-continue.
if let Err(e) =
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir)
.await
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir).await
{
tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node");
}
@@ -3243,20 +3259,17 @@ impl ProdContainerOrchestrator {
manifest.app.container.secret_env_refs = Vec::new();
manifest.app.container.secret_env_hash = None;
} else {
let hash =
archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
let hash = archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
let app_id = manifest.app.id.clone();
manifest.app.container.secret_env_refs = secret_bearing
.into_iter()
.map(|(key, value)| archipelago_container::manifest::SecretEnvRef {
secret_name: format!(
"archy-env-{}-{}",
app_id,
key.to_ascii_lowercase()
),
env_key: key,
value,
})
.map(
|(key, value)| archipelago_container::manifest::SecretEnvRef {
secret_name: format!("archy-env-{}-{}", app_id, key.to_ascii_lowercase()),
env_key: key,
value,
},
)
.collect();
manifest.app.container.secret_env_hash = Some(hash.clone());
@@ -3264,12 +3277,7 @@ impl ProdContainerOrchestrator {
// the steady-state reconcile free: podman is only consulted when
// the resolved content actually changed (or on first touch after
// boot). Mock runtimes no-op via the trait default.
let cached = self
.env_secret_cache
.lock()
.await
.get(&app_id)
.cloned();
let cached = self.env_secret_cache.lock().await.get(&app_id).cloned();
if cached.as_deref() != Some(hash.as_str()) {
self.runtime
.ensure_env_secrets(&manifest.app.container.secret_env_refs)
@@ -3909,7 +3917,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn start(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "starting legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"starting legacy umbrella id via split-stack members"
);
for (i, member) in members.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -3952,7 +3963,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn stop(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "stopping legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"stopping legacy umbrella id via split-stack members"
);
for member in members.iter().rev() {
Box::pin(self.stop(member))
.await
@@ -4019,7 +4033,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn restart(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "restarting legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"restarting legacy umbrella id via split-stack members"
);
for (i, member) in members.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -4222,9 +4239,8 @@ fn command_argv_drifted(
// therefore Config.Cmd) has them as spaces. Normalize both sides so the
// same script never reads as drift over line breaks alone (bitcoin-knots
// and fedimint-gateway on .228, 2026-07-08).
let norm = |v: &[String]| -> Vec<String> {
v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect()
};
let norm =
|v: &[String]| -> Vec<String> { v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect() };
let current_cmd = norm(current_cmd);
let expected_args = norm(expected_args);
let Some(expected_entry) = expected_entry else {
@@ -5491,14 +5507,20 @@ app:
("bitcoin-knots", ReconcileAction::Installed),
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-knots", "lnd")]);
assert_eq!(
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-knots", "lnd")]
);
// Backend merely started from stopped also moves the IP → cascade.
let r = report(vec![
("bitcoin-core", ReconcileAction::Started),
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-core", "lnd")]);
assert_eq!(
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-core", "lnd")]
);
// Backend untouched → no cascade.
let r = report(vec![
@@ -5773,13 +5795,13 @@ app:
#[tokio::test]
async fn reconcile_existing_self_heals_missing_optional_installed_app() {
// A non-baseline app (gitea) whose manifest is still loaded (i.e.
// genuinely installed, not user-uninstalled — see the
// durable-user-uninstalled-marker test above for that case) must
// self-heal the same as a required baseline app when its container
// is fully gone. Leaving any installed-but-absent app stuck forever
// regressed a real node (indeedhub's backend containers never came
// back after going absent) — self-heal is no longer baseline-only.
// A non-baseline app (gitea) self-heals ONLY with installation
// evidence: its container was running at the last periodic snapshot
// (desired-state recovery). Manifest presence alone must NOT install
// — the catalog ships manifests for every app, and treating them as
// installed mass-installed the catalog on a fresh node (2026-07-14).
// The indeedhub/immich vanished-container cases are exactly the
// snapshot-covered scenario exercised here.
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
@@ -5788,6 +5810,8 @@ app:
PathBuf::from("/tmp/gitea"),
)
.await;
// Installation evidence: gitea was running at the last snapshot.
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &["gitea"]).await;
let report = orch.reconcile_existing().await;
@@ -5997,7 +6021,9 @@ app:
let calls = rt.calls();
for name in ["archy-mempool-db", "mempool-api", "archy-mempool-web"] {
assert!(
calls.iter().any(|c| c == &format!("start_container:{name}")),
calls
.iter()
.any(|c| c == &format!("start_container:{name}")),
"{name} not started: {calls:?}"
);
}
+4 -1
View File
@@ -1258,7 +1258,10 @@ app:
assert!(u.read_only_root);
assert!(u.no_new_privileges);
assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]);
assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string(), String::new())]);
assert_eq!(
u.ports,
vec![(8332, 8332, "tcp".to_string(), String::new())]
);
assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]);
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(
+3 -8
View File
@@ -607,14 +607,9 @@ mod prune_missing_content_tests {
availability: Availability::AllPeers,
added_at: "2026-01-01T00:00:00Z".to_string(),
};
save_catalog(
data_dir,
&ContentCatalog {
items: vec![item],
},
)
.await
.unwrap();
save_catalog(data_dir, &ContentCatalog { items: vec![item] })
.await
.unwrap();
// File was never written to disk under content/files/ or filebrowser/.
let result = serve_content(data_dir, "missing-item", None, None, None, None)
+27 -7
View File
@@ -60,8 +60,9 @@ pub fn is_recovery_complete() -> bool {
// the outcome — a container that truly failed goes back to showing its
// real state on the next scan.
static PENDING_BOOT_STARTS: std::sync::LazyLock<std::sync::RwLock<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
static PENDING_BOOT_STARTS: std::sync::LazyLock<
std::sync::RwLock<std::collections::HashSet<String>>,
> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
/// Register container/app names an active recovery or reconcile pass
/// intends to start.
@@ -118,6 +119,25 @@ pub async fn load_last_running_names(data_dir: &Path) -> std::collections::HashS
}
}
/// Test helper: write a running-containers snapshot with the given names —
/// the "installation evidence" the boot reconciler's desired-state recovery
/// reads via `load_last_running_names`.
#[cfg(test)]
pub async fn save_container_snapshot_for_test(data_dir: &Path, names: &[&str]) {
let snapshot = ContainerSnapshot {
timestamp: 0,
containers: names
.iter()
.map(|n| RunningContainerRecord {
name: n.to_string(),
image: format!("test/{n}:latest"),
})
.collect(),
};
let path = data_dir.join(CONTAINER_STATE_FILE);
let _ = fs::write(&path, serde_json::to_string_pretty(&snapshot).unwrap()).await;
}
/// Save the set of user-stopped containers to disk.
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
let path = data_dir.join(USER_STOPPED_FILE);
@@ -163,7 +183,10 @@ pub async fn load_user_uninstalled(data_dir: &Path) -> std::collections::HashSet
}
/// Save the set of user-uninstalled app/container names to disk.
pub async fn save_user_uninstalled(data_dir: &Path, uninstalled: &std::collections::HashSet<String>) {
pub async fn save_user_uninstalled(
data_dir: &Path,
uninstalled: &std::collections::HashSet<String>,
) {
let path = data_dir.join(USER_UNINSTALLED_FILE);
if let Ok(json) = serde_json::to_string_pretty(uninstalled) {
let _ = fs::write(&path, json).await;
@@ -225,10 +248,7 @@ pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContai
// that is not us and whose cmdline looks like the archipelago binary.
if !old_pid.is_empty() {
if let Ok(pid) = old_pid.parse::<u32>() {
if pid != std::process::id()
&& is_process_running(pid)
&& process_is_archipelago(pid)
{
if pid != std::process::id() && is_process_running(pid) && process_is_archipelago(pid) {
warn!(
"Previous process (PID {}) is still running — not a crash, skipping recovery",
pid
+120 -9
View File
@@ -18,16 +18,22 @@ pub struct ParsedInvite {
pub token: String,
/// Inviter's FIPS npub if advertised in the code.
pub fips_npub: Option<String>,
/// Trust level the invite grants both sides. Absent in legacy codes,
/// which default to Trusted.
pub trust_level: TrustLevel,
}
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token, fips_npub?})>`.
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token, fips_npub?, trust})>`.
/// `fips_npub` is only included when the local node has a materialised FIPS key.
/// `trust_level` is the level BOTH sides assign to each other for this invite
/// ("Invite a Peer" = Observer, "Link Your Nodes" = Trusted).
pub async fn create_invite(
data_dir: &Path,
did: &str,
onion: &str,
pubkey: &str,
fips_npub: Option<&str>,
trust_level: TrustLevel,
) -> Result<String> {
use base64::Engine;
use rand::Rng;
@@ -41,6 +47,7 @@ pub async fn create_invite(
"onion": onion,
"pubkey": pubkey,
"token": token,
"trust": trust_level.to_string(),
});
if let Some(npub) = fips_npub {
payload["fips_npub"] = serde_json::Value::String(npub.to_string());
@@ -59,6 +66,7 @@ pub async fn create_invite(
created_at: chrono::Utc::now().to_rfc3339(),
accepted: false,
fips_npub: fips_npub.map(|s| s.to_string()),
trust_level,
};
let mut invites = load_invites(data_dir).await?;
@@ -103,6 +111,13 @@ pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
.get("fips_npub")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Legacy codes (pre trust-threading) carry no "trust" field → Trusted,
// which matches what both sides did before the field existed.
let trust_level = payload
.get("trust")
.and_then(|v| v.as_str())
.and_then(TrustLevel::parse)
.unwrap_or(TrustLevel::Trusted);
Ok(ParsedInvite {
did,
@@ -110,6 +125,7 @@ pub fn parse_invite(code: &str) -> Result<ParsedInvite> {
pubkey,
token,
fips_npub,
trust_level,
})
}
@@ -128,8 +144,9 @@ pub async fn accept_invite(
did,
onion,
pubkey,
token: _,
token,
fips_npub,
trust_level,
} = parse_invite(code)?;
// Refuse self-peering. If the invite's did / onion / pubkey matches
@@ -173,7 +190,9 @@ pub async fn accept_invite(
pubkey,
onion,
name: None,
trust_level: TrustLevel::Trusted,
// The invite code itself says what this relationship is —
// Observer for "Invite a Peer", Trusted for "Link Your Nodes".
trust_level,
added_at: chrono::Utc::now().to_rfc3339(),
last_seen: None,
last_state: None,
@@ -194,6 +213,7 @@ pub async fn accept_invite(
created_at: chrono::Utc::now().to_rfc3339(),
accepted: true,
fips_npub,
trust_level,
});
save_invites(data_dir, &invites).await?;
@@ -206,6 +226,8 @@ pub async fn accept_invite(
local_pubkey,
local_fips_npub,
local_name,
Some(&token),
trust_level,
sign_fn,
)
.await;
@@ -217,6 +239,7 @@ pub async fn accept_invite(
/// Prefers FIPS (if the remote advertised an npub in their invite) and
/// falls back to Tor. Signs the message with our ed25519 key so the
/// remote peer can verify authenticity regardless of transport.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn notify_join(
remote_onion: &str,
remote_fips_npub: Option<&str>,
@@ -225,6 +248,8 @@ pub(crate) async fn notify_join(
local_pubkey: &str,
local_fips_npub: Option<&str>,
local_name: Option<&str>,
invite_token: Option<&str>,
trust_level: TrustLevel,
sign_fn: impl FnOnce(&[u8]) -> String,
) -> Result<()> {
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
@@ -233,6 +258,11 @@ pub(crate) async fn notify_join(
// (any identity claim is anchored on the signed did/pubkey); the
// FIPS daemon's own Noise handshake authenticates the transport
// session regardless of the advertised npub.
//
// invite_token + trust are also unsigned: the inviter treats the token
// as a lookup key into its own stored invites (authoritative for the
// granted level) and only ever accepts the trust claim as a DOWNGRADE,
// so neither field can escalate privileges.
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
let signature = sign_fn(sign_data.as_bytes());
@@ -241,6 +271,7 @@ pub(crate) async fn notify_join(
"onion": local_onion,
"pubkey": local_pubkey,
"signature": signature,
"trust": trust_level.to_string(),
});
if let Some(npub) = local_fips_npub {
params["fips_npub"] = serde_json::Value::String(npub.to_string());
@@ -248,6 +279,9 @@ pub(crate) async fn notify_join(
if let Some(name) = local_name {
params["name"] = serde_json::Value::String(name.to_string());
}
if let Some(token) = invite_token {
params["invite_token"] = serde_json::Value::String(token.to_string());
}
let body = serde_json::json!({
"method": "federation.peer-joined",
@@ -318,9 +352,16 @@ mod tests {
#[tokio::test]
async fn test_create_and_parse_invite() {
let dir = tempfile::tempdir().unwrap();
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", None)
.await
.unwrap();
let code = create_invite(
dir.path(),
"did:key:z1",
"test.onion",
"aabbcc",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
assert!(code.starts_with("fed1:"));
let parsed = parse_invite(&code).unwrap();
@@ -335,9 +376,16 @@ mod tests {
async fn test_invite_roundtrips_fips_npub() {
let dir = tempfile::tempdir().unwrap();
let fips = "npub1fipstest0000000000000000000000000000000000";
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc", Some(fips))
.await
.unwrap();
let code = create_invite(
dir.path(),
"did:key:z1",
"test.onion",
"aabbcc",
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.fips_npub.as_deref(), Some(fips));
}
@@ -362,6 +410,66 @@ mod tests {
assert!(parsed.fips_npub.is_none());
}
#[tokio::test]
async fn test_observer_invite_threads_trust_level() {
// "Invite a Peer" mints an observer invite; the acceptor must land
// on Observer, not Trusted (the original bug).
let dir = tempfile::tempdir().unwrap();
let code = create_invite(
dir.path(),
"did:key:zRemote",
"remote.onion",
"remotepub",
None,
TrustLevel::Observer,
)
.await
.unwrap();
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Observer);
let dir2 = tempfile::tempdir().unwrap();
let node = accept_invite(
dir2.path(),
&code,
"did:key:zLocal",
"local.onion",
"localpub",
None,
None,
|_| "test-sig".to_string(),
)
.await
.unwrap();
assert_eq!(node.trust_level, TrustLevel::Observer);
// The inviter's stored outgoing invite carries the level too — this
// is what peer-joined uses as the authoritative grant on the far side.
let invites = load_invites(dir.path()).await.unwrap();
assert_eq!(invites.outgoing.len(), 1);
assert_eq!(invites.outgoing[0].trust_level, TrustLevel::Observer);
}
#[test]
fn test_parse_legacy_invite_defaults_to_trusted() {
// Codes minted before the trust field existed must stay Trusted.
use base64::Engine;
let legacy = serde_json::json!({
"did": "did:key:zOld",
"onion": "old.onion",
"pubkey": "00",
"token": "aa",
});
let code = format!(
"fed1:{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_string(&legacy).unwrap())
);
let parsed = parse_invite(&code).unwrap();
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
}
#[test]
fn test_parse_invalid_invite() {
assert!(parse_invite("invalid").is_err());
@@ -377,6 +485,7 @@ mod tests {
"remote.onion",
"remotepub",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
@@ -413,6 +522,7 @@ mod tests {
"remote.onion",
"remotepub",
Some(fips),
TrustLevel::Trusted,
)
.await
.unwrap();
@@ -447,6 +557,7 @@ mod tests {
"remote.onion",
"remotepub",
None,
TrustLevel::Trusted,
)
.await
.unwrap();
+4 -1
View File
@@ -11,10 +11,13 @@ mod sync;
mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite};
pub use invites::{accept_invite, create_invite, parse_invite};
// Crate-internal: used by the periodic federation auto-sync to re-assert
// membership to peers that don't list us back (asymmetry self-heal).
pub(crate) use invites::notify_join;
// Crate-internal: peer-joined resolves the granted trust level by matching
// the acceptor's invite_token against our stored outgoing invites.
pub(crate) use storage::load_invites;
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
+15 -1
View File
@@ -403,7 +403,21 @@ mod tests {
last_transport_at: None,
},
];
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers, None);
let state = build_local_state(
vec![],
0.0,
0,
0,
0,
0,
0,
true,
None,
None,
None,
&peers,
None,
);
assert_eq!(state.federated_peers.len(), 1);
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
assert_eq!(
+37
View File
@@ -21,6 +21,34 @@ impl std::fmt::Display for TrustLevel {
}
}
impl TrustLevel {
/// Parse the lowercase wire form ("trusted" | "observer" | "untrusted").
pub fn parse(s: &str) -> Option<Self> {
match s {
"trusted" => Some(TrustLevel::Trusted),
"observer" => Some(TrustLevel::Observer),
"untrusted" => Some(TrustLevel::Untrusted),
_ => None,
}
}
/// The lower (less privileged) of two levels: Untrusted < Observer < Trusted.
pub fn min(self, other: Self) -> Self {
fn rank(l: TrustLevel) -> u8 {
match l {
TrustLevel::Untrusted => 0,
TrustLevel::Observer => 1,
TrustLevel::Trusted => 2,
}
}
if rank(self) <= rank(other) {
self
} else {
other
}
}
}
/// A federated node in our cluster.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedNode {
@@ -138,6 +166,15 @@ pub struct FederationInvite {
/// Inviter's FIPS mesh npub if advertised in the code.
#[serde(default)]
pub fips_npub: Option<String>,
/// Trust level this invite grants both sides ("Invite a Peer" mints
/// Observer invites, "Link Your Nodes" mints Trusted ones). Defaults
/// to Trusted for invites minted before this field existed.
#[serde(default = "default_invite_trust")]
pub trust_level: TrustLevel,
}
fn default_invite_trust() -> TrustLevel {
TrustLevel::Trusted
}
#[cfg(test)]
+4 -1
View File
@@ -1486,7 +1486,10 @@ mod tests {
#[tokio::test]
async fn lifecycle_guard_covers_name_app_id_and_legacy_underscores() {
assert!(!lifecycle_op_covers_container("hm-guard-app", "hm-guard-app"));
assert!(!lifecycle_op_covers_container(
"hm-guard-app",
"hm-guard-app"
));
// Held package lock covers the container directly and via stack
// membership; the '_'→'-' probe covers legacy underscore names.
+1 -2
View File
@@ -107,8 +107,7 @@ async fn main() -> Result<()> {
// RUST_LOG=archipelago=debug) to get debug logs back when debugging.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
@@ -162,7 +162,10 @@ async fn status_line() -> String {
} else {
format!("electrum {:.0}%", e.progress_pct)
};
format!("Archipelago OS v{}: {btc}, {elec}.", env!("CARGO_PKG_VERSION"))
format!(
"Archipelago OS v{}: {btc}, {elec}.",
env!("CARGO_PKG_VERSION")
)
}
#[cfg(test)]
+45 -28
View File
@@ -141,7 +141,9 @@ impl MeshRadioDevice {
anyhow::bail!("Native image send is Reticulum-only")
}
Self::Reticulum(device) => {
device.send_native_image(dest_pubkey_prefix, mime, bytes, caption).await
device
.send_native_image(dest_pubkey_prefix, mime, bytes, caption)
.await
}
}
}
@@ -297,7 +299,9 @@ async fn auto_detect_and_open(
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
Err(e) => {
debug!(path = %path, error = %e, "Reticulum daemon failed to initialize")
}
},
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
}
@@ -323,7 +327,9 @@ async fn auto_detect_and_open(
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
Err(e) => {
debug!(path = %path, error = %e, "Could not open serial port for Meshtastic")
}
}
}
}
@@ -399,7 +405,9 @@ async fn open_preferred_path(
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode"),
Err(e) => {
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
}
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
@@ -431,26 +439,22 @@ async fn open_reticulum_tcp(
our_x25519_pubkey_hex: &str,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut dev = match cfg {
ReticulumTcpConfig::Server { bind } => {
ReticulumLink::open_tcp_server(
bind,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP server interface")?
}
ReticulumTcpConfig::Client { connect } => {
ReticulumLink::open_tcp_client(
connect,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP client interface")?
}
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
bind,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP server interface")?,
ReticulumTcpConfig::Client { connect } => ReticulumLink::open_tcp_client(
connect,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP client interface")?,
};
let info = dev
.initialize()
@@ -790,7 +794,9 @@ fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
fn reticulum_contact_id(public_key_hex: &str) -> Option<u32> {
let bytes = hex::decode(public_key_hex).ok()?;
let hash: [u8; 16] = bytes.try_into().ok()?;
Some(super::super::reticulum::reticulum_contact_id_from_hash(&hash))
Some(super::super::reticulum::reticulum_contact_id_from_hash(
&hash,
))
}
/// Drain any queued messages from the device.
@@ -873,12 +879,23 @@ pub(super) async fn run_mesh_session(
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind)
.await?
auto_detect_and_open(
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
)
.await?
}
}
} else {
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind).await?
auto_detect_and_open(
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
)
.await?
};
// Update status
+31 -14
View File
@@ -414,7 +414,11 @@ impl MeshtasticDevice {
// tx_power defaults to max, which is what we want for a stock mesh.
let mut lora = Vec::new();
encode_varint_field_into(LORA_USE_PRESET_FIELD, 1, &mut lora);
encode_varint_field_into(LORA_MODEM_PRESET_FIELD, LORA_MODEM_PRESET_LONG_FAST, &mut lora);
encode_varint_field_into(
LORA_MODEM_PRESET_FIELD,
LORA_MODEM_PRESET_LONG_FAST,
&mut lora,
);
encode_varint_field_into(LORA_REGION_FIELD, region_code as u64, &mut lora);
encode_varint_field_into(LORA_HOP_LIMIT_FIELD, 3, &mut lora);
encode_varint_field_into(LORA_TX_ENABLED_FIELD, 1, &mut lora);
@@ -772,7 +776,9 @@ impl MeshtasticDevice {
if let Err(e) = self.send_to_radio(&encode_want_config()).await {
warn!("Failed to re-request config after radio reboot: {}", e);
} else {
info!("Re-requested Meshtastic config after reboot — packet stream resubscribed");
info!(
"Re-requested Meshtastic config after reboot — packet stream resubscribed"
);
}
}
if let Some(inbound) = inbound {
@@ -890,7 +896,10 @@ impl MeshtasticDevice {
if let Some((region, modem_preset)) = parse_config_lora_region(value) {
self.current_region = Some(region);
self.current_modem_preset = Some(modem_preset);
debug!(region, modem_preset, "Meshtastic LoRa region/preset from device config");
debug!(
region,
modem_preset, "Meshtastic LoRa region/preset from device config"
);
}
None
}
@@ -1066,7 +1075,10 @@ fn packet_to_inbound_frame(
if packet.portnum == POSITION_APP {
if let Some((lat, lon)) = parse_position_lat_lon(&packet.payload) {
debug!(from = format!("!{:08x}", from), lat, lon, "Meshtastic position update");
debug!(
from = format!("!{:08x}", from),
lat, lon, "Meshtastic position update"
);
contact.lat = Some(lat);
contact.lon = Some(lon);
}
@@ -1309,7 +1321,7 @@ fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
/// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g.
/// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive.
/// Returns `None` for an unrecognised name so we never write a bogus region.
fn region_name_to_code(name: &str) -> Option<u32> {
pub(crate) fn region_name_to_code(name: &str) -> Option<u32> {
Some(match name.trim().to_uppercase().as_str() {
"UNSET" => 0,
"US" => 1,
@@ -1811,10 +1823,10 @@ mod tests {
encode_fixed32_field(1, 0x1111_2222, &mut packet); // from
encode_len_field(4, &decoded, &mut packet); // decoded
encode_fixed32_field(8, (-7.5f32).to_bits(), &mut packet); // rx_snr
// int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as
// the 10-byte two's-complement-extended varint; truncating back to
// i32 after decode recovers the original value (see parse_mesh_packet's
// `v as i32` cast — confirmed by this roundtrip, not just asserted).
// int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as
// the 10-byte two's-complement-extended varint; truncating back to
// i32 after decode recovers the original value (see parse_mesh_packet's
// `v as i32` cast — confirmed by this roundtrip, not just asserted).
encode_varint_field_into(12, (-92i32) as u32 as u64, &mut packet);
let parsed = parse_mesh_packet(&packet).expect("packet should parse");
@@ -1901,7 +1913,10 @@ mod tests {
// must still update the contact's signal/position bookkeeping.
let frame =
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys);
assert!(frame.is_none(), "POSITION_APP must not surface as a chat frame");
assert!(
frame.is_none(),
"POSITION_APP must not surface as a chat frame"
);
let contact = contacts.get(&from).expect("contact should be tracked");
assert_eq!(contact.snr, Some(-6.0));
assert_eq!(contact.rssi, Some(-80));
@@ -1930,7 +1945,10 @@ mod tests {
// A `to == BROADCAST_NUM` text is a channel broadcast (3ccc on public
// LongFast), so it routes to the channel thread, carrying its sender.
assert_eq!(frame.code, protocol::RESP_MESHTASTIC_CHANNEL_TEXT);
assert_eq!(frame.data[0], 0, "no channel field set => primary/public (0)");
assert_eq!(
frame.data[0], 0,
"no channel field set => primary/public (0)"
);
assert_eq!(&frame.data[1..7], &[0xcc, 0x3c, 0x00, 0x00, 0x6d, 0x65]);
assert_eq!(&frame.data[7..], b"hello from 3ccc");
assert!(contacts.contains_key(&from));
@@ -1954,9 +1972,8 @@ mod tests {
encode_len_field(4, &decoded, &mut packet);
encode_fixed32_field(7, 12_345, &mut packet);
let frame =
packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
.expect("directed DM must surface");
let frame = packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
.expect("directed DM must surface");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
let (sender_prefix, payload, _snr) =
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
+14 -6
View File
@@ -213,7 +213,11 @@ pub struct TypedEnvelope {
/// Unix timestamp (seconds since epoch).
pub ts: u32,
/// Optional Ed25519 signature of (t || v || ts_bytes) — for signed messages.
#[serde(default, skip_serializing_if = "Option::is_none", with = "compact_bytes_opt")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "compact_bytes_opt"
)]
pub sig: Option<Vec<u8>>,
/// Message sequence number (per-sender, monotonically increasing).
#[serde(default)]
@@ -564,7 +568,11 @@ pub struct ContentRefPayload {
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "base64_opt_bytes"
)]
pub thumb_bytes: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
@@ -860,8 +868,8 @@ mod tests {
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec())
.signed_with_seq(42, &key);
let envelope =
TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec()).signed_with_seq(42, &key);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
// v2 binds seq: replaying the signed envelope under a different
@@ -880,8 +888,8 @@ mod tests {
// preimage, no seq) and allocate seq afterwards; verify must still
// accept that.
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key)
.with_seq(7);
let envelope =
TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key).with_seq(7);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
}
+16 -1
View File
@@ -401,6 +401,12 @@ pub struct MeshConfig {
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
}
/// True when `name` is a LoRa region the Meshtastic driver can program
/// (the canonical region list for the mesh settings UI).
pub fn meshtastic_region_is_valid(name: &str) -> bool {
meshtastic::region_name_to_code(name).is_some()
}
fn default_assistant_backend() -> String {
"claude".to_string()
}
@@ -545,6 +551,12 @@ pub async fn detect_devices() -> Vec<String> {
serial::detect_serial_devices().await
}
/// Detected serial ports with USB identity metadata (for the device-detected
/// setup modal's hardware graphic).
pub async fn detect_devices_info() -> Vec<serial::DetectedDeviceInfo> {
serial::detect_serial_devices_info().await
}
// ─── MeshService ────────────────────────────────────────────────────────
/// Top-level mesh networking service.
@@ -2315,7 +2327,10 @@ mod tests {
// Force the dev fallback even if a packaged binary happens to exist
// on this machine's PATH convention — this test wants exactly the
// freshly-built venv daemon under test.
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", "/nonexistent-force-dev-fallback");
std::env::set_var(
"ARCHY_RETICULUM_DAEMON_BIN",
"/nonexistent-force-dev-fallback",
);
let data_dir = root.path().join("node");
std::fs::create_dir_all(data_dir.join("identity")).unwrap();
+50 -19
View File
@@ -270,7 +270,9 @@ impl ReticulumLink {
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
probe_rnode(path).await.context("RNode KISS detect failed")?;
probe_rnode(path)
.await
.context("RNode KISS detect failed")?;
Self::spawn(
ReticulumInterface::Serial(path),
data_dir,
@@ -342,8 +344,9 @@ impl ReticulumLink {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
let _ =
tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
}
let label = iface.label();
let iface_key = label.replace(['/', ' ', ':', ','], "_");
@@ -553,7 +556,11 @@ impl ReticulumLink {
Ok(())
}
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
pub async fn send_text_msg(
&mut self,
dest_pubkey_prefix: &[u8; 6],
payload: &[u8],
) -> Result<()> {
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
@@ -738,11 +745,9 @@ impl ReticulumLink {
async fn drain_events(&mut self) {
loop {
let mut line = String::new();
let read = tokio::time::timeout(
Duration::from_millis(20),
self.reader.read_line(&mut line),
)
.await;
let read =
tokio::time::timeout(Duration::from_millis(20), self.reader.read_line(&mut line))
.await;
let n = match read {
Ok(Ok(n)) => n,
_ => break, // timeout (no data) or read error — stop draining
@@ -840,7 +845,8 @@ impl ReticulumLink {
// to survive a restart — give it a placeholder name (the real
// one, if any, arrives via a later "announce" and overwrites
// this) so its routing entry alone doesn't get lost.
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
{
e.insert(ReticulumPeer {
dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
@@ -858,13 +864,22 @@ impl ReticulumLink {
// UI (dispatch.rs's existing ContentInline handling, zero new
// frontend code) instead of the plain text bytes below.
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let caption = ev.get("content").and_then(Value::as_str).filter(|s| !s.trim().is_empty());
let caption = ev
.get("content")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty());
if let (Some(fmt), Some(b64)) = (
ev.get("image_format").and_then(Value::as_str),
ev.get("image_b64").and_then(Value::as_str),
) {
if let Ok(bytes) = B64.decode(b64) {
match build_content_inline_frame(&prefix, image_format_to_mime(fmt), None, caption, bytes) {
match build_content_inline_frame(
&prefix,
image_format_to_mime(fmt),
None,
caption,
bytes,
) {
Ok(frame) => {
self.inbound.push_back(frame);
return;
@@ -905,7 +920,8 @@ impl ReticulumLink {
},
None => content_str.as_bytes().to_vec(),
};
self.inbound.push_back(build_synthetic_frame(&prefix, &content));
self.inbound
.push_back(build_synthetic_frame(&prefix, &content));
}
Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
@@ -916,7 +932,8 @@ impl ReticulumLink {
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
{
e.insert(ReticulumPeer {
dest_hash: source_hash,
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
@@ -939,7 +956,8 @@ impl ReticulumLink {
// Resources are already a binary-safe whole-blob transfer),
// so this is the same payload shape `decode.rs` already
// accepts for a single-frame (non-chunked) typed envelope.
self.inbound.push_back(build_synthetic_frame(&prefix, &data));
self.inbound
.push_back(build_synthetic_frame(&prefix, &data));
}
Some("resource_progress") => {
debug!(
@@ -1138,7 +1156,14 @@ mod tests {
#[test]
fn detect_resp_found_in_kiss_stream() {
let stream = [0x10, 0x20, KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP, 0x99];
let stream = [
0x10,
0x20,
KISS_FEND,
KISS_CMD_DETECT,
KISS_DETECT_RESP,
0x99,
];
assert!(contains_detect_resp(&stream));
}
@@ -1154,7 +1179,9 @@ mod tests {
async fn probe_rnode_detects_real_hardware() {
let port = std::env::var("ARCHY_RNODE_TEST_PORT")
.expect("set ARCHY_RNODE_TEST_PORT to the RNode's serial path");
probe_rnode(&port).await.expect("KISS detect probe failed against real hardware");
probe_rnode(&port)
.await
.expect("KISS detect probe failed against real hardware");
}
#[test]
@@ -1181,7 +1208,8 @@ mod tests {
arch_pubkey_hex: Some("abcdef".to_string()),
}];
let dir = std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
let dir =
std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("peers.json");
std::fs::write(&path, serde_json::to_vec(&persisted).unwrap()).unwrap();
@@ -1207,7 +1235,10 @@ mod tests {
h
};
let id = reticulum_contact_id_from_hash(&hash_high_bit);
assert!(id < 0x8000_0000, "must not collide with federation-synthetic space");
assert!(
id < 0x8000_0000,
"must not collide with federation-synthetic space"
);
assert_ne!(id, 0);
let zero_hash = [0u8; 16];
+77
View File
@@ -509,6 +509,83 @@ pub async fn detect_serial_devices() -> Vec<String> {
devices
}
/// USB identity of a detected serial port, read from sysfs — lets the UI
/// show the actual board (native-USB boards like T-Deck/RAK4631 report their
/// name in `product`; bridge chips like CP2102/CH340 only identify the chip,
/// so vid:pid is the fallback signal).
#[derive(Debug, Clone, serde::Serialize)]
pub struct DetectedDeviceInfo {
pub path: String,
pub vid: Option<String>,
pub pid: Option<String>,
pub product: Option<String>,
pub manufacturer: Option<String>,
}
/// Like `detect_serial_devices`, but with USB metadata per port.
pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
let mut out = Vec::new();
for path in detect_serial_devices().await {
let usb = usb_info_for_tty(&path).await;
out.push(DetectedDeviceInfo {
path,
vid: usb.0,
pid: usb.1,
product: usb.2,
manufacturer: usb.3,
});
}
out
}
/// Resolve a tty path (following the /dev/mesh-radio symlink) to its USB
/// device sysfs node and read idVendor/idProduct/product/manufacturer.
/// Best-effort: any miss returns None fields (e.g. non-USB UARTs).
async fn usb_info_for_tty(
path: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let resolved = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| std::path::PathBuf::from(path));
let Some(name) = resolved.file_name().and_then(|n| n.to_str()) else {
return (None, None, None, None);
};
// /sys/class/tty/<name>/device -> .../usbN/N-M/N-M:1.0/(ttyUSBx|tty). Walk
// up from the device link until a directory with idVendor appears.
let mut dir = std::path::PathBuf::from(format!("/sys/class/tty/{name}/device"));
for _ in 0..6 {
if tokio::fs::metadata(dir.join("idVendor")).await.is_ok() {
let read = |f: &str| {
let p = dir.join(f);
async move {
tokio::fs::read_to_string(p)
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
};
return (
read("idVendor").await,
read("idProduct").await,
read("product").await,
read("manufacturer").await,
);
}
dir.push("..");
let Ok(canon) = tokio::fs::canonicalize(&dir).await else {
break;
};
dir = canon;
}
(None, None, None, None)
}
/// Try to open and handshake with each detected serial device.
/// Returns the first device that responds as Meshcore.
pub async fn probe_for_meshcore(paths: &[String]) -> Option<(String, DeviceInfo)> {
+3 -1
View File
@@ -614,7 +614,9 @@ mod tests {
#[tokio::test]
async fn test_lnd_aezeed_empty_rejected() {
let dir = tempfile::tempdir().unwrap();
assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x").await.is_err());
assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x")
.await
.is_err());
}
#[tokio::test]
+11 -4
View File
@@ -89,8 +89,10 @@ impl Server {
if let Ok(loc) = serde_json::from_slice::<serde_json::Value>(&bytes) {
data.server_info.lat = loc.get("lat").and_then(|v| v.as_f64());
data.server_info.lon = loc.get("lon").and_then(|v| v.as_f64());
data.server_info.share_location =
loc.get("share_location").and_then(|v| v.as_bool()).unwrap_or(false);
data.server_info.share_location = loc
.get("share_location")
.and_then(|v| v.as_bool())
.unwrap_or(false);
}
}
data.server_info.tor_address = docker_packages::read_tor_address("archipelago").await;
@@ -515,6 +517,10 @@ impl Server {
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
// Re-assert at the level WE hold for
// this peer; no invite token on heal.
None,
node.trust_level,
|b| node_identity.sign(b),
)
.await
@@ -1261,8 +1267,9 @@ fn merge_preserving_transitional(
/// scan is the owner: once podman reports a settled state and the id is no
/// longer queued for a boot start, the fresh state wins immediately instead
/// of being preserved for the transitional-stuck timeout.
static SCANNER_RESTARTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
static SCANNER_RESTARTING: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashSet<String>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
fn take_scanner_restarting(id: &str) -> bool {
SCANNER_RESTARTING
+4 -1
View File
@@ -67,7 +67,10 @@ pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
match ecash::receive_token(data_dir, token).await {
Ok(amount) => {
received_total += amount;
info!(amount_sats = amount, "swept TollGate ecash into local wallet");
info!(
amount_sats = amount,
"swept TollGate ecash into local wallet"
);
}
Err(e) => {
// The token is still in this log line if this happens — not
+7 -5
View File
@@ -355,8 +355,7 @@ fn parse_and_verify_manifest(raw: serde_json::Value) -> Result<(UpdateManifest,
crate::trust::SignatureStatus::Verified { anchored, .. } => anchored,
crate::trust::SignatureStatus::Unsigned => false,
};
let manifest: UpdateManifest =
serde_json::from_value(raw).context("parse update manifest")?;
let manifest: UpdateManifest = serde_json::from_value(raw).context("parse update manifest")?;
Ok((manifest, signed))
}
@@ -625,8 +624,8 @@ pub async fn verify_pending_update(data_dir: &Path) {
// .116 during the v1.7.40 rollout recovery.
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
while std::time::Instant::now() < deadline {
attempt += 1;
@@ -2087,7 +2086,10 @@ mod tests {
key
}
fn sign_value(key: &ed25519_dalek::SigningKey, mut doc: serde_json::Value) -> serde_json::Value {
fn sign_value(
key: &ed25519_dalek::SigningKey,
mut doc: serde_json::Value,
) -> serde_json::Value {
let (sig, did) = crate::trust::signed_doc::sign_detached(key, &doc).unwrap();
let obj = doc.as_object_mut().unwrap();
obj.insert("signed_by".into(), serde_json::json!(did));
+13 -16
View File
@@ -171,19 +171,18 @@ impl CashuToken {
let v4: TokenV4 =
ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?;
let proofs = v4
.t
.into_iter()
.flat_map(|entry| {
let keyset_id = hex::encode(&entry.i);
entry.p.into_iter().map(move |p| Proof {
amount: p.a,
id: keyset_id.clone(),
secret: p.s,
c: hex::encode(&p.c),
let proofs =
v4.t.into_iter()
.flat_map(|entry| {
let keyset_id = hex::encode(&entry.i);
entry.p.into_iter().map(move |p| Proof {
amount: p.a,
id: keyset_id.clone(),
secret: p.s,
c: hex::encode(&p.c),
})
})
})
.collect();
.collect();
let token = CashuToken {
token: vec![TokenEntry { mint: v4.m, proofs }],
@@ -407,10 +406,8 @@ mod tests {
use ciborium::value::Value;
let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e];
let sig = hex::decode(
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24",
)
.unwrap();
let sig = hex::decode("02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24")
.unwrap();
let proof = Value::Map(vec![
(Value::from("a"), Value::from(8u64)),