Merge remote-tracking branch 'origin/main' into archy-hwconfig

This commit is contained in:
2026-07-23 21:40:06 +00:00
89 changed files with 3564 additions and 359 deletions
+20 -2
View File
@@ -188,7 +188,7 @@ impl ApiHandler {
}
};
let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
_ => {
// Not a paid item — no invoice to issue.
return Ok(build_response(
@@ -198,6 +198,13 @@ impl ApiHandler {
));
}
};
if !content_server::method_accepted(&item.access, "lightning") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
));
}
let memo = format!("Archipelago peer file {content_id}");
match self
@@ -315,7 +322,18 @@ impl ApiHandler {
.unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
content_server::AccessControl::Paid { price_sats, .. } => {
if !content_server::method_accepted(&i.access, "onchain") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
),
));
}
*price_sats
}
_ => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
+115 -1
View File
@@ -179,7 +179,24 @@ impl RpcHandler {
if price == 0 {
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
}
AccessControl::Paid { price_sats: price }
// Optional list of payment methods the sharer accepts.
// Absent/empty = all methods (backward compatible).
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
let accepted: Vec<String> = params
.get("accepted_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.as_str())
.filter(|m| KNOWN_METHODS.contains(m))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
AccessControl::Paid {
price_sats: price,
accepted,
}
}
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
};
@@ -412,6 +429,55 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// NEVER pay twice for content we already own (2026-07-22: a file
// shared twice produced two catalog ids for the same bytes and the
// buyer paid both). Guard BEFORE any ecash is minted, matching both
// by exact (onion, content_id) and by (onion, filename) — the latter
// catches duplicate ids pointing at the same file on the same
// seller. The owned copy is served from the local cache instead.
{
let filename = params.get("filename").and_then(|v| v.as_str());
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
let already = owned.iter().find(|o| {
o.onion == onion
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
}))
});
if let Some(o) = already {
tracing::info!(
onion,
content_id,
owned_as = %o.content_id,
"paid download: already owned — serving cached copy, NOT paying again"
);
if let Some((mime, bytes)) = crate::content_owned::read_owned(
&self.config.data_dir,
&o.onion,
&o.content_id,
)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
"owned": true,
"already_owned": true,
"filename": o.filename,
"mime_type": mime,
"size_bytes": bytes.len(),
"paid_sats": 0,
"data_base64":
base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
}
// Cache record exists but bytes are gone — fall through and
// repurchase rather than stranding the user.
}
}
// `method` pins the backend the user confirmed in the UI ("cashu" |
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
// verify_payment_token accepts either, so a node whose balance lives in
@@ -590,6 +656,54 @@ impl RpcHandler {
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
}
// Auto-file the purchase into the user's Files area (2026-07-22):
// Photos for images/video, Music for audio, Documents otherwise —
// same buckets the Cloud view uses. The in-app viewer still plays
// from the purchase cache; this makes the file ALSO show up where
// files live, on every device, without relying on a browser
// download. Best-effort: never fail a paid download over it.
{
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
"Photos"
} else if mime_type.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let base = std::path::Path::new(&filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let dir = self.config.data_dir.join("filebrowser").join(folder);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (base.clone(), String::new()),
};
let mut n = 2;
while target.exists() {
target = dir.join(format!("{stem} ({n}){ext}"));
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
),
}
}
}
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -260,6 +260,7 @@ impl RpcHandler {
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
// Ark protocol (via barkd sidecar)
"wallet.ark-status" => self.handle_wallet_ark_status().await,
+25
View File
@@ -118,6 +118,31 @@ impl RpcHandler {
Ok(serde_json::json!({ "removed": removed }))
}
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
/// with sufficient balance. Returns the notes token for the recipient
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
/// split from Cashu 2026-07-22). The heavy lifting already existed in
/// `fedimint_client::spend_from_any`; it was simply never exposed.
pub(super) async fn handle_wallet_fedimint_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let amount_sats = params
.as_ref()
.and_then(|p| p.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
let (token, federation_id) =
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
.await?;
Ok(serde_json::json!({
"token": token,
"federation_id": federation_id,
"amount_sats": amount_sats,
}))
}
/// `wallet.fedimint-balance` — total sats across all joined federations.
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified
+14 -2
View File
@@ -30,9 +30,21 @@ impl RpcHandler {
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
// isn't directly dialable (phone away from home, node behind NAT).
let anchors = fips::anchors::load(&self.config.data_dir)
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
.await
.unwrap_or_default()
.unwrap_or_default();
// Pairing must always carry a public rendezvous point: without one the
// phone is IP-bound to the LAN host it scanned and goes dark the
// moment it leaves that network. This is a pairing hint only — the
// node's own anchor file is not modified, so an operator's removal of
// the default anchors still sticks for the node itself.
if !anchor_list
.iter()
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
{
anchor_list.push(fips::anchors::archy_anchor());
}
let anchors = anchor_list
.into_iter()
.map(|a| {
serde_json::json!({
+51 -4
View File
@@ -86,7 +86,7 @@ impl RpcHandler {
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let version = data.server_info.version.clone();
let relays = self.config.nostr_relays.clone();
let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence(
@@ -106,6 +106,17 @@ impl RpcHandler {
Ok(serde_json::json!({ "enabled": enabled }))
}
/// The relay set every handshake operation uses: the user-managed relay
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
/// hardcoded config relays (one of which is defunct) and ignored user
/// relay edits entirely — so a sender publishing where the receiver
/// never read was a routine, silent way for peer requests to vanish.
pub(super) async fn handshake_relays(&self) -> Vec<String> {
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
.await
}
/// Discover discoverable nodes via Nostr presence events.
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
@@ -113,9 +124,10 @@ impl RpcHandler {
// to query relays as long as the user is actively browsing — they're
// an anonymous observer of presence events, not publishing anything.
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let nodes = nostr_handshake::discover_nodes(
&identity_dir,
&self.config.nostr_relays,
&relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
@@ -161,7 +173,7 @@ impl RpcHandler {
our_version,
our_name,
message,
&self.config.nostr_relays,
&self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
@@ -191,6 +203,40 @@ impl RpcHandler {
/// - `PeerReject` → mark matching outbound row as `Rejected`
///
/// Never auto-adds peers, never auto-responds, never sends our onion.
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
/// ONLY when a user opened Federation and pressed the Poll button — a
/// peer request sat on the relay until the target's operator happened to
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
/// and nudges the websocket revision when anything new lands so open UIs
/// refresh immediately.
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
match self.handle_handshake_poll().await {
Ok(res) => {
let new = res
.get("new_requests")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let applied = res
.get("applied_invites")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
if new > 0 || applied > 0 {
tracing::info!(
new_requests = new,
applied_invites = applied,
"handshake poll: inbound peer activity"
);
let (data, _) = self.state_manager.get_snapshot().await;
self.state_manager.update_data(data).await;
}
}
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
}
}
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
// Runtime gate: if the user hasn't enabled discoverability, don't
// touch the relays. The poll endpoint is a hard no-op until they
@@ -207,9 +253,10 @@ impl RpcHandler {
}));
}
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let handshakes = nostr_handshake::poll_handshakes(
&identity_dir,
&self.config.nostr_relays,
&relays,
self.config.nostr_tor_proxy.as_deref(),
None,
)
+102
View File
@@ -120,6 +120,108 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
Ok(())
}
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
/// for 14 HOURS with its RPC answering but the server never finishing
/// startup — synced_to_chain=false, zero peers, every channel inactive —
/// and nothing noticed until a human tried to open a channel. The wedge
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
/// with channels that need a peer) persists. A restart reliably clears it
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
/// after 15 consecutive bad minutes we bounce the container ourselves, with
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the
/// operator.
pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move {
let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None;
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet)
continue;
};
let macaroon_hex = hex::encode(bytes);
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
else {
continue;
};
let Ok(resp) = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
else {
bad_minutes = 0; // down/locked — not the wedge signature
continue;
};
let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0;
continue;
};
let synced = info
.get("synced_to_chain")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
let channels = info
.get("num_active_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_inactive_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_pending_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let wedged = !synced || (channels > 0 && peers == 0);
if !wedged {
bad_minutes = 0;
continue;
}
bad_minutes += 1;
if bad_minutes < 15 {
continue;
}
if last_restart
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
.unwrap_or(false)
{
continue;
}
tracing::warn!(
synced_to_chain = synced,
num_peers = peers,
channels,
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
);
let out = tokio::process::Command::new("podman")
.args(["restart", "lnd"])
.output()
.await;
match out {
Ok(o) if o.status.success() => {
tracing::info!("LND watchdog restart complete");
}
Ok(o) => tracing::warn!(
"LND watchdog restart failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
}
last_restart = Some(tokio::time::Instant::now());
bad_minutes = 0;
}
});
}
impl RpcHandler {
/// Helper: create an authenticated LND REST client.
/// Returns an HTTP client configured for LND's self-signed TLS and the
@@ -62,6 +62,14 @@ impl RpcHandler {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
// Invoices are short-lived; retrying the same one can never
// succeed, so tell the user the way out instead of just the fact.
if msg.contains("invoice expired") {
return Err(anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
msg.trim_start_matches("invoice expired. ")
));
}
return Err(anyhow::anyhow!("Payment failed: {}", msg));
}
@@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Mempool requires",
"Container",
"Image",
// Wallet-actionable errors: masking "Insufficient balance: need 80
// sats, have 0 sats" behind "Operation failed. Check server logs."
// sent the operator to journalctl for a message that was written for
// them in the first place (ecash send, 2026-07-22).
"Insufficient balance",
"Insufficient funds",
// Lightning payment failures carry LND's reason ("invoice expired.
// Valid until …", "no route", …) — the user can act on every one of
// them, and masking sent the operator to journalctl (invoice-expired
// send, 2026-07-23).
"Payment failed",
"Invalid payment request",
"Missing 'payment_request'",
"Your Lightning node is still finishing",
"Bitcoin address",
"No router",
"No OpenWrt",
@@ -151,6 +165,25 @@ mod sanitize_tests {
}
}
#[test]
fn lightning_payment_errors_pass_through() {
// LND's payment-failure reasons are written for the payer — masking
// "invoice expired" as "Check server logs" left a user retrying a
// dead invoice (framework-pt, 2026-07-23).
for msg in [
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
"Payment failed: unable to find a path to destination",
"Invalid payment request: must be a Lightning invoice (lnbc...)",
"Missing 'payment_request' parameter",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
+1
View File
@@ -26,6 +26,7 @@ mod node;
mod nostr;
mod openwrt;
mod package;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
mod pine_status;
mod response;
@@ -4,6 +4,7 @@ mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
mod set_config;
@@ -697,6 +697,189 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
false
}
// ---------------------------------------------------------------------------
// Wyoming satellite keeper
// ---------------------------------------------------------------------------
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
///
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
/// renumbering — or the whole node moving to a different network — strands
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
/// never re-resolves on its own. This keeper probes each satellite entry and,
/// when one stops answering, sweeps the node's local /24s for the same
/// Wyoming port and rewrites the entry to the address that answers.
pub(crate) async fn wyoming_satellite_keeper() {
loop {
if home_assistant_installed().await {
heal_wyoming_satellites().await;
}
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
}
}
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
matches!(
tokio::time::timeout(
std::time::Duration::from_millis(ms),
tokio::net::TcpStream::connect((host, port)),
)
.await,
Ok(Ok(_))
)
}
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
/// excluded — satellites live on real LANs.
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
let Ok(out) = tokio::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "scope", "global"])
.output()
.await
else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let cidr = l.split_whitespace().nth(3)?;
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
let o = ip.octets();
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
if o[0] == 100 && (64..128).contains(&o[1]) {
return None;
}
Some(ip)
})
.collect()
}
/// Sweep the /24 of every local interface for something answering `port`.
/// First responder wins; the node's own addresses are skipped.
async fn find_satellite(port: u16) -> Option<String> {
let self_ips = local_ipv4_addresses().await;
for base in self_ips.iter().map(|ip| ip.octets()) {
let mut set = tokio::task::JoinSet::new();
for i in 1..255u8 {
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
if self_ips.contains(&ip) {
continue;
}
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
}
while let Some(res) = set.join_next().await {
if let Ok(Some(ip)) = res {
return Some(ip);
}
}
}
None
}
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
/// port now answers. Stops HA before editing the store (HA flushes its own
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
/// again after.
async fn heal_wyoming_satellites() {
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
let Some(store) = read_store(&path).await else {
return;
};
let Some(entries) = store
.get("data")
.and_then(|d| d.get("entries"))
.and_then(|e| e.as_array())
else {
return;
};
// (host, port, new_host) for every stranded satellite we can re-resolve.
let mut moves: Vec<(String, u16, String)> = Vec::new();
for e in entries {
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
continue;
}
let Some(host) = e
.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
else {
continue;
};
// Engines use host.containers.internal — only IP-pinned entries drift.
if host.parse::<std::net::Ipv4Addr>().is_err() {
continue;
}
let port = e
.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
.unwrap_or(0) as u16;
if port == 0 || tcp_alive(host, port, 1500).await {
continue;
}
let Some(new_host) = find_satellite(port).await else {
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
continue;
};
if new_host != host {
moves.push((host.to_string(), port, new_host));
}
}
if moves.is_empty() {
return;
}
// Stop HA, re-read + rewrite the store, start HA.
let _ = tokio::process::Command::new("podman")
.args(["stop", "homeassistant"])
.output()
.await;
if let Some(mut store) = read_store(&path).await {
let mut changed = false;
if let Some(entries) = store
.get_mut("data")
.and_then(|d| d.get_mut("entries"))
.and_then(|e| e.as_array_mut())
{
for e in entries.iter_mut() {
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
continue;
}
let host = e
.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let port = e
.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
.unwrap_or(0) as u16;
if let Some((_, _, new_host)) =
moves.iter().find(|(h, p, _)| *h == host && *p == port)
{
if let Some(data) = e.get_mut("data") {
data["host"] = json!(new_host);
}
e["modified_at"] = json!(ha_now());
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
changed = true;
}
}
}
if changed {
write_store(&path, &store).await;
}
}
let _ = tokio::process::Command::new("podman")
.args(["start", "homeassistant"])
.output()
.await;
}
// ---------------------------------------------------------------------------
// Presence probes + HA restart
// ---------------------------------------------------------------------------
@@ -139,9 +139,17 @@ impl RpcHandler {
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "archipelago".to_string());
// LAN IPv4 rides along for the companion pairing QR: when the
// operator's browser reaches this node over Tailscale/VPN or
// localhost, that origin is useless to a phone on the LAN — the QR
// must advertise an address the phone can actually dial
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
// companion could never connect).
let lan_ip = crate::host_ip::primary_host_ipv4().await;
Ok(serde_json::json!({
"hostname": hostname,
"mdns_hostname": format!("{hostname}.local"),
"lan_ip": lan_ip,
}))
}
+125
View File
@@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
// The router daemon handles routing + the ELD re-modeset nudge; this heal
// installs the packages, group membership, script and unit on deployed nodes.
const AUDIO_ROUTER: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
const AUDIO_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
// splice-from-configs + self-heal pattern as the audio router.
const GAMEPAD_KEYS: &str =
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
const GAMEPAD_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
// write the identical file at build time (image-recipe/_archived/
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
@@ -794,6 +816,109 @@ pub async fn ensure_kiosk_hardened() {
}
}
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
/// under the lingering user manager — no logind seat, so no udev ACLs on
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
/// nudge) installed and current. No-op on nodes without the kiosk — audio
/// only matters where media plays on an attached display.
pub async fn ensure_audio_stack() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return; // no kiosk → no display audio to route
}
// Package install runs via systemd-run (host_sudo), outside the service
// sandbox — /usr and the dpkg database are read-only in our namespace.
if fs::metadata("/usr/bin/pactl").await.is_err() {
info!("audio: PipeWire stack missing — installing packages");
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
match host_sudo(&[
"apt-get",
"install",
"-y",
"-qq",
"--no-install-recommends",
"pipewire",
"pipewire-pulse",
"pipewire-alsa",
"wireplumber",
"alsa-utils",
])
.await
{
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
Ok(s) => {
warn!("audio: package install exited with {} — will retry next start", s);
return;
}
Err(e) => {
warn!("audio: package install failed: {:#} — will retry next start", e);
return;
}
}
}
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
}
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("audio: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
// First install on this node — bring it up now and on every boot.
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
} else if script_changed || unit_changed {
// Content update: restart only if it's running — never re-enable a
// unit an operator deliberately disabled.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
info!("audio: router updated");
}
}
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
/// daemon (controller works inside every app iframe on the TV) installed and
/// current. Same gating as audio: no kiosk → no display input to bridge.
pub async fn ensure_gamepad_keys() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return;
}
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
.await
.unwrap_or(false);
if script_changed {
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
}
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
.await
.unwrap_or(false);
if script_changed || unit_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
}
}
if unit_was_missing {
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
info!("gamepad: bridge installed and enabled (TV controller input)");
} else if script_changed || unit_changed {
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
info!("gamepad: bridge updated");
}
}
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend
@@ -108,17 +108,33 @@ impl BootReconciler {
let orchestrator = self.orchestrator.clone();
let interval = self.interval;
Some(tokio::spawn(async move {
let mut failure_rounds: u32 = 0;
loop {
let installed = orchestrator.manifest_ids().await;
for (companion, err) in crate::container::companion::reconcile(&installed).await
{
let failures =
crate::container::companion::reconcile(&installed).await;
for (companion, err) in &failures {
tracing::warn!(
companion = %companion,
error = %err,
"companion reconcile failed"
);
}
time::sleep(interval).await;
// A failed repair can involve registry pulls and full
// image builds; retrying every 30s hammered unreachable
// registries ~174×/image/day on an offline node
// (archy-x250-dev log sweep, 2026-07-22). Back off
// exponentially while rounds keep failing — 30s doubling
// to a 1h cap — and reset the moment a round is clean.
failure_rounds = if failures.is_empty() {
0
} else {
failure_rounds.saturating_add(1)
};
let backoff = interval
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
.min(Duration::from_secs(3600));
time::sleep(backoff).await;
}
}))
} else {
@@ -3094,8 +3094,9 @@ impl ProdContainerOrchestrator {
.unwrap_or_else(|| "bitcoin-knots".to_string());
}
#[allow(unreachable_code)]
// Mirrors api::rpc::package::dependencies (the legacy install path);
// both Bitcoin node variants are reachable on archy-net by name.
// The known Bitcoin node containers, preferred in order. Any archy
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
// (or bare `bitcoin`), all reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
@@ -3105,12 +3106,23 @@ impl ProdContainerOrchestrator {
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
names
.lines()
.map(|l| l.trim())
.find(|name| BITCOIN_NAMES.contains(name))
.map(|name| name.to_string())
.unwrap_or_else(|| "bitcoin-knots".to_string())
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
// Prefer a known name in priority order…
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
return hit.to_string();
}
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
}
#[cfg(test)]
@@ -3247,10 +3259,10 @@ impl ProdContainerOrchestrator {
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
}
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
// derived_env (works on Knots/Core/any distro). The old hardcoded
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
// derived-env and broke Core nodes.
let provider = FileSecretsProvider {
root: self.secrets_dir.clone(),
+43 -4
View File
@@ -51,9 +51,25 @@ pub enum AccessControl {
PeersOnly,
Paid {
price_sats: u64,
/// Payment methods the sharer accepts: "lightning", "onchain",
/// "ecash", "fedimint". Empty = everything — which is also what
/// catalogs written before this field deserialize to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
accepted: Vec<String>,
},
}
/// Does the sharer accept this payment method for the item? Empty list =
/// all methods (pre-field catalogs and "no preference").
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
match access {
AccessControl::Paid { accepted, .. } => {
accepted.is_empty() || accepted.iter().any(|m| m == method)
}
_ => true,
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ContentCatalog {
pub items: Vec<ContentItem>,
@@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
}
/// Add a content item to the catalog.
///
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
/// every call, so id-only dedup let the same file be shared twice as two
/// separately-priced entries — and a buyer paid twice for one file
/// (2026-07-22). Same filename → update the existing entry in place and
/// keep its id, so existing buyers' owned records stay valid.
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
let mut catalog = load_catalog(data_dir).await?;
if catalog.items.iter().any(|i| i.id == item.id) {
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
}
catalog.items.push(item);
let norm = |f: &str| f.trim_start_matches('/').to_string();
if let Some(existing) = catalog
.items
.iter_mut()
.find(|i| norm(&i.filename) == norm(&item.filename))
{
let keep_id = existing.id.clone();
*existing = item;
existing.id = keep_id;
} else {
catalog.items.push(item);
}
save_catalog(data_dir, &catalog).await?;
Ok(catalog)
}
@@ -252,20 +285,26 @@ pub async fn serve_content(
// Check access control
match &item.access {
AccessControl::Paid { price_sats } => {
AccessControl::Paid { price_sats, .. } => {
// Two ways to satisfy payment:
// (a) a valid ecash token (the local-wallet fast path), or
// (b) a Lightning-invoice payment hash this node issued and has
// since confirmed settled (the "pay from any wallet" path, #46).
// Each path only counts when the sharer accepts that method.
let mut authorized = false;
if let Some(token) = payment_token {
if verify_payment_token(data_dir, token, *price_sats).await {
if (method_accepted(&item.access, "ecash")
|| method_accepted(&item.access, "fedimint"))
&& verify_payment_token(data_dir, token, *price_sats).await
{
authorized = true;
}
}
if !authorized {
if let Some(hash) = invoice_hash {
if crate::content_invoice::is_paid_for(hash, id).await {
if method_accepted(&item.access, "lightning")
&& crate::content_invoice::is_paid_for(hash, id).await
{
authorized = true;
}
}
+18 -3
View File
@@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
for (i, record) in containers.iter().enumerate() {
// Skip containers that are already up — `podman start` on a running
// container produces the noisy benign conmon "Failed to create
// container" + cgroup Permission-denied journal pair (fleet log
// sweep 2026-07-22).
if container_state(&record.name).await.as_deref() == Some("running") {
report.recovered += 1;
continue;
}
info!(
"Recovering container: {} (image: {})",
record.name, record.image
@@ -936,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
}
async fn start_existing_container(container: &str) -> bool {
info!("Recovering stack container: {}", container);
let timeout = match container {
"immich_server" | "netbird-server" => Duration::from_secs(120),
_ => Duration::from_secs(90),
};
if container_state(container).await.as_deref() == Some("initialized") {
cleanup_container_runtime_state(container).await;
match container_state(container).await.as_deref() {
// Already up — `podman start` on a running container makes conmon
// try to join the live cgroup and fail with a scary "Failed to
// create container: exit status 1" + cgroup.procs Permission denied
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
// these per day per node, all benign. Skip instead.
Some("running") => return true,
Some("initialized") => cleanup_container_runtime_state(container).await,
_ => {}
}
info!("Recovering stack container: {}", container);
match podman_output(&["start", container], timeout).await {
Ok(output) if output.status.success() => {
tokio::time::sleep(Duration::from_secs(3)).await;
+13
View File
@@ -386,6 +386,19 @@ async fn main() -> Result<()> {
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened());
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
// nodes (older ISOs shipped no audio stack; the router also heals the
// boot-time ELD race that leaves HDMI silently unavailable).
tokio::spawn(bootstrap::ensure_audio_stack());
// TV input: gamepad→keyboard bridge so controllers work inside every app
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
tokio::spawn(bootstrap::ensure_gamepad_keys());
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
// DHCP renumbering strands them — HA never re-resolves on its own.
tokio::spawn(api::rpc::wyoming_satellite_keeper());
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
+29 -11
View File
@@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
// ─── Response parsers ───────────────────────────────────────────────────
/// Decode a device/contact name from raw frame bytes, defensively.
///
/// Name fields sit at firmware-version-dependent offsets; when an offset
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
/// hand the UI replacement-character soup ("\u{618}…"). Strict rules: valid
/// UTF-8 up to the first NUL, no control characters, at least one
/// non-whitespace char — anything else gets the caller's readable fallback.
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
match std::str::from_utf8(&bytes[..end]) {
Ok(s) => {
let s = s.trim();
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
s.to_string()
} else {
fallback.to_string()
}
}
Err(_) => fallback.to_string(),
}
}
/// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
@@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Name follows after fixed fields — find it by scanning for printable ASCII
// Name follows after fixed fields. A firmware whose fixed-field layout
// differs would put binary here — decode defensively so the settings
// panel never shows byte soup.
let name_start = 4;
let name = if data.len() > name_start {
let name_end = data[name_start..]
.iter()
.position(|&b| b == 0)
.map(|p| name_start + p)
.unwrap_or(data.len());
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
} else {
String::new()
};
@@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
// name at data[99..131] (32 bytes)
let name_start = 99.min(data.len());
let name_end = (name_start + 32).min(data.len());
let short_id = format!("{}...", &public_key_hex[..8]);
let advert_name = if data.len() > name_start {
String::from_utf8_lossy(&data[name_start..name_end])
.trim_end_matches('\0')
.to_string()
decode_mesh_name(&data[name_start..name_end], &short_id)
} else {
format!("{}...", &public_key_hex[..8])
short_id
};
// last_advert at data[131..135]
+13
View File
@@ -581,6 +581,12 @@ pub struct DetectedDeviceInfo {
pub pid: Option<String>,
pub product: Option<String>,
pub manufacturer: Option<String>,
/// Unix epoch seconds of the /dev node's creation — udev recreates the
/// node on every plug, so this changes on each replug. The UI keys its
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
/// unplug/replug faster than a status poll) invalidates old dismissals
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
pub plugged_at: Option<u64>,
}
/// Like `detect_serial_devices`, but with USB metadata per port.
@@ -588,12 +594,19 @@ 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;
let plugged_at = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
out.push(DetectedDeviceInfo {
path,
vid: usb.0,
pid: usb.1,
product: usb.2,
manufacturer: usb.3,
plugged_at,
});
}
out
+4 -1
View File
@@ -406,7 +406,10 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
validate_onion(onion)?;
match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health")
.service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(30))
// 12s, not 30s: this is a liveness dot, not a data transfer. A Tor
// circuit that hasn't answered /health in 12s is "offline" for UI
// purposes; the old 30s made the Connected Nodes probes crawl.
.timeout(std::time::Duration::from_secs(12))
.send_get()
.await
{
+12 -2
View File
@@ -307,9 +307,19 @@ async fn send_handshake_message(
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
.tag(Tag::public_key(recipient_pk));
let _ = client.send_event_builder(builder).await;
// Surface real delivery failures. Before 2026-07-22 this was `let _ =`,
// so a request no relay accepted still reported ok:true to the UI and
// the sender believed it was delivered.
let send = client.send_event_builder(builder).await;
client.disconnect().await;
Ok(())
match send {
Ok(output) if !output.success.is_empty() => Ok(()),
Ok(output) => anyhow::bail!(
"no relay accepted the handshake event (tried {}, all failed)",
output.failed.len()
),
Err(e) => anyhow::bail!("failed to publish handshake event: {e}"),
}
}
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
+15
View File
@@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.current.fyi",
];
/// The union of the boot-config relay list and the user-managed (enabled)
/// relays — the set every nostr-facing operation should use, so senders and
/// receivers always overlap regardless of which list the operator edited.
pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec<String> {
let mut relays: Vec<String> = config_relays.to_vec();
if let Ok(store) = load_relays(data_dir).await {
for r in store.relays {
if r.enabled && !relays.contains(&r.url) {
relays.push(r.url);
}
}
}
relays
}
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
let path = data_dir.join(RELAYS_FILE);
if !path.exists() {
+27 -1
View File
@@ -106,6 +106,9 @@ impl Server {
// seconds of hitting the mempool (works whenever LND is up; retries
// forever otherwise). User req 2026-07-22.
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
// LND wedge watchdog — self-heal the silent "RPC up, server never
// ready" state instead of waiting for a human (100%-uptime req).
crate::api::rpc::lnd::spawn_lnd_health_watchdog();
// Retry Tor address in background — Tor may not be ready at startup
if data.server_info.tor_address.is_none() {
@@ -209,9 +212,15 @@ impl Server {
let did =
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
let version = data.server_info.version.clone();
let relays = config.nostr_relays.clone();
// Merged relay set (config + user-managed) — publish presence
// where handshake peers actually read (2026-07-22 unification).
let data_dir_for_relays = config.data_dir.clone();
let config_relays = config.nostr_relays.clone();
let tor_proxy = config.nostr_tor_proxy.clone();
tokio::spawn(async move {
let relays =
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
.await;
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
@@ -253,6 +262,23 @@ impl Server {
.await?,
);
// Background handshake poll: fetch inbound nostr peer requests every
// 5 minutes instead of only when a user presses the Federation Poll
// button (requests used to sit on relays unseen — 2026-07-22). The
// handler's own discoverability gate makes this a no-op until the
// user opts in.
{
let rpc = api_handler.rpc_handler().clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
rpc.background_handshake_poll().await;
}
});
}
// Initialize mesh networking service (if config has enabled: true)
{
let data_dir = config.data_dir.clone();