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",