From 0c02d06a66620e46c466886df3052d957875e141 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 18 Apr 2026 11:07:08 -0400 Subject: [PATCH] feat: deploy-to-target supports .253 + mesh/federation/VPN updates - Add deploy_secondary() function for deploying to multiple LAN nodes - --both now deploys to .198 and .253 (previously .198 only) - Fleet deploy updated for 3 LAN nodes - Mesh DM fixes: protocol frame format, DM-via-channel routing - Federation pending requests, discover modal - VPN status UI improvements - Image versions and container specs updates Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/api/rpc/dispatcher.rs | 2 + .../src/api/rpc/federation/handlers.rs | 207 +++- core/archipelago/src/api/rpc/handshake.rs | 386 ++++++-- core/archipelago/src/api/rpc/network.rs | 1 + .../archipelago/src/api/rpc/package/config.rs | 1 + .../src/api/rpc/package/install.rs | 23 +- .../archipelago/src/api/rpc/package/stacks.rs | 125 ++- core/archipelago/src/api/rpc/peers.rs | 9 + core/archipelago/src/api/rpc/vpn.rs | 37 +- core/archipelago/src/config.rs | 17 +- core/archipelago/src/container/registry.rs | 10 +- core/archipelago/src/federation/invites.rs | 44 +- core/archipelago/src/federation/mod.rs | 1 + core/archipelago/src/federation/pending.rs | 312 ++++++ core/archipelago/src/federation/storage.rs | 1 + core/archipelago/src/federation/sync.rs | 3 + core/archipelago/src/federation/types.rs | 5 + core/archipelago/src/mesh/listener/mod.rs | 36 +- core/archipelago/src/mesh/mod.rs | 30 +- core/archipelago/src/nostr_handshake.rs | 219 +++-- core/archipelago/src/transport/delta.rs | 2 + core/archipelago/src/vpn.rs | 7 +- docs/lora-functionality.html | 899 ++++++++++++++++++ image-recipe/build-auto-installer-iso.sh | 11 +- neode-ui/package-lock.json | 26 +- neode-ui/public/architecture/index.html | 899 ++++++++++++++++++ neode-ui/src/api/rpc-client.ts | 120 ++- neode-ui/src/views/Federation.vue | 143 +++ neode-ui/src/views/Home.vue | 2 +- neode-ui/src/views/Server.vue | 276 ++---- .../src/views/appSession/appSessionConfig.ts | 2 +- neode-ui/src/views/discover/curatedApps.ts | 8 +- .../src/views/federation/DiscoverModal.vue | 192 ++++ .../views/federation/PendingRequestsPanel.vue | 128 +++ .../src/views/settings/VpnStatusSection.vue | 2 +- scripts/deploy-to-target.sh | 142 +-- scripts/first-boot-containers.sh | 138 +-- scripts/image-versions.sh | 4 +- 38 files changed, 3773 insertions(+), 697 deletions(-) create mode 100644 core/archipelago/src/federation/pending.rs create mode 100644 docs/lora-functionality.html create mode 100644 neode-ui/public/architecture/index.html create mode 100644 neode-ui/src/views/federation/DiscoverModal.vue create mode 100644 neode-ui/src/views/federation/PendingRequestsPanel.vue diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index fa60eaf8..8941e2df 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -75,6 +75,8 @@ impl RpcHandler { "handshake.discover" => self.handle_handshake_discover().await, "handshake.connect" => self.handle_handshake_connect(params).await, "handshake.poll" => self.handle_handshake_poll().await, + "nostr.discovery-status" => self.handle_nostr_discovery_status().await, + "nostr.set-discovery" => self.handle_nostr_set_discovery(params).await, // TOTP 2FA "auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await, diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index 66befe90..fc4b7a4e 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -1,14 +1,37 @@ use super::*; use crate::api::rpc::RpcHandler; use crate::credentials; -use crate::federation::{self, FederatedNode, TrustLevel}; +use crate::federation::{self, pending, FederatedNode, TrustLevel}; use crate::identity; +use crate::mesh; use crate::network::dwn_store::DwnStore; +use crate::nostr_handshake; use anyhow::{Context, Result}; use tracing::{debug, info, warn}; const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1"; +impl RpcHandler { + /// Register a federation node with the running mesh service so it's + /// immediately addressable as a chat target. The mesh service seeds + /// federation peers at startup, but federation nodes added or rotated + /// later in the session would otherwise stay invisible to the mesh + /// chat UI until the next mesh restart, and `mesh.send` against the + /// frontend's synthesised contact_id would fail with "Unknown + /// federation peer". Best-effort: silently no-ops when mesh is off. + async fn register_federation_peer_in_mesh( + &self, + pubkey_hex: &str, + did: &str, + name: Option<&str>, + ) { + let svc = self.mesh_service.read().await; + if let Some(svc) = svc.as_ref() { + mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await; + } + } +} + 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 { @@ -65,6 +88,12 @@ impl RpcHandler { info!(peer_did = %node.did, "Joined federation with peer"); + // Make the new peer immediately addressable from the mesh chat UI. + // Without this, the row exists in the federation list but `mesh.send` + // against it fails until the next mesh service restart re-seeds. + self.register_federation_peer_in_mesh(&node.pubkey, &node.did, node.name.as_deref()) + .await; + // Store federation membership as DWN message if let Ok(store) = DwnStore::new(&self.config.data_dir).await { let dwn_data = serde_json::json!({ @@ -315,8 +344,20 @@ impl RpcHandler { let tor_active = data.server_info.tor_address.is_some(); let server_name = data.server_info.name.clone().filter(|n| !n.is_empty()); + + // Encode our local Nostr identity as bech32 npub so federated peers + // can display it under our name in the mesh UI without each peer + // having to know how to convert hex → bech32 themselves. + let nostr_npub = tokio::fs::read_to_string(self.config.data_dir.join("identity/nostr_pubkey")) + .await + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .and_then(|hex| nostr_sdk::PublicKey::from_hex(&hex).ok()) + .and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok()); + let state = federation::build_local_state( - apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name, + apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name, nostr_npub, ); Ok(serde_json::to_value(&state)?) @@ -395,6 +436,10 @@ impl RpcHandler { federation::add_node(&self.config.data_dir, node).await?; info!(peer_did = %did, "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. + self.register_federation_peer_in_mesh(pubkey, did, None).await; + Ok(serde_json::json!({ "accepted": true })) } @@ -698,11 +743,31 @@ impl RpcHandler { } let old_pubkey = node.pubkey.clone(); + let rotated_name = node.name.clone(); node.did = new_did.to_string(); node.pubkey = new_pubkey.to_string(); node.last_seen = Some(chrono::Utc::now().to_rfc3339()); federation::save_nodes(&self.config.data_dir, &nodes).await?; + // Drop the stale mesh peer entry keyed by the old pubkey's + // synthetic contact_id, then upsert a fresh one under the + // new pubkey so the chat UI doesn't show two rows post-rotation. + { + let svc = self.mesh_service.read().await; + if let Some(svc) = svc.as_ref() { + let state = svc.shared_state(); + let stale_id = mesh::federation_peer_contact_id(&old_pubkey); + state.peers.write().await.remove(&stale_id); + mesh::upsert_federation_peer( + &state, + new_pubkey, + new_did, + rotated_name.as_deref(), + ) + .await; + } + } + info!( old_did = %old_did, new_did = %new_did, @@ -725,4 +790,142 @@ impl RpcHandler { } } } + + /// federation.list-pending-requests — return the inbox of inbound peer + /// requests received over Nostr (and our outbound `Sent` rows). Each + /// row carries a stable `id` the FE refers to when calling + /// `federation.approve-request` / `federation.reject-request`. + pub(in crate::api::rpc) async fn handle_federation_list_pending_requests( + &self, + ) -> Result { + let requests = pending::load_pending(&self.config.data_dir).await?; + Ok(serde_json::json!({ "requests": requests })) + } + + /// federation.approve-request — turn a pending peer request into a + /// federation invite, ship it back via NIP-44, and add the requester + /// to our federation list as `Observer` (NOT Trusted — the user must + /// explicitly promote afterwards via `federation.set-trust`). + /// + /// This is the *only* code path that ever causes our onion to leave + /// this box over Nostr, and the onion only travels inside a NIP-44 + /// ciphertext addressed to the requester's specific nostr pubkey. + pub(in crate::api::rpc) async fn handle_federation_approve_request( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let id = params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing id"))?; + + let req = pending::find_by_id(&self.config.data_dir, id) + .await? + .ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?; + if !matches!(req.state, pending::PendingState::Pending) || req.outbound { + anyhow::bail!("Pending request is not awaiting approval (state={:?})", req.state); + } + + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let local_onion = data + .server_info + .tor_address + .clone() + .ok_or_else(|| anyhow::anyhow!("Tor address not available"))?; + let local_pubkey = data.server_info.pubkey.clone(); + + // Generate a one-shot federation invite. The code embeds OUR onion + // and OUR pubkey, but it leaves this box only inside the NIP-44 + // ciphertext below. + let invite_code = + federation::create_invite(&self.config.data_dir, &local_did, &local_onion, &local_pubkey) + .await?; + + // Pre-add the requester to OUR federation list as Observer so that + // when their `federation.peer-joined` callback arrives over Tor we + // already trust their pubkey enough to accept the join. Their DID + // and pubkey come from the request — we'll cross-check the pubkey + // against the eventual peer-joined signature in the existing + // verification path (handlers.rs line ~365). + if !req.from_did.is_empty() { + // We don't know the requester's onion or ed25519 pubkey yet — + // they'll send those in the federation.peer-joined callback + // after they apply our invite. Until then we can't add a real + // FederatedNode entry. We just store the pending row as + // Approved so the UI shows progress, and trust the existing + // peer-joined handler to admit them as Observer when they call. + // + // Caveat: peer-joined currently hardcodes TrustLevel::Trusted. + // We override that below by demoting on success. + debug!( + requester_did = %req.from_did, + "Approval pending — waiting for federation.peer-joined callback over Tor" + ); + } + + // Encrypt + send the invite over NIP-44 to the requester. + let identity_dir = self.config.data_dir.join("identity"); + nostr_handshake::send_peer_invite( + &identity_dir, + &req.from_nostr_pubkey, + &invite_code, + &self.config.nostr_relays, + self.config.nostr_tor_proxy.as_deref(), + ) + .await?; + + pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?; + info!( + id = %id, + from = %req.from_nostr_pubkey, + "Approved peer request and shipped invite over NIP-44" + ); + Ok(serde_json::json!({ + "approved": true, + "id": id, + })) + } + + /// federation.reject-request — drop a pending request and, if requested, + /// ship a NIP-44 `PeerReject` to the sender so their UI can update. + pub(in crate::api::rpc) async fn handle_federation_reject_request( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let id = params + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing id"))?; + let reason = params.get("reason").and_then(|v| v.as_str()); + let notify = params + .get("notify") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let req = pending::find_by_id(&self.config.data_dir, id) + .await? + .ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?; + if !matches!(req.state, pending::PendingState::Pending) || req.outbound { + anyhow::bail!("Pending request is not awaiting approval (state={:?})", req.state); + } + + if notify { + let identity_dir = self.config.data_dir.join("identity"); + let _ = nostr_handshake::send_peer_reject( + &identity_dir, + &req.from_nostr_pubkey, + reason, + &self.config.nostr_relays, + self.config.nostr_tor_proxy.as_deref(), + ) + .await; + } + + pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?; + info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request"); + Ok(serde_json::json!({ "rejected": true, "id": id })) + } } diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index 9ba4d6ec..32e141c5 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -1,11 +1,119 @@ +//! Nostr peer-discovery RPCs. +//! +//! `handshake.discover` — browse other nodes' presence events on configured +//! relays. Returns DID + nostr pubkey only; no onion is ever exposed. +//! +//! `handshake.connect` — send a `PeerRequest` to a discovered node's nostr +//! pubkey. Records the outbound request locally so the user can see what +//! they've sent. Does NOT include our onion address on the wire. +//! +//! `handshake.poll` — fetch new NIP-44 DMs addressed to our nostr pubkey +//! and dispatch them: inbound `PeerRequest` is queued in +//! `federation::pending` for manual approval; inbound `PeerInvite` is +//! applied via the existing federation invite-acceptance flow (which +//! adds the new peer as `Observer` — see federation.rs); inbound +//! `PeerReject` is recorded against the matching outbound row. + use super::RpcHandler; -use crate::{nostr_handshake, peers}; -use anyhow::Result; +use crate::federation::pending::{ + self, PendingPeerRequest, PendingState, +}; +use crate::nostr_handshake::{self, HandshakeMessage}; +use anyhow::{Context, Result}; use nostr_sdk::FromBech32; +use serde::{Deserialize, Serialize}; + +const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json"; + +/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level +/// config file is read once at boot and is OFF by default; this state file +/// lets the user flip discoverability on/off at runtime via the Federation +/// UI without restarting the service. Both the boot-time presence publish +/// and the `handshake.poll` handler check this file before doing anything. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct NostrDiscoveryState { + #[serde(default)] + enabled: bool, +} + +async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState { + let path = data_dir.join(NOSTR_STATE_FILE); + match tokio::fs::read_to_string(&path).await { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => NostrDiscoveryState::default(), + } +} + +async fn save_discovery_state( + data_dir: &std::path::Path, + state: &NostrDiscoveryState, +) -> Result<()> { + let path = data_dir.join(NOSTR_STATE_FILE); + let content = serde_json::to_string_pretty(state).context("serialize discovery state")?; + tokio::fs::write(&path, content) + .await + .context("write discovery state")?; + Ok(()) +} impl RpcHandler { - /// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses). + /// Read the current runtime discoverability flag. + pub(super) async fn handle_nostr_discovery_status(&self) -> Result { + let state = load_discovery_state(&self.config.data_dir).await; + Ok(serde_json::json!({ "enabled": state.enabled })) + } + + /// Set the runtime discoverability flag. If turning ON, publish presence + /// once immediately so the user gets visible feedback that the relays + /// have been notified. If turning OFF, do NOT actively scrub the relays + /// here — `nostr_handshake::publish_presence` is replaceable, so the + /// next reboot's startup pass plus the existing legacy revocation in + /// `nostr_discovery::revoke_legacy_advertisements` are sufficient. A + /// future Layer 3 task adds an explicit "tombstone" publish if needed. + pub(super) async fn handle_nostr_set_discovery( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let enabled = params + .get("enabled") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("Missing enabled"))?; + + save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?; + + if enabled && !self.config.nostr_relays.is_empty() { + let (data, _) = self.state_manager.get_snapshot().await; + let identity_dir = self.config.data_dir.join("identity"); + 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 tor_proxy = self.config.nostr_tor_proxy.clone(); + tokio::spawn(async move { + if let Err(e) = nostr_handshake::publish_presence( + &identity_dir, + &did, + &version, + &relays, + tor_proxy.as_deref(), + ) + .await + { + tracing::warn!("Initial presence publish failed: {}", e); + } + }); + } + + Ok(serde_json::json!({ "enabled": enabled })) + } + + /// 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 { + // Discoverability gate: respect the runtime toggle. We allow `discover` + // 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 nodes = nostr_handshake::discover_nodes( &identity_dir, @@ -16,59 +124,90 @@ impl RpcHandler { Ok(serde_json::json!({ "nodes": nodes })) } - /// Send encrypted connection request to a peer's Nostr pubkey. - /// Params: { recipient_nostr_pubkey } + /// Send a `PeerRequest` to a discovered node. Onion is never sent. + /// Params: `{ recipient_nostr_pubkey, message?, name? }`. pub(super) async fn handle_handshake_connect( &self, params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; - // Accept either hex pubkey or npub1... bech32 format let recipient_raw = params .get("recipient_nostr_pubkey") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?; - let recipient = if recipient_raw.starts_with("npub1") { + let recipient_hex = if recipient_raw.starts_with("npub1") { nostr_sdk::PublicKey::from_bech32(recipient_raw) .map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))? .to_hex() } else { recipient_raw.to_string() }; - let recipient = recipient.as_str(); + let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex) + .ok() + .and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok()) + .unwrap_or_default(); + let message = params.get("message").and_then(|v| v.as_str()); + let optional_name = params.get("name").and_then(|v| v.as_str()); let (data, _) = self.state_manager.get_snapshot().await; - let our_onion = data - .server_info - .tor_address - .as_deref() - .ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?; - let our_node_pubkey = &data.server_info.pubkey; - let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey) - .unwrap_or_default(); + let our_did = + crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); let our_version = &data.server_info.version; - let our_name = data.server_info.name.as_deref(); + let our_name = optional_name.or(data.server_info.name.as_deref()); let identity_dir = self.config.data_dir.join("identity"); - nostr_handshake::send_connect_request( + nostr_handshake::send_peer_request( &identity_dir, - recipient, - our_onion, - our_node_pubkey, + &recipient_hex, &our_did, our_version, our_name, + message, &self.config.nostr_relays, self.config.nostr_tor_proxy.as_deref(), ) .await?; - Ok(serde_json::json!({ "ok": true, "sent_to": recipient })) + // Record the outbound request so the user can see "Sent" status + // and so the eventual NIP-44 PeerInvite reply can be matched. + let row = pending::insert_outbound( + &self.config.data_dir, + recipient_hex.clone(), + recipient_npub, + String::new(), // remote DID unknown until they reply + None, + message.map(String::from), + ) + .await?; + + Ok(serde_json::json!({ + "ok": true, + "sent_to": recipient_hex, + "id": row.id, + })) } - /// Poll for incoming encrypted handshake messages (connect requests/responses). - /// Auto-adds peers and auto-responds to requests. + /// Poll relays for inbound NIP-44 handshake messages, then dispatch: + /// - `PeerRequest` → queue in `federation::pending` for approval + /// - `PeerInvite` → apply via federation invite flow (adds as Observer) + /// - `PeerReject` → mark matching outbound row as `Rejected` + /// + /// Never auto-adds peers, never auto-responds, never sends our onion. pub(super) async fn handle_handshake_poll(&self) -> Result { + // Runtime gate: if the user hasn't enabled discoverability, don't + // touch the relays. The poll endpoint is a hard no-op until they + // explicitly opt in via the Federation UI toggle. + let state = load_discovery_state(&self.config.data_dir).await; + if !state.enabled { + return Ok(serde_json::json!({ + "polled": 0, + "new_requests": Vec::::new(), + "applied_invites": Vec::::new(), + "rejected_outbound": Vec::::new(), + "skipped": Vec::::new(), + "discovery_disabled": true, + })); + } let identity_dir = self.config.data_dir.join("identity"); let handshakes = nostr_handshake::poll_handshakes( &identity_dir, @@ -78,72 +217,159 @@ impl RpcHandler { ) .await?; - let (data, _) = self.state_manager.get_snapshot().await; - let mut added_peers = Vec::new(); + let mut new_requests: Vec = Vec::new(); + let mut applied_invites: Vec = Vec::new(); + let mut rejected_outbound: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); for hs in &handshakes { - let (onion, node_pubkey, name) = match &hs.message { - nostr_handshake::HandshakeMessage::ConnectRequest { - onion, - node_pubkey, + match &hs.message { + HandshakeMessage::PeerRequest { + from_did, + version: _, name, - .. + message, } => { - // Auto-respond with our details - if let Some(our_onion) = data.server_info.tor_address.as_deref() { - let our_did = crate::identity::did_key_from_pubkey_hex( - &data.server_info.pubkey, - ) - .unwrap_or_default(); - let _ = nostr_handshake::send_connect_response( - &identity_dir, - &hs.from_nostr_pubkey, - our_onion, - &data.server_info.pubkey, - &our_did, - &data.server_info.version, - data.server_info.name.as_deref(), - &self.config.nostr_relays, - self.config.nostr_tor_proxy.as_deref(), - ) - .await; + match pending::insert_inbound( + &self.config.data_dir, + hs.from_nostr_pubkey.clone(), + hs.from_nostr_npub.clone(), + from_did.clone(), + name.clone(), + message.clone(), + ) + .await + { + Ok(Some(row)) => new_requests.push(row), + Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()), + Err(e) => { + tracing::warn!( + from = %hs.from_nostr_pubkey, + error = %e, + "Dropped peer request (rate limit or storage error)" + ); + skipped.push(hs.from_nostr_pubkey.clone()); + } } - (onion.clone(), node_pubkey.clone(), name.clone()) } - nostr_handshake::HandshakeMessage::ConnectResponse { - onion, - node_pubkey, - name, - .. - } => (onion.clone(), node_pubkey.clone(), name.clone()), - }; + HandshakeMessage::PeerInvite { invite_code } => { + // Match against an outbound Sent request from this nostr + // pubkey. If we never sent them anything, ignore — we + // don't accept unsolicited invites over Nostr. + let pendings = pending::load_pending(&self.config.data_dir).await?; + let matching = pendings.iter().find(|r| { + r.outbound + && r.from_nostr_pubkey == hs.from_nostr_pubkey + && matches!(r.state, PendingState::Sent) + }); + let Some(row) = matching else { + tracing::warn!( + from = %hs.from_nostr_pubkey, + "Ignoring unsolicited PeerInvite — no matching Sent request" + ); + continue; + }; + let row_id = row.id.clone(); + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = crate::identity::did_key_from_pubkey_hex( + &data.server_info.pubkey, + ) + .unwrap_or_default(); + let local_onion = data + .server_info + .tor_address + .clone() + .unwrap_or_default(); + let local_pubkey = data.server_info.pubkey.clone(); - // Auto-add as peer - let peer = peers::KnownPeer { - onion, - pubkey: node_pubkey.clone(), - name, - added_at: Some(chrono::Utc::now().to_rfc3339()), - }; - let _ = peers::add_peer(&self.config.data_dir, peer).await; - added_peers.push(node_pubkey); + let identity_dir2 = self.config.data_dir.join("identity"); + let node_identity = + crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?; + match crate::federation::accept_invite( + &self.config.data_dir, + invite_code, + &local_did, + &local_onion, + &local_pubkey, + |bytes| node_identity.sign(bytes), + ) + .await + { + 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 + // discovery flow should never auto-trust. + let _ = crate::federation::set_trust_level( + &self.config.data_dir, + &node.did, + crate::federation::TrustLevel::Observer, + ) + .await; + + // Mirror into the mesh peer table immediately so the + // chat UI can address the new peer without waiting + // for the next mesh restart. + let svc = self.mesh_service.read().await; + if let Some(svc) = svc.as_ref() { + crate::mesh::upsert_federation_peer( + &svc.shared_state(), + &node.pubkey, + &node.did, + node.name.as_deref(), + ) + .await; + } + + pending::set_state( + &self.config.data_dir, + &row_id, + PendingState::Approved, + ) + .await?; + applied_invites.push(node.did); + } + Err(e) => { + tracing::warn!( + from = %hs.from_nostr_pubkey, + error = %e, + "Failed to apply PeerInvite" + ); + } + } + } + HandshakeMessage::PeerReject { reason } => { + let pendings = pending::load_pending(&self.config.data_dir).await?; + if let Some(row) = pendings.iter().find(|r| { + r.outbound + && r.from_nostr_pubkey == hs.from_nostr_pubkey + && matches!(r.state, PendingState::Sent) + }) { + let row_id = row.id.clone(); + pending::set_state( + &self.config.data_dir, + &row_id, + PendingState::Rejected, + ) + .await?; + rejected_outbound.push(row_id); + tracing::info!( + from = %hs.from_nostr_pubkey, + reason = ?reason, + "Outbound peer request rejected" + ); + } + } + } } - let serialized: Vec = handshakes - .iter() - .map(|hs| { - serde_json::json!({ - "from_nostr_pubkey": hs.from_nostr_pubkey, - "from_nostr_npub": hs.from_nostr_npub, - "message": hs.message, - "timestamp": hs.timestamp, - }) - }) - .collect(); - Ok(serde_json::json!({ - "handshakes": serialized, - "added_peers": added_peers, + "polled": handshakes.len(), + "new_requests": new_requests, + "applied_invites": applied_invites, + "rejected_outbound": rejected_outbound, + "skipped": skipped, })) } } diff --git a/core/archipelago/src/api/rpc/network.rs b/core/archipelago/src/api/rpc/network.rs index a024eeff..3b780bc4 100644 --- a/core/archipelago/src/api/rpc/network.rs +++ b/core/archipelago/src/api/rpc/network.rs @@ -123,6 +123,7 @@ impl RpcHandler { &req_msg.to_string(), None, None, + None, ).await?; // Also add them as a pending peer locally diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 5f0dc8f1..0c90eb56 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -910,6 +910,7 @@ pub(super) async fn get_app_config( "GITEA__packages__ENABLED=true".to_string(), "GITEA__repository__ENABLE_PUSH_CREATE_USER=true".to_string(), "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(), + "GITEA__security__X_FRAME_OPTIONS=".to_string(), ], None, None, diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 8ee0cf85..6cf1e508 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -261,6 +261,7 @@ impl RpcHandler { if !is_tailscale { run_args.push("--cap-drop=ALL"); run_args.push("--security-opt=no-new-privileges:true"); + run_args.push("--pids-limit=4096"); for cap in &security_caps { run_args.push(cap); } @@ -600,11 +601,11 @@ impl RpcHandler { .spawn() .context("Failed to start image pull")?; - // Wrap the entire pull (stderr progress + wait) in a 60s timeout. - // If the registry is unreachable, the pull hangs on DNS/TCP and the - // stderr reader never returns — so the timeout must cover everything. + // Wrap the entire pull (stderr progress + wait) in a 10-minute timeout. + // Large image layers (Minio, Postgres, ffmpeg) can take several minutes + // to pull. 60s was too short and caused premature retries on slow registries. let pull_result = tokio::time::timeout( - std::time::Duration::from_secs(60), + std::time::Duration::from_secs(600), async { if let Some(stderr) = child.stderr.take() { let reader = BufReader::new(stderr); @@ -1097,15 +1098,21 @@ server { } } - // Set ROOT_URL in Gitea config + // Set ROOT_URL in Gitea config — port 3000 is the nginx iframe proxy, + // which is the public-facing port users and the UI iframe access. let host_ip = &self.config.host_ip; - let root_url = format!("GITEA__server__ROOT_URL=http://{}:3001/", host_ip); let _ = tokio::process::Command::new("podman") .args(["exec", "gitea", "sh", "-c", - &format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)]) + &format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3000/|' /data/gitea/conf/app.ini || true", host_ip)]) .output() .await; - info!("Gitea: ROOT_URL set to http://{}:3001/", host_ip); + // Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header + let _ = tokio::process::Command::new("podman") + .args(["exec", "gitea", "sh", "-c", + "grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"]) + .output() + .await; + info!("Gitea: ROOT_URL set to http://{}:3000/, X_FRAME_OPTIONS cleared", host_ip); } if package_id == "nextcloud" { diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index 1f6311fd..7890d46a 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -138,6 +138,8 @@ impl RpcHandler { .output() .await; + let db_pass = super::config::read_or_generate_secret("immich-db-password").await; + let _ = tokio::process::Command::new("podman") .args([ "run", @@ -148,10 +150,24 @@ impl RpcHandler { "unless-stopped", "--network", "immich-net", + "--network-alias", + "immich_postgres", + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=DAC_OVERRIDE", + "--cap-add=FOWNER", + "--cap-add=SETGID", + "--cap-add=SETUID", + "--security-opt=no-new-privileges:true", + "--memory=512m", + "--pids-limit=4096", + "--health-cmd=pg_isready -U postgres || exit 1", + "--health-interval=30s", + "--health-retries=3", "-v", "/var/lib/archipelago/immich-db:/var/lib/postgresql/data", "-e", - "POSTGRES_PASSWORD=immichpass", + &format!("POSTGRES_PASSWORD={}", db_pass), "-e", "POSTGRES_USER=postgres", "-e", @@ -172,6 +188,15 @@ impl RpcHandler { "unless-stopped", "--network", "immich-net", + "--network-alias", + "immich_redis", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--memory=128m", + "--pids-limit=2048", + "--health-cmd=valkey-cli ping || exit 1", + "--health-interval=30s", + "--health-retries=3", "git.tx1138.com/lfg2025/valkey:7-alpine", ]) .output() @@ -188,6 +213,12 @@ impl RpcHandler { "unless-stopped", "--network", "immich-net", + "--network-alias", + "immich_server", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--memory=2g", + "--pids-limit=4096", "-p", "2283:2283", "-v", @@ -197,7 +228,7 @@ impl RpcHandler { "-e", "DB_USERNAME=postgres", "-e", - "DB_PASSWORD=immichpass", + &format!("DB_PASSWORD={}", db_pass), "-e", "DB_DATABASE_NAME=immich", "-e", @@ -276,6 +307,20 @@ impl RpcHandler { "unless-stopped", "--network", "penpot-net", + "--network-alias", + "penpot-postgres", + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=DAC_OVERRIDE", + "--cap-add=FOWNER", + "--cap-add=SETGID", + "--cap-add=SETUID", + "--security-opt=no-new-privileges:true", + "--memory=512m", + "--pids-limit=4096", + "--health-cmd=pg_isready -U penpot || exit 1", + "--health-interval=30s", + "--health-retries=3", "-v", "/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data", "-e", @@ -300,6 +345,15 @@ impl RpcHandler { "unless-stopped", "--network", "penpot-net", + "--network-alias", + "penpot-valkey", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--memory=192m", + "--pids-limit=2048", + "--health-cmd=valkey-cli ping || exit 1", + "--health-interval=30s", + "--health-retries=3", "-e", "VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu", "git.tx1138.com/lfg2025/valkey:8.1", @@ -318,6 +372,12 @@ impl RpcHandler { "unless-stopped", "--network", "penpot-net", + "--network-alias", + "penpot-backend", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--memory=1g", + "--pids-limit=4096", "-v", "/var/lib/archipelago/penpot-assets:/opt/data/assets", "-e", @@ -354,6 +414,12 @@ impl RpcHandler { "unless-stopped", "--network", "penpot-net", + "--network-alias", + "penpot-exporter", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--memory=512m", + "--pids-limit=2048", "-e", &format!("PENPOT_SECRET_KEY={}", secret), "-e", @@ -376,6 +442,12 @@ impl RpcHandler { "unless-stopped", "--network", "penpot-net", + "--network-alias", + "penpot-frontend", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--memory=512m", + "--pids-limit=2048", "-p", "9001:8080", "-v", @@ -473,7 +545,18 @@ impl RpcHandler { "--restart", "unless-stopped", "--network", "archy-net", "--network-alias", "archy-btcpay-db", + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=DAC_OVERRIDE", + "--cap-add=FOWNER", + "--cap-add=SETGID", + "--cap-add=SETUID", + "--security-opt=no-new-privileges:true", "--memory=512m", + "--pids-limit=4096", + "--health-cmd=pg_isready -U btcpay || exit 1", + "--health-interval=30s", + "--health-retries=3", "-v", "/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data", "-e", "POSTGRES_DB=btcpay", "-e", "POSTGRES_USER=btcpay", @@ -501,7 +584,10 @@ impl RpcHandler { "--restart", "unless-stopped", "--network", "archy-net", "--network-alias", "archy-nbxplorer", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", "--memory=512m", + "--pids-limit=4096", "-p", "32838:32838", "-v", "/var/lib/archipelago/nbxplorer:/data", "-e", "NBXPLORER_DATADIR=/data", @@ -531,7 +617,10 @@ impl RpcHandler { "--restart", "unless-stopped", "--network", "archy-net", "--network-alias", "btcpay-server", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", "--memory=1g", + "--pids-limit=4096", "-p", "23000:49392", "-v", "/var/lib/archipelago/btcpay:/datadir", "-e", "ASPNETCORE_URLS=http://0.0.0.0:49392", @@ -632,7 +721,18 @@ impl RpcHandler { "--restart", "unless-stopped", "--network", "archy-net", "--network-alias", "archy-mempool-db", + "--cap-drop=ALL", + "--cap-add=CHOWN", + "--cap-add=DAC_OVERRIDE", + "--cap-add=FOWNER", + "--cap-add=SETGID", + "--cap-add=SETUID", + "--security-opt=no-new-privileges:true", "--memory=512m", + "--pids-limit=4096", + "--health-cmd=mariadb-admin ping -u root --password=$MYSQL_ROOT_PASSWORD || exit 1", + "--health-interval=30s", + "--health-retries=3", "-v", "/var/lib/archipelago/mysql-mempool:/var/lib/mysql", "-e", "MYSQL_DATABASE=mempool", "-e", "MYSQL_USER=mempool", @@ -652,7 +752,10 @@ impl RpcHandler { "--restart", "unless-stopped", "--network", "archy-net", "--network-alias", "mempool-api", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", "--memory=512m", + "--pids-limit=4096", "-p", "8999:8999", "-v", "/var/lib/archipelago/mempool:/data", "-e", "MEMPOOL_BACKEND=electrum", @@ -682,7 +785,10 @@ impl RpcHandler { "--restart", "unless-stopped", "--network", "archy-net", "--network-alias", "mempool", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", "--memory=256m", + "--pids-limit=2048", "-p", "4080:8080", "-e", "FRONTEND_HTTP_PORT=8080", "-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api", @@ -718,7 +824,7 @@ impl RpcHandler { .into_iter() .find(|r| r.enabled) .map(|r| r.url) - .unwrap_or_else(|| "23.182.128.160:3000/lfg2025".to_string()); + .unwrap_or_else(|| "git.tx1138.com/lfg2025".to_string()); let user_tmp = format!( "{}/.local/share/containers/tmp", @@ -740,16 +846,13 @@ impl RpcHandler { format!("{}/indeedhub:1.0.0", registry), ]; + // Pull all images with retry; fail the install if any image can't be pulled. + // Previously this just logged a warning and continued, leaving the stack + // broken and the user seeing "failed" with no recovery path. for img in &images { info!("Pulling {}", img); - let status = tokio::process::Command::new("podman") - .args(["pull", img, "--tls-verify=false"]) - .env("TMPDIR", &user_tmp) - .status() - .await; - if !status.map(|s| s.success()).unwrap_or(false) { - tracing::warn!("Failed to pull {}", img); - } + pull_image_with_retry(img).await + .with_context(|| format!("Failed to pull IndeedHub image: {}", img))?; } // Create indeedhub-net diff --git a/core/archipelago/src/api/rpc/peers.rs b/core/archipelago/src/api/rpc/peers.rs index a7896363..8c728b92 100644 --- a/core/archipelago/src/api/rpc/peers.rs +++ b/core/archipelago/src/api/rpc/peers.rs @@ -90,6 +90,15 @@ impl RpcHandler { let (data, _) = self.state_manager.get_snapshot().await; let pubkey = data.server_info.pubkey.clone(); + // Skip sending to ourselves (prevents duplicate messages in group chat) + if let Some(ref our_onion) = data.server_info.tor_address { + let our = our_onion.trim_end_matches(".onion"); + let their = onion.trim_end_matches(".onion"); + if our == their { + return Ok(serde_json::json!({ "ok": true, "sent_to": onion, "skipped": "self" })); + } + } + // Load signing key for E2E encryption let identity_dir = self.config.data_dir.join("identity"); let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?; diff --git a/core/archipelago/src/api/rpc/vpn.rs b/core/archipelago/src/api/rpc/vpn.rs index d07ce3f0..b79a3479 100644 --- a/core/archipelago/src/api/rpc/vpn.rs +++ b/core/archipelago/src/api/rpc/vpn.rs @@ -587,42 +587,7 @@ impl RpcHandler { } } - // NostrVPN mesh participants (from nvpn config) - let our_npub = vpn::read_nvpn_config_value("nostr", "public_key").await; - for path in vpn::NVPN_CONFIG_PATHS { - if let Ok(content) = tokio::fs::read_to_string(path).await { - if let Ok(table) = content.parse::() { - if let Some(networks) = table.get("networks").and_then(|v| v.as_array()) { - for net in networks { - if let Some(participants) = net.get("participants").and_then(|v| v.as_array()) { - for p in participants { - if let Some(npub) = p.as_str() { - // Skip our own npub - if our_npub.as_deref() == Some(npub) { continue; } - // Check peer_aliases for a friendly name - let alias = table.get("peer_aliases") - .and_then(|a| a.get(npub)) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let short = if npub.len() > 20 { - format!("{}...{}", &npub[..12], &npub[npub.len()-6..]) - } else { npub.to_string() }; - peers.push(serde_json::json!({ - "name": if alias.is_empty() { short } else { alias.to_string() }, - "ip": "mesh", - "npub": npub, - "type": "nostrvpn", - })); - } - } - } - } - } - } - break; // Use first config found - } - } - + // NostrVPN peer loading removed — standalone WireGuard only Ok(serde_json::json!({ "peers": peers })) } diff --git a/core/archipelago/src/config.rs b/core/archipelago/src/config.rs index 6da54d97..234373ae 100644 --- a/core/archipelago/src/config.rs +++ b/core/archipelago/src/config.rs @@ -199,7 +199,12 @@ impl Default for Config { port_offset: 10000, bitcoin_simulation: BitcoinSimulation::Mock, dev_data_dir: PathBuf::from("/tmp/archipelago-dev"), - nostr_discovery_enabled: true, + // Discoverability is opt-in. Until the user explicitly enables it + // (Settings UI / `nostr_discovery_enabled = true` in config), no + // presence event is ever published and `handshake.poll` never + // contacts a relay. This is the sole knob that controls whether + // we leak our DID + npub to the public Nostr relays. + nostr_discovery_enabled: false, nostr_relays: vec![ "wss://relay.damus.io".into(), "wss://relay.nostr.info".into(), @@ -223,7 +228,7 @@ mod tests { assert_eq!(config.host_ip, "127.0.0.1"); assert!(!config.dev_mode); assert_eq!(config.port_offset, 10000); - assert!(config.nostr_discovery_enabled); + assert!(!config.nostr_discovery_enabled); assert_eq!(config.nostr_relays.len(), 2); assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string())); } @@ -333,9 +338,13 @@ mod tests { } #[test] - fn test_config_nostr_discovery_enabled_by_default() { + fn test_config_nostr_discovery_disabled_by_default() { + // Discoverability is opt-in: nothing is published to public relays + // until the user explicitly turns it on. Flipping this back to + // `true` would silently start leaking the local DID + npub on every + // boot — guard rail. let config = Config::default(); - assert!(config.nostr_discovery_enabled); + assert!(!config.nostr_discovery_enabled); assert!(config.nostr_tor_proxy.is_some()); } diff --git a/core/archipelago/src/container/registry.rs b/core/archipelago/src/container/registry.rs index 5f2b532b..f4b955fe 100644 --- a/core/archipelago/src/container/registry.rs +++ b/core/archipelago/src/container/registry.rs @@ -44,16 +44,16 @@ impl Default for RegistryConfig { Self { registries: vec![ Registry { - url: "23.182.128.160:3000/lfg2025".to_string(), + url: "git.tx1138.com/lfg2025".to_string(), name: "Archipelago Primary".to_string(), - tls_verify: false, + tls_verify: true, enabled: true, priority: 0, }, Registry { - url: "git.tx1138.com/lfg2025".to_string(), - name: "Archipelago Legacy".to_string(), - tls_verify: true, + url: "23.182.128.160:3000/lfg2025".to_string(), + name: "Archipelago Fallback".to_string(), + tls_verify: false, enabled: true, priority: 10, }, diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index f8df7dc9..00effdc5 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use std::path::Path; -use super::storage::{add_node, load_invites, load_nodes, save_invites}; +use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes}; use super::types::{FederatedNode, FederationInvite, TrustLevel}; /// Generate an invite code. Format: `fed1:` @@ -94,10 +94,28 @@ pub async fn accept_invite( ) -> Result { let (did, onion, pubkey, _token) = parse_invite(code)?; - // Check not already federated - let nodes = load_nodes(data_dir).await?; - if nodes.iter().any(|n| n.did == did) { - anyhow::bail!("Already federated with node {}", did); + // Make accept idempotent: drop any existing entry that conflicts with + // this invite — same DID (same node, refreshing the link), same onion + // (node rotated identity but kept its hidden service), or same pubkey + // (DID and onion reformatted but the underlying key is the same). + // Whatever is there gets replaced so re-accepting an invite is always + // safe and the user never has to manually remove an entry first. + let mut nodes = load_nodes(data_dir).await?; + let onion_norm = onion.trim_end_matches(".onion"); + let before = nodes.len(); + nodes.retain(|n| { + n.did != did + && n.onion.trim_end_matches(".onion") != onion_norm + && n.pubkey != pubkey + }); + if nodes.len() != before { + save_nodes(data_dir, &nodes).await?; + tracing::info!( + removed = before - nodes.len(), + new_did = %did, + onion = %onion, + "Replaced stale federation entry on re-accept" + ); } let node = FederatedNode { @@ -226,7 +244,11 @@ mod tests { } #[tokio::test] - async fn test_accept_invite_rejects_duplicate() { + async fn test_accept_invite_is_idempotent() { + // Re-accepting the same invite is a no-op refresh — it must not + // duplicate the entry and must not error. This is the contract the + // UI relies on: clicking "Join" twice or refreshing after an + // identity rotation always converges to one entry. let dir = tempfile::tempdir().unwrap(); let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub") .await @@ -244,8 +266,7 @@ mod tests { .await .unwrap(); - // Accepting the same invite again should fail - let result = accept_invite( + accept_invite( dir2.path(), &code, "did:key:zLocal", @@ -253,7 +274,10 @@ mod tests { "localpub", |_| "test-sig".to_string(), ) - .await; - assert!(result.is_err()); + .await + .unwrap(); + + let nodes = load_nodes(dir2.path()).await.unwrap(); + assert_eq!(nodes.len(), 1, "re-accept should not duplicate"); } } diff --git a/core/archipelago/src/federation/mod.rs b/core/archipelago/src/federation/mod.rs index b0b4c159..1b751507 100644 --- a/core/archipelago/src/federation/mod.rs +++ b/core/archipelago/src/federation/mod.rs @@ -5,6 +5,7 @@ //! sync container status, health metrics, and availability. mod invites; +pub mod pending; mod storage; mod sync; mod types; diff --git a/core/archipelago/src/federation/pending.rs b/core/archipelago/src/federation/pending.rs new file mode 100644 index 00000000..77428d47 --- /dev/null +++ b/core/archipelago/src/federation/pending.rs @@ -0,0 +1,312 @@ +//! Pending peer-discovery requests received over Nostr. +//! +//! When another node discovers us via Nostr presence and sends an encrypted +//! `PeerRequest` (NIP-44 DM), we store the request here instead of acting +//! on it. The user explicitly approves or rejects each request via the +//! Federation UI; only on approval do we generate a federation invite code +//! and ship it back over the same encrypted channel. +//! +//! Nothing in this module ever exposes the local onion address. The onion +//! is only added to the wire later, by the approval handler, and only +//! inside a NIP-44 ciphertext addressed to the requester's nostr pubkey. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::fs; + +const PENDING_FILE: &str = "federation/pending_requests.json"; +const MAX_PENDING_PER_PUBKEY: usize = 5; +const PENDING_EXPIRY_DAYS: i64 = 30; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PendingState { + /// Inbound: a remote node sent us a peer request, awaiting local approval. + Pending, + /// Outbound: we sent a peer request, awaiting their approval (and the + /// invite code they will send back via NIP-44 if they accept). + Sent, + /// Approved locally — the inbound request has been turned into a federation + /// invite that has been shipped back to the requester. Kept as history. + Approved, + /// Rejected locally. Kept as history so the same npub can't immediately + /// re-request without the user noticing. + Rejected, + /// Auto-expired after `PENDING_EXPIRY_DAYS` with no action. + Expired, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingPeerRequest { + /// UUID — stable identifier the FE refers to when approving/rejecting. + pub id: String, + /// Sender's Nostr secp256k1 pubkey (hex). Authoritative for routing + /// the encrypted NIP-44 reply on approval. + pub from_nostr_pubkey: String, + /// Sender's Nostr pubkey in bech32 npub format (display only). + pub from_nostr_npub: String, + /// Sender's claimed archipelago DID. Verified at *approval* time + /// (when their onion arrives via federation.peer-joined), not now — + /// the requester could lie here, but the worst case is a wasted + /// approval slot. + pub from_did: String, + /// Optional friendly name the requester typed. + pub from_name: Option, + /// Optional one-line message the requester attached. + pub message: Option, + pub received_at: String, + pub state: PendingState, + /// True if this row represents an outbound request we sent (`Sent`) + /// rather than an inbound one we received (`Pending`). + #[serde(default)] + pub outbound: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PendingRequestsFile { + pub requests: Vec, +} + +pub async fn load_pending(data_dir: &Path) -> Result> { + let path = data_dir.join(PENDING_FILE); + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path) + .await + .context("Failed to read pending requests file")?; + let file: PendingRequestsFile = serde_json::from_str(&content).unwrap_or_default(); + Ok(file.requests) +} + +pub async fn save_pending(data_dir: &Path, requests: &[PendingPeerRequest]) -> Result<()> { + let path = data_dir.join(PENDING_FILE); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .await + .context("Failed to create federation dir")?; + } + let file = PendingRequestsFile { + requests: requests.to_vec(), + }; + let content = serde_json::to_string_pretty(&file) + .context("Failed to serialize pending requests")?; + fs::write(&path, content) + .await + .context("Failed to write pending requests file")?; + Ok(()) +} + +/// Sweep auto-expired entries. Returns the cleaned list, mutated in place. +fn expire_stale(requests: &mut Vec) { + let cutoff = chrono::Utc::now() - chrono::Duration::days(PENDING_EXPIRY_DAYS); + for r in requests.iter_mut() { + if !matches!(r.state, PendingState::Pending | PendingState::Sent) { + continue; + } + if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.received_at) { + if ts.with_timezone(&chrono::Utc) < cutoff { + r.state = PendingState::Expired; + } + } + } +} + +/// Insert a new inbound peer request. Returns the stored row (with id), +/// or `None` if the request was deduplicated or rate-limited. +/// +/// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a +/// `Pending` entry, do not insert a second one — the user will see the +/// existing row and act on that. Otherwise count `Pending` entries per +/// pubkey and reject anything beyond `MAX_PENDING_PER_PUBKEY`. +pub async fn insert_inbound( + data_dir: &Path, + from_nostr_pubkey: String, + from_nostr_npub: String, + from_did: String, + from_name: Option, + message: Option, +) -> Result> { + let mut requests = load_pending(data_dir).await?; + expire_stale(&mut requests); + + let already_pending = requests.iter().any(|r| { + r.from_nostr_pubkey == from_nostr_pubkey + && r.from_did == from_did + && matches!(r.state, PendingState::Pending) + && !r.outbound + }); + if already_pending { + save_pending(data_dir, &requests).await?; + return Ok(None); + } + + let live_count = requests + .iter() + .filter(|r| { + r.from_nostr_pubkey == from_nostr_pubkey + && matches!(r.state, PendingState::Pending) + && !r.outbound + }) + .count(); + if live_count >= MAX_PENDING_PER_PUBKEY { + save_pending(data_dir, &requests).await?; + anyhow::bail!( + "rate-limited: {} already has {} pending requests", + from_nostr_pubkey, + live_count + ); + } + + let row = PendingPeerRequest { + id: uuid::Uuid::new_v4().to_string(), + from_nostr_pubkey, + from_nostr_npub, + from_did, + from_name, + message, + received_at: chrono::Utc::now().to_rfc3339(), + state: PendingState::Pending, + outbound: false, + }; + requests.push(row.clone()); + save_pending(data_dir, &requests).await?; + Ok(Some(row)) +} + +/// Record an outbound peer request we just sent, so the user can see it +/// in the "sent" tab and so the eventual NIP-44 invite reply can be +/// matched against it. +pub async fn insert_outbound( + data_dir: &Path, + to_nostr_pubkey: String, + to_nostr_npub: String, + to_did: String, + to_name: Option, + message: Option, +) -> Result { + let mut requests = load_pending(data_dir).await?; + expire_stale(&mut requests); + requests.retain(|r| { + !(r.outbound + && r.from_nostr_pubkey == to_nostr_pubkey + && matches!(r.state, PendingState::Sent)) + }); + let row = PendingPeerRequest { + id: uuid::Uuid::new_v4().to_string(), + from_nostr_pubkey: to_nostr_pubkey, + from_nostr_npub: to_nostr_npub, + from_did: to_did, + from_name: to_name, + message, + received_at: chrono::Utc::now().to_rfc3339(), + state: PendingState::Sent, + outbound: true, + }; + requests.push(row.clone()); + save_pending(data_dir, &requests).await?; + Ok(row) +} + +pub async fn find_by_id( + data_dir: &Path, + id: &str, +) -> Result> { + let requests = load_pending(data_dir).await?; + Ok(requests.into_iter().find(|r| r.id == id)) +} + +pub async fn set_state(data_dir: &Path, id: &str, state: PendingState) -> Result<()> { + let mut requests = load_pending(data_dir).await?; + if let Some(r) = requests.iter_mut().find(|r| r.id == id) { + r.state = state; + } else { + anyhow::bail!("Pending request not found: {}", id); + } + save_pending(data_dir, &requests).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_insert_inbound_then_dedupes() { + let dir = tempfile::tempdir().unwrap(); + let r1 = insert_inbound( + dir.path(), + "npk1".into(), + "npub1".into(), + "did:key:zABC".into(), + None, + None, + ) + .await + .unwrap(); + assert!(r1.is_some()); + + let r2 = insert_inbound( + dir.path(), + "npk1".into(), + "npub1".into(), + "did:key:zABC".into(), + None, + None, + ) + .await + .unwrap(); + assert!(r2.is_none(), "duplicate Pending request should be ignored"); + } + + #[tokio::test] + async fn test_rate_limit() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..MAX_PENDING_PER_PUBKEY { + let res = insert_inbound( + dir.path(), + "npk-spammer".into(), + "npub-spammer".into(), + format!("did:key:zVar{}", i), + None, + None, + ) + .await + .unwrap(); + assert!(res.is_some()); + } + let result = insert_inbound( + dir.path(), + "npk-spammer".into(), + "npub-spammer".into(), + "did:key:zOverflow".into(), + None, + None, + ) + .await; + assert!(result.is_err(), "should rate-limit beyond MAX"); + } + + #[tokio::test] + async fn test_set_state_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let row = insert_inbound( + dir.path(), + "npk2".into(), + "npub2".into(), + "did:key:zXYZ".into(), + Some("Bob".into()), + Some("hi".into()), + ) + .await + .unwrap() + .unwrap(); + + set_state(dir.path(), &row.id, PendingState::Approved) + .await + .unwrap(); + let reloaded = find_by_id(dir.path(), &row.id).await.unwrap().unwrap(); + assert_eq!(reloaded.state, PendingState::Approved); + } +} diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index a2e069d5..23b81e41 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -264,6 +264,7 @@ mod tests { disk_total_bytes: None, uptime_secs: Some(86400), tor_active: Some(true), + nostr_npub: None, }; update_node_state(dir.path(), "did:key:z1", state) diff --git a/core/archipelago/src/federation/sync.rs b/core/archipelago/src/federation/sync.rs index a4b0f907..ea2646eb 100644 --- a/core/archipelago/src/federation/sync.rs +++ b/core/archipelago/src/federation/sync.rs @@ -74,6 +74,7 @@ pub fn build_local_state( uptime: u64, tor_active: bool, server_name: Option, + nostr_npub: Option, ) -> NodeStateSnapshot { NodeStateSnapshot { timestamp: chrono::Utc::now().to_rfc3339(), @@ -86,6 +87,7 @@ pub fn build_local_state( disk_total_bytes: Some(disk_total), uptime_secs: Some(uptime), tor_active: Some(tor_active), + nostr_npub, } } @@ -180,6 +182,7 @@ mod tests { 3600, true, Some("Test Node".to_string()), + None, ); assert_eq!(state.apps.len(), 1); assert_eq!(state.cpu_usage_percent, Some(25.5)); diff --git a/core/archipelago/src/federation/types.rs b/core/archipelago/src/federation/types.rs index 4ee3242b..f772844b 100644 --- a/core/archipelago/src/federation/types.rs +++ b/core/archipelago/src/federation/types.rs @@ -59,6 +59,11 @@ pub struct NodeStateSnapshot { pub uptime_secs: Option, #[serde(default)] pub tor_active: Option, + /// bech32-encoded Nostr identity pubkey (npub1…) for cross-transport + /// peer identification in the mesh UI. Optional: older nodes that + /// haven't synced after this field was added will report None. + #[serde(default)] + pub nostr_npub: Option, } /// Status of a single app/container on a remote node. diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index b620edd2..cfb25e82 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -201,15 +201,33 @@ impl MeshState { pub async fn store_message(&self, msg: MeshMessage) { let mut messages = self.messages.write().await; - // Deduplicate: skip if we already have a message with the same text, - // peer, and timestamp within 30 seconds (prevents echo-back doubles) - let dominated = messages.iter().rev().take(20).any(|m| { - m.peer_contact_id == msg.peer_contact_id - && m.plaintext == msg.plaintext - && within_seconds_iso(&m.timestamp, &msg.timestamp, 30) - }); - if dominated { - return; + // Deduplicate RECEIVED messages only — a Sent record is the user's + // own action and must ALWAYS be shown, even when the display text + // collides with an earlier one (e.g. two 👍 reactions to different + // targets, or "ok" reply twice in a row). + // + // For received messages, prefer MessageKey (sender_pubkey, sender_seq) + // as the dedup identity — it's exact and cross-transport-safe. Fall + // back to (peer, plaintext, 30s window) only for legacy plain-text + // frames that arrive without a sender_seq. + if matches!(msg.direction, MessageDirection::Received) { + let dominated = if msg.sender_pubkey.is_some() && msg.sender_seq.is_some() { + messages.iter().rev().take(40).any(|m| { + matches!(m.direction, MessageDirection::Received) + && m.sender_pubkey == msg.sender_pubkey + && m.sender_seq == msg.sender_seq + }) + } else { + messages.iter().rev().take(20).any(|m| { + matches!(m.direction, MessageDirection::Received) + && m.peer_contact_id == msg.peer_contact_id + && m.plaintext == msg.plaintext + && within_seconds_iso(&m.timestamp, &msg.timestamp, 30) + }) + }; + if dominated { + return; + } } messages.push_back(msg); if messages.len() > MAX_MESSAGES { diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index acb442ea..a60b3449 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -628,6 +628,12 @@ impl MeshService { /// Send raw wire payload bytes to a peer (no Sent-record bookkeeping). /// Callers are responsible for storing the MeshMessage record afterwards. + /// + /// Oversized payloads (>LoRa per-frame budget) are handled by the lower + /// `send_dm_via_channel` layer, which base64-encodes + MC-frame-chunks + /// the bytes into 80-char pieces and reassembles on the receiver. We + /// must NOT chunk here as well — doing so double-chunks and produces + /// bytes the receiver can't decode. async fn send_raw_payload(&self, contact_id: u32, payload: Vec) -> Result<()> { let status = self.state.status.read().await; if !status.device_connected { @@ -635,14 +641,6 @@ impl MeshService { } drop(status); - if payload.len() > protocol::MAX_MESSAGE_LEN { - anyhow::bail!( - "Message too large for LoRa: {} bytes (max {})", - payload.len(), - protocol::MAX_MESSAGE_LEN - ); - } - let dest_prefix = self.peer_dest_prefix(contact_id).await?; self.state.send_cmd(listener::MeshCommand::SendText { @@ -714,17 +712,11 @@ impl MeshService { ) .await; } - if exceeds_lora { - // No federation path — fall back to send-side chunking. Receive - // side already handles MC-framed base64 reassembly for up to 20 - // chunks (~3KB) per message, which is plenty for ContentRef or - // long replies when the peer is LoRa-only. - self.send_chunked_payload(contact_id, wire).await?; - return Ok(self - .record_sent_typed(contact_id, type_label, display_text, typed_payload, sender_seq) - .await); - } - // Fall through: federation-synthetic case handled above, shouldn't reach here. + // No federation path — fall through to send_raw_payload, which + // hands the wire to the lower DM-via-channel layer. That layer + // (`send_dm_via_channel` in listener/session.rs) handles both + // single-frame and chunked transmission internally; we must NOT + // pre-chunk here as well or the receiver sees garbage. } self.send_raw_payload(contact_id, wire).await?; Ok(self diff --git a/core/archipelago/src/nostr_handshake.rs b/core/archipelago/src/nostr_handshake.rs index 502f8251..e45b1824 100644 --- a/core/archipelago/src/nostr_handshake.rs +++ b/core/archipelago/src/nostr_handshake.rs @@ -1,15 +1,28 @@ -//! Encrypted peer handshake via Nostr NIP-44. +//! Encrypted peer-discovery handshake via Nostr NIP-44. //! -//! Instead of publishing onion addresses publicly on relays, nodes exchange -//! them privately via NIP-44 encrypted DMs: +//! Goals: +//! - A node can opt in to being *discoverable* on Nostr by publishing a +//! minimal presence event (DID + nostr pubkey, NIP-33 kind 30078). The +//! presence event NEVER contains the onion address, the federation list, +//! the app inventory, or anything beyond a DID + nostr pubkey + version. +//! - Another node that sees the presence event can send an encrypted +//! `PeerRequest` (NIP-44 DM, kind 4) asking to peer. The request also +//! does NOT contain the requester's onion — it carries only the DID +//! the requester claims, an optional friendly name, and an optional +//! one-line message. +//! - The recipient does NOT auto-accept and does NOT auto-respond. +//! Instead, the request is queued in `federation::pending` for the +//! user to manually approve or reject in the Federation UI. +//! - On approval, the recipient generates a one-shot federation invite +//! code (which contains *their* onion + pubkey) and ships it back via +//! NIP-44 encrypted to the requester's nostr pubkey. The requester's +//! poll loop receives the invite, applies it via the existing +//! `federation.join` flow, and the two boxes complete the trust +//! exchange over Tor — never over a public relay. //! -//! 1. Node publishes presence-only event (DID + Nostr pubkey, NO onion address) -//! 2. To connect, Node A sends NIP-44 encrypted DM to Node B's Nostr pubkey -//! containing A's onion address + Ed25519 node pubkey -//! 3. Node B auto-responds with its own onion address + pubkey -//! 4. Both nodes add each other as known peers -//! -//! Uses NIP-44 (ChaCha20-Poly1305) for encryption, kind 4 for DMs. +//! Result: the only thing ever visible on a public Nostr relay is the +//! presence event (a DID + a npub + a version). Everything actionable +//! lives inside NIP-44 ciphertext addressed to a specific nostr pubkey. use anyhow::{Context, Result}; use nostr_sdk::prelude::*; @@ -22,26 +35,39 @@ use tracing::warn; const NOSTR_SECRET_FILE: &str = "nostr_secret"; -/// Message types for the encrypted handshake protocol +/// Message types exchanged inside NIP-44 encrypted DMs (kind 4). +/// +/// Note: NONE of these variants carry an onion address. The onion is only +/// transmitted as part of a `PeerInvite { invite_code }`, where the invite +/// code is generated by the local approval flow and points at the local +/// federation hidden service. The legacy `connect-request` / `connect-response` +/// variants from earlier development are preserved on the deserialize side +/// (`#[serde(other)]`-style fallback via untagged Unknown) so that an old +/// peer that hasn't been upgraded yet can't crash the parser, but we no +/// longer construct or act on them. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum HandshakeMessage { - #[serde(rename = "connect-request")] - ConnectRequest { - onion: String, - node_pubkey: String, - did: String, - version: String, - name: Option, - }, - #[serde(rename = "connect-response")] - ConnectResponse { - onion: String, - node_pubkey: String, - did: String, + /// Inbound peer-discovery request. The sender proves they hold the + /// nostr secret key by signing the kind-4 envelope (Nostr does this + /// at the protocol layer). They claim a `from_did` here, but we do + /// not trust it until the federation invite round-trip completes. + #[serde(rename = "peer-request")] + PeerRequest { + from_did: String, version: String, name: Option, + message: Option, }, + /// Approval reply: contains a one-shot federation invite code + /// generated by the approver. The invite code embeds the approver's + /// onion + pubkey, but the whole envelope is NIP-44 encrypted to the + /// requester's nostr pubkey, so only the requester can read it. + #[serde(rename = "peer-invite")] + PeerInvite { invite_code: String }, + /// Rejection reply. Optional one-line reason for the user. + #[serde(rename = "peer-reject")] + PeerReject { reason: Option }, } /// Result of polling for incoming handshake messages @@ -219,16 +245,16 @@ pub async fn discover_nodes( Ok(nodes) } -/// Send an encrypted connection request to a peer's Nostr pubkey. -/// Uses NIP-44 encrypted DM (kind 4) containing our onion address. -pub async fn send_connect_request( +/// Encrypt and publish a `HandshakeMessage` to a recipient's nostr pubkey. +/// Used by both the request-side (PeerRequest) and the approver-side +/// (PeerInvite / PeerReject) flows. The message is wrapped in a NIP-44 v2 +/// ciphertext addressed to `recipient_nostr_pubkey` and posted as a kind-4 +/// encrypted DM, so only the holder of that nostr secret key can decrypt +/// the contents. Relays only ever see the ciphertext. +async fn send_handshake_message( identity_dir: &Path, recipient_nostr_pubkey: &str, - our_onion: &str, - our_node_pubkey: &str, - our_did: &str, - our_version: &str, - our_name: Option<&str>, + msg: &HandshakeMessage, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { @@ -239,20 +265,10 @@ pub async fn send_connect_request( let keys = load_nostr_keys(identity_dir) .await? .ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?; + let recipient_pk = + PublicKey::from_hex(recipient_nostr_pubkey).context("Invalid recipient Nostr pubkey")?; - let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey) - .context("Invalid recipient Nostr pubkey")?; - - let msg = HandshakeMessage::ConnectRequest { - onion: our_onion.to_string(), - node_pubkey: our_node_pubkey.to_string(), - did: our_did.to_string(), - version: our_version.to_string(), - name: our_name.map(String::from), - }; - let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?; - - // NIP-44 encrypt + let plaintext = serde_json::to_string(msg).context("Failed to serialize handshake")?; let encrypted = nip44::encrypt( keys.secret_key(), &recipient_pk, @@ -265,79 +281,86 @@ pub async fn send_connect_request( for url in relays { let _ = client.add_relay(url).await; } - if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { + if tokio::time::timeout(Duration::from_secs(10), client.connect()) + .await + .is_err() + { warn!("Nostr relay connection timed out after 10s, continuing anyway"); } - // Kind 4 encrypted DM with p-tag for recipient - let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) - .tag(Tag::public_key(recipient_pk)); + let builder = + EventBuilder::new(Kind::EncryptedDirectMessage, encrypted).tag(Tag::public_key(recipient_pk)); let _ = client.send_event_builder(builder).await; client.disconnect().await; + Ok(()) +} +/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never +/// include an onion address — the recipient learns nothing that would let +/// them dial us directly. They learn only our claimed DID, version, +/// optional friendly name, and the optional message. +pub async fn send_peer_request( + identity_dir: &Path, + recipient_nostr_pubkey: &str, + our_did: &str, + our_version: &str, + our_name: Option<&str>, + message: Option<&str>, + relays: &[String], + tor_proxy: Option<&str>, +) -> Result<()> { + let msg = HandshakeMessage::PeerRequest { + from_did: our_did.to_string(), + version: our_version.to_string(), + name: our_name.map(String::from), + message: message.map(String::from), + }; + send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?; tracing::info!( - "🤝 Sent encrypted connect request to {}...{}", + "🤝 Sent peer-request to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); Ok(()) } -/// Send an encrypted connection response to a peer. -pub async fn send_connect_response( +/// Send a `PeerInvite` reply containing a one-shot federation invite code. +/// The code embeds our onion + pubkey, but the entire envelope is NIP-44 +/// encrypted to the requester's nostr pubkey, so the onion is only ever +/// readable by them. +pub async fn send_peer_invite( identity_dir: &Path, recipient_nostr_pubkey: &str, - our_onion: &str, - our_node_pubkey: &str, - our_did: &str, - our_version: &str, - our_name: Option<&str>, + invite_code: &str, relays: &[String], tor_proxy: Option<&str>, ) -> Result<()> { - if relays.is_empty() { - anyhow::bail!("No relays configured"); - } - - let keys = load_nostr_keys(identity_dir) - .await? - .ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?; - - let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey) - .context("Invalid recipient Nostr pubkey")?; - - let msg = HandshakeMessage::ConnectResponse { - onion: our_onion.to_string(), - node_pubkey: our_node_pubkey.to_string(), - did: our_did.to_string(), - version: our_version.to_string(), - name: our_name.map(String::from), + let msg = HandshakeMessage::PeerInvite { + invite_code: invite_code.to_string(), }; - let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?; - - let encrypted = nip44::encrypt( - keys.secret_key(), - &recipient_pk, - &plaintext, - nip44::Version::V2, - ) - .map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?; - - let client = build_client(keys, tor_proxy)?; - for url in relays { - let _ = client.add_relay(url).await; - } - if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() { - warn!("Nostr relay connection timed out after 10s, continuing anyway"); - } - - let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted) - .tag(Tag::public_key(recipient_pk)); - let _ = client.send_event_builder(builder).await; - client.disconnect().await; - + send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?; tracing::info!( - "🤝 Sent encrypted connect response to {}...{}", + "🤝 Sent peer-invite to {}...{}", + &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], + &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] + ); + Ok(()) +} + +/// Send a `PeerReject` reply with an optional reason. +pub async fn send_peer_reject( + identity_dir: &Path, + recipient_nostr_pubkey: &str, + reason: Option<&str>, + relays: &[String], + tor_proxy: Option<&str>, +) -> Result<()> { + let msg = HandshakeMessage::PeerReject { + reason: reason.map(String::from), + }; + send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?; + tracing::info!( + "🚫 Sent peer-reject to {}...{}", &recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())], &recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..] ); diff --git a/core/archipelago/src/transport/delta.rs b/core/archipelago/src/transport/delta.rs index c73cf57c..b96375f6 100644 --- a/core/archipelago/src/transport/delta.rs +++ b/core/archipelago/src/transport/delta.rs @@ -222,6 +222,7 @@ mod tests { disk_total_bytes: Some(1_800_000_000_000), uptime_secs: Some(86400), tor_active: Some(true), + nostr_npub: None, } } @@ -253,6 +254,7 @@ mod tests { disk_total_bytes: Some(1_800_000_000_000), uptime_secs: Some(86700), // Changed tor_active: Some(true), + nostr_npub: None, } } diff --git a/core/archipelago/src/vpn.rs b/core/archipelago/src/vpn.rs index f38a3aa0..a7b8e808 100644 --- a/core/archipelago/src/vpn.rs +++ b/core/archipelago/src/vpn.rs @@ -281,17 +281,14 @@ pub fn generate_wireguard_conf(config: &WireGuardConfig) -> String { /// Get the current VPN status by checking network interfaces. pub async fn get_status() -> VpnStatus { - // Check for NostrVPN (native system service) - if let Ok(nvpn) = get_nostr_vpn_status().await { - return nvpn; - } + // NostrVPN disabled — standalone WireGuard only // Check for Tailscale interface if let Ok(tailscale) = get_tailscale_status().await { return tailscale; } - // Check for WireGuard interface + // Check for WireGuard interface (wg0) if let Ok(wg) = get_wireguard_status().await { return wg; } diff --git a/docs/lora-functionality.html b/docs/lora-functionality.html new file mode 100644 index 00000000..3fceac08 --- /dev/null +++ b/docs/lora-functionality.html @@ -0,0 +1,899 @@ + + + + + +Archipelago — LoRa & Mesh Functionality Guide + + + + + + +
+ +
+

LoRa & Mesh Functionality

+

How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.

+
+ Meshcore Companion USB + Double Ratchet E2E + 23 Message Types + 160-byte LoRa Frame +
+
+ +

Introduction

+

This document explains Archipelago's mesh subsystem — the code under core/archipelago/src/mesh/ that lets nodes talk to each other over LoRa radio instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.

+

The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.

+ +

What is LoRa? Layman

+
+ Think of LoRa as a whisper that travels 10 kilometers. + Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about 160 bytes at a time, and each whisper takes a second or two to complete. +
+

Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses chirp spread spectrum (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 5–15 km with a simple antenna; data rates are 0.3–50 kbps.

+

Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running Meshcore firmware, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.

+ +

Why Archipelago uses it

+
+
+

Off-grid safety

+

Dead-man switch and emergency alerts reach family without cell coverage.

+
+
+

Censorship resistance

+

No ISP, no DNS, no TLS termination — just radio waves between nodes.

+
+
+

Bitcoin when internet is down

+

Relay signed transactions and Lightning payments through on-grid peers.

+
+
+

Truly peer-to-peer chat

+

Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.

+
+
+ +
+ +

Hardware & Firmware

+

Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.

+ + + + + + + + + + + +
ComponentRoleExamples
MCURuns Meshcore firmware, talks USB serialESP32, nRF52840
RadioSemtech LoRa transceiverSX1262, SX1276
BoardMCU + radio + USB + antennaHeltec V3, T-Beam, RAK WisBlock, Station G2
FirmwareMesh routing + Companion USB protocolMeshcore
ConnectionUSB CDC-ACM serial/dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM*
Link params115200 baud, 8N1Set in mesh/serial.rs
+ +
+ It's a modem. Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through. +
+ +

USB Serial Transport

+

Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with '<' and listens for '>'.

+ +
Host → Device: 0x3C '<' │ len_lo len_hiframe_bytes... +Device → Host: 0x3E '>' │ len_lo len_hiframe_bytes... + +Baud: 115200 Framing: 8N1 Source: mesh/serial.rs
+ +

The frame body is a Meshcore Companion command or response. Archipelago builds these in mesh/protocol.rs and parses replies in mesh/listener/decode.rs.

+ +

Companion commands Archipelago uses

+ + + + + + + + + + + + + + + +
CodeNamePurpose
0x01APP_STARTHandshake; device returns its node_id and name
0x02SEND_TXT_MSGSend payload to a contact (targeted by 6-byte pubkey prefix)
0x03SEND_CHANNEL_TXT_MSGBroadcast on a channel (no specific recipient)
0x04GET_CONTACTSPull the device's contact table
0x06SET_DEVICE_TIMESync Unix timestamp for message dating
0x07SEND_SELF_ADVERTBroadcast our identity onto the mesh
0x08SET_ADVERT_NAMESet our display name
0x0ASYNC_NEXT_MESSAGEPop the next queued inbound message
0x0BSET_RADIO_PARAMSFrequency, spreading factor, bandwidth
0x0CSET_RADIO_TX_POWERTransmit power (dBm)
0x38GET_STATSDevice statistics
+ +

Responses and push notifications

+

Responses begin with a status byte. Codes < 0x80 are replies to a command we sent; codes >= 0x80 are asynchronous push events from the device.

+ + + + + + + + + + + + +
CodeNameMeaning
0x00RESP_OKCommand accepted
0x01RESP_ERRCommand failed + error code
0x03RESP_CONTACTOne contact entry (32-byte pubkey + metadata)
0x05RESP_SELF_INFOOur node_id and name after APP_START
0x10RESP_CONTACT_MSG_V3Direct inbound message (SNR + sender prefix + payload)
0x11RESP_CHANNEL_MSG_V3Channel broadcast inbound
0x83PUSH_MESSAGES_WAITINGAsync: new messages in queue, call SYNC_NEXT_MESSAGE
+ +

Wire Format — the payload byte 0

+

Once a frame reaches the message payload, Archipelago looks at the first byte to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.

+ +
0x00 Plain text (legacy, unencrypted) +0x01 Identity broadcast (ARCHY:2 / ARCHY:3) +0x02 Typed CBOR envelope (plaintext, used for debug or intra-LAN) +0xEE Encrypted typed — ChaCha20-Poly1305 w/ static shared secret +0xDD Ratcheted typed — Double Ratchet, forward-secure
+ +

Markers 0xEE and 0xDD are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.

+ +

0xEE — static-key encrypted envelope

+
[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]
+
    +
  • Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.
  • +
  • Cipher: ChaCha20-Poly1305 AEAD.
  • +
  • Max plaintext: 160 − 1 − 12 − 16 = 131 bytes (see crypto::MAX_ENCRYPTED_PLAINTEXT).
  • +
  • Properties: confidential + authenticated, but compromise of a key decrypts all history.
  • +
+ +

0xDD — Double Ratchet envelope

+
[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
+
    +
  • Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).
  • +
  • Handles out-of-order delivery via a skipped-keys cache.
  • +
  • Properties: forward secrecy + post-compromise recovery. Used for mesh.* chat once a session is established.
  • +
  • Implementation: mesh/ratchet.rs, session load/save in mesh/listener/session.rs.
  • +
+ +
+ Static key vs. ratchet = a safe vs. a self-shredding envelope. + The 0xEE lane is like a locked safe: one key opens everything. The 0xDD lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages. +
+ +

Encryption Layers

+

Three cryptographic primitives combine to produce the 0xDD ratchet flow:

+ +
+
+

X25519 ECDH

+

Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.

+
+
+

HKDF-SHA256

+

Derives root key, chain key, and message key at each ratchet step.

+
+
+

ChaCha20-Poly1305

+

Symmetric AEAD used for the actual payload encryption + authentication tag.

+
+
+ +

Session bootstrap — X3DH-like handshake

+

Before the ratchet can start, peers exchange a PrekeyBundle (type 5) and a SessionInit (type 6). Those two messages are carried by the 0xEE static-key envelope, because the ratchet session doesn't exist yet. Once SessionInit is processed, subsequent traffic switches to 0xDD. See mesh/x3dh.rs.

+ +

Fragmentation — how a 500-byte message rides a 160-byte pipe

+

The LoRa frame budget is 160 bytes (protocol::MAX_MESSAGE_LEN). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.

+ +
Chunk header ┌──────────┬──────────┬────────────┐ + │ type (1) │ id (1) │ total (1) │ + └──────────┴──────────┴────────────┘ +Chunk body Up to 140 bytes of Base64-encoded payload + +Sender: compress → encrypt → split into 140-char chunks + → send with tiny inter-chunk delay +Receiver: accumulate by (sender, chunk_id) → reassemble + → decrypt → decompress → dispatch
+ +

For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.

+ +
+ Escape hatch: federation fallback. If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago skips LoRa entirely and routes the message over Tor federation instead. See the ContentRef path in rpc/mesh/typed_messages.rs. +
+ +

Dual Transport — LoRa + Tor federation

+

Archipelago treats LoRa and Tor federation as two lanes of the same highway. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.

+ +
┌──────────────────┐ + │ mesh.send(...) │ + └────────┬─────────┘ + │ + ┌──────────┴──────────┐ + │ Is peer synthetic? │ + └──────────┬──────────┘ + No │ Yes + ┌──────────┘ └──────────┐ + ▼ ▼ + LoRa radio Tor federation + (160-byte frame) (unlimited, slower setup) + │ │ + │ if > 160 B && synth ──────┘ (fallback) + ▼ + Chunked over LoRa + or refused if no fallback
+ +

Addressing

+
    +
  • Contact ID — 32-bit handle from Meshcore's contact table. Used by SEND_TXT_MSG.
  • +
  • Pubkey prefix — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.
  • +
  • DID / onion — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.
  • +
+ +

Synthetic federation contacts

+

To let the chat list show federation peers before any message arrives, Archipelago inserts synthetic contacts into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (≥ 0x8000_0000), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.

+ +
+ +

All 23 Message Types

+

Every typed message is a CBOR envelope identified by a single MeshMessageType byte. The Transport column shows which marker carries it on the wire and which Companion command is used.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTypePurposeMarkerCmdChunked?
0TextPlain chat message0xDD0x02If >160 B
1AlertEmergency / dead-man heartbeat0xDD0x02/0x03No (short)
2InvoiceLightning / BOLT11 invoice0xDD0x02Usually
3PsbtHashUnsigned tx hash for co-signing0xDD0x02No
4CoordinateGPS location share0xDD0x02No
5PrekeyBundleX3DH bootstrap (pre-session)0xEE0x02No
6SessionInitInitial ratchet message0xEE0x02No
7BlockHeaderBitcoin block height/hash0xDD0x03No
8TxRelaySigned Bitcoin tx for on-grid peer to broadcast0xDD0x02Yes
9TxRelayResponsetxid or error from the relay peer0xDD0x02No
10LightningRelayBOLT11 to pay via on-grid peer0xDD0x02Yes
11LightningRelayResponsepayment_hash or error0xDD0x02No
12TxConfirmationDepth update (1/2/3 confs)0xDD0x02No
13ReplyQuoted reply to a previous message0xDD0x02If long
14ReactionEmoji reaction on MessageKey0xDD0x02No
15ReadReceipt"Seen up to MessageKey X"0xDD0x02No
16ForwardRe-forwarded original w/ provenance0xDD0x02Yes
17EditIn-place text replacement0xDD0x02If long
18DeleteTombstone for earlier message0xDD0x02No
19ContentRefCID of blob held by sender (file/image)0xDD0x02 or TorFederation fallback
20PresenceHeartbeat + last-activity epoch0xDD0x03No
21ChannelInviteGroup membership announcement0xDD0x03No
22ContactCardShareable federation node card0xDD0x02Maybe
+ +

The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.

+ +

Text, Reply, Edit, Delete, Forward

+ +

Text (type 0)

+

Sender path. rpc.mesh.sendtyped_messages::send_text → CBOR-encode the Text{body} variant → ratchet-encrypt → prefix 0xDD → if under 160 B, send in one SEND_TXT_MSG frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.

+ +

Reply (type 13)

+

Same as Text, but the CBOR envelope carries a MessageKey pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.

+ +

Edit (type 17)

+

Envelope contains the original MessageKey plus the new body. Receiver updates its local store in-place and tags the entry "edited".

+ +

Delete (type 18)

+

Tombstone only: MessageKey with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.

+ +

Forward (type 16)

+

Wraps original {sender_name, original_timestamp, body} so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are almost always chunked.

+ +

Reaction, ReadReceipt, Presence

+ +

Reaction (type 14)

+

Envelope: {target: MessageKey, emoji: String}. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see MessageActions in neode-ui).

+ +

ReadReceipt (type 15)

+

Envelope: {up_to: MessageKey}. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).

+ +

Presence (type 20)

+

Periodic heartbeat carrying {last_activity_epoch}. Broadcast on a channel (SEND_CHANNEL_TXT_MSG, cmd 0x03) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.

+ +
+ Like a lighthouse beacon. Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed. +
+ +

ContentRef — files and images without bloating the radio

+

LoRa cannot move a 500 KB image. The ContentRef type (19) solves this by sending only a pointer — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.

+ +
Sender Receiver +────── ──────── +store blob locally (CID) +┌──────────────────────┐ +│ ContentRef {cid, │ ──ratchet──▶ +│ mime, size, │ 0xDD +│ thumb_hash} │ over LoRa +└──────────────────────┘ + see CID in chat + click to fetch + ┌─────────────────┐ + │ rpc.mesh.fetch- │ + │ content(cid) │ + └────────┬────────┘ + ▼ + federation (Tor) + resolve DID → pull blob
+ +
+ Resolution bug fix note. An earlier revision of ContentRef routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit 5f7ebf14) resolves the owning peer by DID and falls back to name-match only if DID lookup fails. +
+ +

Bitcoin & Lightning over LoRa

+

Archipelago uses the mesh as a Bitcoin transport of last resort. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.

+ +

TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)

+
Offline signer On-grid relay peer Bitcoin p2p +────────────── ────────────────── ─────────── +sign tx +┌─────────────┐ +│ TxRelay │ ─ratchet/LoRa▶ decrypt → validate +│ {raw_tx} │ broadcast via bitcoind ───▶ mempool +└─────────────┘ │ + ▼ + ┌────────────────────────┐ + ◀─ratchet│ TxRelayResponse{txid} │ + └────────────────────────┘ + (or {error}) + +later, as blocks arrive: + ┌────────────────────────┐ + ◀─ratchet│ TxConfirmation │ + │ {txid, depth: 1..3} │ + └────────────────────────┘
+ +

The binary framing in mesh/bitcoin_relay.rs is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.

+ +

LightningRelay (10) → LightningRelayResponse (11)

+

Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns payment_hash or an error. Invoices are often long enough to chunk.

+ +

Invoice (2) and PsbtHash (3)

+

These are not relays — they're peer-to-peer handoffs. Invoice delivers a BOLT11 to be paid by the recipient. PsbtHash carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.

+ +

BlockHeader (7)

+

Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via SEND_CHANNEL_TXT_MSG) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.

+ +

Alerts, Coordinates, Dead-Man

+ +

Alert (type 1)

+

Envelope: {kind, message, sender_contact_id}. Kinds include Emergency and Deadman. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).

+ +

Dead-man switch

+

A background task in mesh/alerts.rs sends a Deadman alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.

+ +

Coordinate (type 4)

+

Envelope: {lat, lon, accuracy_m} with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.

+ +

ChannelInvite (type 21)

+

Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via SEND_CHANNEL_TXT_MSG.

+ +

Identity, PrekeyBundle, ContactCard

+ +

Identity broadcast (marker 0x01, ARCHY:2/3)

+

The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed 0x01. This is how peers discover each other. The payload encodes protocol version (ARCHY:2 or ARCHY:3) and the raw pubkey. Carried by CMD_SEND_SELF_ADVERT (0x07).

+ +

PrekeyBundle (type 5) and SessionInit (type 6)

+

X3DH handshake. PrekeyBundle advertises a signed prekey; SessionInit consumes it to derive the initial ratchet root key. Both ride on 0xEE (static-key encryption), because the ratchet session they're creating doesn't yet exist.

+ +

ContactCard (type 22)

+

A shareable card containing {did, onion_address, pubkey, display_name}. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.

+ +
+ +

RPC API — what callers actually invoke

+

Every user-facing action goes through the RPC dispatcher (api/rpc/dispatcher.rs, lines 287+) and ends in api/rpc/mesh/typed_messages.rs. The tables below show the public surface.

+ +

Core commands

+ + + + + + + + + + + + +
RPCEffect
mesh.statusDevice info, peer count, enabled state
mesh.peersList all discovered peers with RSSI / SNR / hop count
mesh.messagesRetrieve stored mesh messages
mesh.sendSend plain text to a specific peer
mesh.send-channelBroadcast on a channel
mesh.broadcastMesh-wide announcement
mesh.configureSet device params (name, power, channel)
mesh.debug-dumpRaw state for debugging
+ +

Rich message commands

+ + + + + + + + + + + + + + + + +
RPCMsg TypeNotes
mesh.send-invoiceInvoice (2)Deliver BOLT11 to peer
mesh.send-coordinateCoordinate (4)Single frame, fixed-point
mesh.send-alertAlert (1)Emergency or deadman
mesh.send-contentContentRef (19)Stores blob, sends CID
mesh.fetch-contentPulls blob via federation
mesh.send-psbtPsbtHash (3)Hash only, full PSBT via fetch
mesh.send-replyReply (13)Quoted response
mesh.send-reactionReaction (14)Emoji
mesh.send-read-receiptReadReceipt (15)Cumulative "seen up to"
mesh.forward-messageForward (16)Wraps original + provenance
mesh.edit-messageEdit (17)In-place text replacement
mesh.delete-messageDelete (18)Tombstone
+ +

User Interface

+

The Vue side lives under neode-ui/src/views/mesh/ with state in stores/mesh.ts. Notable panels:

+
+
+

Mesh chat

+

Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.

+
+
+

MeshBitcoinPanel

+

UI for TxRelay / LightningRelay submission and confirmation tracking.

+
+
+

MeshDeadmanPanel

+

Configure dead-man interval, pick recipients, show last heartbeat time.

+
+
+

Unified inbox

+

Federation and mesh chats appear side-by-side; the transport is invisible to the user.

+
+
+ +

Listener loop — how inbound traffic is decoded

+

A long-running async task in mesh/listener/mod.rs owns the serial device and feeds events into the rest of the system.

+ +
loop { + event = await serial_read() + match event { + PUSH_MESSAGES_WAITING → send SYNC_NEXT_MESSAGE until empty + RESP_CONTACT_MSG_V3 → decode.rs extracts payload + → match first byte: + 0x00 plain text + 0x01 identity → frames::parse_identity + 0x02 typed CBOR plaintext + 0xEE → crypto::decrypt_static + 0xDD → session::load + ratchet::decrypt + → dispatch.rs routes typed msg + to chat store / bitcoin relay / + alerts / presence / ... + RESP_CONTACT → contact list update + RESP_SELF_INFO → record our node_id + } +}
+ +

Chunk reassembly happens in listener/session.rs, keyed by (sender_pubkey_prefix, chunk_id). Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.

+ +

File Map

+ + + + + + + + + + + + + + + + + + +
FileSizeRole
mesh/mod.rs52 KBPublic API, send paths, federation integration
mesh/protocol.rs26 KBFrame encoding/decoding, command builders
mesh/serial.rs15 KBUSB driver, device detection, handshake
mesh/crypto.rs10 KBX25519 ECDH, ChaCha20-Poly1305, HKDF
mesh/ratchet.rs16 KBDouble Ratchet implementation
mesh/message_types.rs23 KB23 typed message discriminators + CBOR schemas
mesh/bitcoin_relay.rs17 KBTxRelay / LightningRelay binary framing
mesh/listener/dispatch.rs29 KBTyped-message routing into chat/relay/alerts
mesh/listener/session.rs14 KBRatchet session persistence + chunk reassembly
mesh/x3dh.rsPrekey / SessionInit bootstrap
mesh/outbox.rsRetry queue for unacked sends
mesh/steganography.rsWeather/sensor framing for deniable traffic
api/rpc/mesh/typed_messages.rsAll mesh.* RPC handlers
neode-ui/src/stores/mesh.ts14 KBPinia store consumed by all mesh Vue views
+ +
+ +

Summary scoreboard

+
+
23
Message types
+
160
Bytes / frame
+
2
Transports
+
5
Wire markers
+
~6k
LoC in mesh/
+
FS
Forward-secure
+
+ +
+ Bottom line. Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link. +
+ +
+ + diff --git a/image-recipe/build-auto-installer-iso.sh b/image-recipe/build-auto-installer-iso.sh index 27c1b3c1..5a1428d4 100755 --- a/image-recipe/build-auto-installer-iso.sh +++ b/image-recipe/build-auto-installer-iso.sh @@ -393,6 +393,13 @@ COPY lib/ /home/archipelago/archy/scripts/lib/ RUN chmod +x /home/archipelago/archy/scripts/*.sh /home/archipelago/archy/scripts/lib/*.sh /opt/archipelago/scripts/*.sh && \ chown -R archipelago:archipelago /home/archipelago/archy +# Enable cgroup delegation for rootless podman (CPU/memory limits require this) +RUN mkdir -p /etc/systemd/system/user@.service.d && \ + printf '[Service]\nDelegate=cpu cpuset io memory pids\n' > /etc/systemd/system/user@.service.d/delegate.conf + +# Allow unprivileged ping inside rootless containers +RUN printf 'net.ipv4.ping_group_range=0 2147483647\n' > /etc/sysctl.d/90-podman-ping.conf + # Enable services RUN systemctl enable NetworkManager || true && \ systemctl enable ssh || true && \ @@ -2132,8 +2139,8 @@ mkdir -p /mnt/target/var/lib/archipelago/config cat > /mnt/target/var/lib/archipelago/config/registries.json <<'DYNREG' { "registries": [ - {"url": "23.182.128.160:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0}, - {"url": "git.tx1138.com/lfg2025", "name": "Archipelago Legacy", "tls_verify": true, "enabled": true, "priority": 10} + {"url": "git.tx1138.com/lfg2025", "name": "Archipelago Primary", "tls_verify": true, "enabled": true, "priority": 0}, + {"url": "23.182.128.160:3000/lfg2025", "name": "Archipelago Fallback", "tls_verify": false, "enabled": true, "priority": 10} ] } DYNREG diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 6f99db60..51dc0d80 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.2.0-alpha", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.2.0-alpha", + "version": "1.3.5", "dependencies": { "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", @@ -146,7 +146,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1808,7 +1807,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1832,7 +1830,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3843,7 +3840,6 @@ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/geojson": "*" } @@ -3887,7 +3883,6 @@ "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cac": "^6.7.14", "colorette": "^2.0.20", @@ -4388,7 +4383,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4850,7 +4844,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5852,7 +5845,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -8027,7 +8019,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8077,7 +8068,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -8180,8 +8170,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/leven": { "version": "3.1.0", @@ -8933,7 +8922,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9586,7 +9574,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10850,7 +10837,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11106,7 +11092,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11348,7 +11333,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11511,7 +11495,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11525,7 +11508,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -11618,7 +11600,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", @@ -12195,7 +12176,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/neode-ui/public/architecture/index.html b/neode-ui/public/architecture/index.html new file mode 100644 index 00000000..3fceac08 --- /dev/null +++ b/neode-ui/public/architecture/index.html @@ -0,0 +1,899 @@ + + + + + +Archipelago — LoRa & Mesh Functionality Guide + + + + + + +
+ +
+

LoRa & Mesh Functionality

+

How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.

+
+ Meshcore Companion USB + Double Ratchet E2E + 23 Message Types + 160-byte LoRa Frame +
+
+ +

Introduction

+

This document explains Archipelago's mesh subsystem — the code under core/archipelago/src/mesh/ that lets nodes talk to each other over LoRa radio instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.

+

The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.

+ +

What is LoRa? Layman

+
+ Think of LoRa as a whisper that travels 10 kilometers. + Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about 160 bytes at a time, and each whisper takes a second or two to complete. +
+

Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses chirp spread spectrum (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 5–15 km with a simple antenna; data rates are 0.3–50 kbps.

+

Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running Meshcore firmware, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.

+ +

Why Archipelago uses it

+
+
+

Off-grid safety

+

Dead-man switch and emergency alerts reach family without cell coverage.

+
+
+

Censorship resistance

+

No ISP, no DNS, no TLS termination — just radio waves between nodes.

+
+
+

Bitcoin when internet is down

+

Relay signed transactions and Lightning payments through on-grid peers.

+
+
+

Truly peer-to-peer chat

+

Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.

+
+
+ +
+ +

Hardware & Firmware

+

Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.

+ + + + + + + + + + + +
ComponentRoleExamples
MCURuns Meshcore firmware, talks USB serialESP32, nRF52840
RadioSemtech LoRa transceiverSX1262, SX1276
BoardMCU + radio + USB + antennaHeltec V3, T-Beam, RAK WisBlock, Station G2
FirmwareMesh routing + Companion USB protocolMeshcore
ConnectionUSB CDC-ACM serial/dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM*
Link params115200 baud, 8N1Set in mesh/serial.rs
+ +
+ It's a modem. Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through. +
+ +

USB Serial Transport

+

Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with '<' and listens for '>'.

+ +
Host → Device: 0x3C '<' │ len_lo len_hiframe_bytes... +Device → Host: 0x3E '>' │ len_lo len_hiframe_bytes... + +Baud: 115200 Framing: 8N1 Source: mesh/serial.rs
+ +

The frame body is a Meshcore Companion command or response. Archipelago builds these in mesh/protocol.rs and parses replies in mesh/listener/decode.rs.

+ +

Companion commands Archipelago uses

+ + + + + + + + + + + + + + + +
CodeNamePurpose
0x01APP_STARTHandshake; device returns its node_id and name
0x02SEND_TXT_MSGSend payload to a contact (targeted by 6-byte pubkey prefix)
0x03SEND_CHANNEL_TXT_MSGBroadcast on a channel (no specific recipient)
0x04GET_CONTACTSPull the device's contact table
0x06SET_DEVICE_TIMESync Unix timestamp for message dating
0x07SEND_SELF_ADVERTBroadcast our identity onto the mesh
0x08SET_ADVERT_NAMESet our display name
0x0ASYNC_NEXT_MESSAGEPop the next queued inbound message
0x0BSET_RADIO_PARAMSFrequency, spreading factor, bandwidth
0x0CSET_RADIO_TX_POWERTransmit power (dBm)
0x38GET_STATSDevice statistics
+ +

Responses and push notifications

+

Responses begin with a status byte. Codes < 0x80 are replies to a command we sent; codes >= 0x80 are asynchronous push events from the device.

+ + + + + + + + + + + + +
CodeNameMeaning
0x00RESP_OKCommand accepted
0x01RESP_ERRCommand failed + error code
0x03RESP_CONTACTOne contact entry (32-byte pubkey + metadata)
0x05RESP_SELF_INFOOur node_id and name after APP_START
0x10RESP_CONTACT_MSG_V3Direct inbound message (SNR + sender prefix + payload)
0x11RESP_CHANNEL_MSG_V3Channel broadcast inbound
0x83PUSH_MESSAGES_WAITINGAsync: new messages in queue, call SYNC_NEXT_MESSAGE
+ +

Wire Format — the payload byte 0

+

Once a frame reaches the message payload, Archipelago looks at the first byte to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.

+ +
0x00 Plain text (legacy, unencrypted) +0x01 Identity broadcast (ARCHY:2 / ARCHY:3) +0x02 Typed CBOR envelope (plaintext, used for debug or intra-LAN) +0xEE Encrypted typed — ChaCha20-Poly1305 w/ static shared secret +0xDD Ratcheted typed — Double Ratchet, forward-secure
+ +

Markers 0xEE and 0xDD are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.

+ +

0xEE — static-key encrypted envelope

+
[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]
+
    +
  • Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.
  • +
  • Cipher: ChaCha20-Poly1305 AEAD.
  • +
  • Max plaintext: 160 − 1 − 12 − 16 = 131 bytes (see crypto::MAX_ENCRYPTED_PLAINTEXT).
  • +
  • Properties: confidential + authenticated, but compromise of a key decrypts all history.
  • +
+ +

0xDD — Double Ratchet envelope

+
[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
+
    +
  • Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).
  • +
  • Handles out-of-order delivery via a skipped-keys cache.
  • +
  • Properties: forward secrecy + post-compromise recovery. Used for mesh.* chat once a session is established.
  • +
  • Implementation: mesh/ratchet.rs, session load/save in mesh/listener/session.rs.
  • +
+ +
+ Static key vs. ratchet = a safe vs. a self-shredding envelope. + The 0xEE lane is like a locked safe: one key opens everything. The 0xDD lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages. +
+ +

Encryption Layers

+

Three cryptographic primitives combine to produce the 0xDD ratchet flow:

+ +
+
+

X25519 ECDH

+

Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.

+
+
+

HKDF-SHA256

+

Derives root key, chain key, and message key at each ratchet step.

+
+
+

ChaCha20-Poly1305

+

Symmetric AEAD used for the actual payload encryption + authentication tag.

+
+
+ +

Session bootstrap — X3DH-like handshake

+

Before the ratchet can start, peers exchange a PrekeyBundle (type 5) and a SessionInit (type 6). Those two messages are carried by the 0xEE static-key envelope, because the ratchet session doesn't exist yet. Once SessionInit is processed, subsequent traffic switches to 0xDD. See mesh/x3dh.rs.

+ +

Fragmentation — how a 500-byte message rides a 160-byte pipe

+

The LoRa frame budget is 160 bytes (protocol::MAX_MESSAGE_LEN). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.

+ +
Chunk header ┌──────────┬──────────┬────────────┐ + │ type (1) │ id (1) │ total (1) │ + └──────────┴──────────┴────────────┘ +Chunk body Up to 140 bytes of Base64-encoded payload + +Sender: compress → encrypt → split into 140-char chunks + → send with tiny inter-chunk delay +Receiver: accumulate by (sender, chunk_id) → reassemble + → decrypt → decompress → dispatch
+ +

For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.

+ +
+ Escape hatch: federation fallback. If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago skips LoRa entirely and routes the message over Tor federation instead. See the ContentRef path in rpc/mesh/typed_messages.rs. +
+ +

Dual Transport — LoRa + Tor federation

+

Archipelago treats LoRa and Tor federation as two lanes of the same highway. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.

+ +
┌──────────────────┐ + │ mesh.send(...) │ + └────────┬─────────┘ + │ + ┌──────────┴──────────┐ + │ Is peer synthetic? │ + └──────────┬──────────┘ + No │ Yes + ┌──────────┘ └──────────┐ + ▼ ▼ + LoRa radio Tor federation + (160-byte frame) (unlimited, slower setup) + │ │ + │ if > 160 B && synth ──────┘ (fallback) + ▼ + Chunked over LoRa + or refused if no fallback
+ +

Addressing

+
    +
  • Contact ID — 32-bit handle from Meshcore's contact table. Used by SEND_TXT_MSG.
  • +
  • Pubkey prefix — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.
  • +
  • DID / onion — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.
  • +
+ +

Synthetic federation contacts

+

To let the chat list show federation peers before any message arrives, Archipelago inserts synthetic contacts into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (≥ 0x8000_0000), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.

+ +
+ +

All 23 Message Types

+

Every typed message is a CBOR envelope identified by a single MeshMessageType byte. The Transport column shows which marker carries it on the wire and which Companion command is used.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTypePurposeMarkerCmdChunked?
0TextPlain chat message0xDD0x02If >160 B
1AlertEmergency / dead-man heartbeat0xDD0x02/0x03No (short)
2InvoiceLightning / BOLT11 invoice0xDD0x02Usually
3PsbtHashUnsigned tx hash for co-signing0xDD0x02No
4CoordinateGPS location share0xDD0x02No
5PrekeyBundleX3DH bootstrap (pre-session)0xEE0x02No
6SessionInitInitial ratchet message0xEE0x02No
7BlockHeaderBitcoin block height/hash0xDD0x03No
8TxRelaySigned Bitcoin tx for on-grid peer to broadcast0xDD0x02Yes
9TxRelayResponsetxid or error from the relay peer0xDD0x02No
10LightningRelayBOLT11 to pay via on-grid peer0xDD0x02Yes
11LightningRelayResponsepayment_hash or error0xDD0x02No
12TxConfirmationDepth update (1/2/3 confs)0xDD0x02No
13ReplyQuoted reply to a previous message0xDD0x02If long
14ReactionEmoji reaction on MessageKey0xDD0x02No
15ReadReceipt"Seen up to MessageKey X"0xDD0x02No
16ForwardRe-forwarded original w/ provenance0xDD0x02Yes
17EditIn-place text replacement0xDD0x02If long
18DeleteTombstone for earlier message0xDD0x02No
19ContentRefCID of blob held by sender (file/image)0xDD0x02 or TorFederation fallback
20PresenceHeartbeat + last-activity epoch0xDD0x03No
21ChannelInviteGroup membership announcement0xDD0x03No
22ContactCardShareable federation node card0xDD0x02Maybe
+ +

The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.

+ +

Text, Reply, Edit, Delete, Forward

+ +

Text (type 0)

+

Sender path. rpc.mesh.sendtyped_messages::send_text → CBOR-encode the Text{body} variant → ratchet-encrypt → prefix 0xDD → if under 160 B, send in one SEND_TXT_MSG frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.

+ +

Reply (type 13)

+

Same as Text, but the CBOR envelope carries a MessageKey pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.

+ +

Edit (type 17)

+

Envelope contains the original MessageKey plus the new body. Receiver updates its local store in-place and tags the entry "edited".

+ +

Delete (type 18)

+

Tombstone only: MessageKey with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.

+ +

Forward (type 16)

+

Wraps original {sender_name, original_timestamp, body} so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are almost always chunked.

+ +

Reaction, ReadReceipt, Presence

+ +

Reaction (type 14)

+

Envelope: {target: MessageKey, emoji: String}. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see MessageActions in neode-ui).

+ +

ReadReceipt (type 15)

+

Envelope: {up_to: MessageKey}. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).

+ +

Presence (type 20)

+

Periodic heartbeat carrying {last_activity_epoch}. Broadcast on a channel (SEND_CHANNEL_TXT_MSG, cmd 0x03) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.

+ +
+ Like a lighthouse beacon. Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed. +
+ +

ContentRef — files and images without bloating the radio

+

LoRa cannot move a 500 KB image. The ContentRef type (19) solves this by sending only a pointer — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.

+ +
Sender Receiver +────── ──────── +store blob locally (CID) +┌──────────────────────┐ +│ ContentRef {cid, │ ──ratchet──▶ +│ mime, size, │ 0xDD +│ thumb_hash} │ over LoRa +└──────────────────────┘ + see CID in chat + click to fetch + ┌─────────────────┐ + │ rpc.mesh.fetch- │ + │ content(cid) │ + └────────┬────────┘ + ▼ + federation (Tor) + resolve DID → pull blob
+ +
+ Resolution bug fix note. An earlier revision of ContentRef routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit 5f7ebf14) resolves the owning peer by DID and falls back to name-match only if DID lookup fails. +
+ +

Bitcoin & Lightning over LoRa

+

Archipelago uses the mesh as a Bitcoin transport of last resort. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.

+ +

TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)

+
Offline signer On-grid relay peer Bitcoin p2p +────────────── ────────────────── ─────────── +sign tx +┌─────────────┐ +│ TxRelay │ ─ratchet/LoRa▶ decrypt → validate +│ {raw_tx} │ broadcast via bitcoind ───▶ mempool +└─────────────┘ │ + ▼ + ┌────────────────────────┐ + ◀─ratchet│ TxRelayResponse{txid} │ + └────────────────────────┘ + (or {error}) + +later, as blocks arrive: + ┌────────────────────────┐ + ◀─ratchet│ TxConfirmation │ + │ {txid, depth: 1..3} │ + └────────────────────────┘
+ +

The binary framing in mesh/bitcoin_relay.rs is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.

+ +

LightningRelay (10) → LightningRelayResponse (11)

+

Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns payment_hash or an error. Invoices are often long enough to chunk.

+ +

Invoice (2) and PsbtHash (3)

+

These are not relays — they're peer-to-peer handoffs. Invoice delivers a BOLT11 to be paid by the recipient. PsbtHash carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.

+ +

BlockHeader (7)

+

Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via SEND_CHANNEL_TXT_MSG) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.

+ +

Alerts, Coordinates, Dead-Man

+ +

Alert (type 1)

+

Envelope: {kind, message, sender_contact_id}. Kinds include Emergency and Deadman. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).

+ +

Dead-man switch

+

A background task in mesh/alerts.rs sends a Deadman alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.

+ +

Coordinate (type 4)

+

Envelope: {lat, lon, accuracy_m} with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.

+ +

ChannelInvite (type 21)

+

Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via SEND_CHANNEL_TXT_MSG.

+ +

Identity, PrekeyBundle, ContactCard

+ +

Identity broadcast (marker 0x01, ARCHY:2/3)

+

The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed 0x01. This is how peers discover each other. The payload encodes protocol version (ARCHY:2 or ARCHY:3) and the raw pubkey. Carried by CMD_SEND_SELF_ADVERT (0x07).

+ +

PrekeyBundle (type 5) and SessionInit (type 6)

+

X3DH handshake. PrekeyBundle advertises a signed prekey; SessionInit consumes it to derive the initial ratchet root key. Both ride on 0xEE (static-key encryption), because the ratchet session they're creating doesn't yet exist.

+ +

ContactCard (type 22)

+

A shareable card containing {did, onion_address, pubkey, display_name}. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.

+ +
+ +

RPC API — what callers actually invoke

+

Every user-facing action goes through the RPC dispatcher (api/rpc/dispatcher.rs, lines 287+) and ends in api/rpc/mesh/typed_messages.rs. The tables below show the public surface.

+ +

Core commands

+ + + + + + + + + + + + +
RPCEffect
mesh.statusDevice info, peer count, enabled state
mesh.peersList all discovered peers with RSSI / SNR / hop count
mesh.messagesRetrieve stored mesh messages
mesh.sendSend plain text to a specific peer
mesh.send-channelBroadcast on a channel
mesh.broadcastMesh-wide announcement
mesh.configureSet device params (name, power, channel)
mesh.debug-dumpRaw state for debugging
+ +

Rich message commands

+ + + + + + + + + + + + + + + + +
RPCMsg TypeNotes
mesh.send-invoiceInvoice (2)Deliver BOLT11 to peer
mesh.send-coordinateCoordinate (4)Single frame, fixed-point
mesh.send-alertAlert (1)Emergency or deadman
mesh.send-contentContentRef (19)Stores blob, sends CID
mesh.fetch-contentPulls blob via federation
mesh.send-psbtPsbtHash (3)Hash only, full PSBT via fetch
mesh.send-replyReply (13)Quoted response
mesh.send-reactionReaction (14)Emoji
mesh.send-read-receiptReadReceipt (15)Cumulative "seen up to"
mesh.forward-messageForward (16)Wraps original + provenance
mesh.edit-messageEdit (17)In-place text replacement
mesh.delete-messageDelete (18)Tombstone
+ +

User Interface

+

The Vue side lives under neode-ui/src/views/mesh/ with state in stores/mesh.ts. Notable panels:

+
+
+

Mesh chat

+

Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.

+
+
+

MeshBitcoinPanel

+

UI for TxRelay / LightningRelay submission and confirmation tracking.

+
+
+

MeshDeadmanPanel

+

Configure dead-man interval, pick recipients, show last heartbeat time.

+
+
+

Unified inbox

+

Federation and mesh chats appear side-by-side; the transport is invisible to the user.

+
+
+ +

Listener loop — how inbound traffic is decoded

+

A long-running async task in mesh/listener/mod.rs owns the serial device and feeds events into the rest of the system.

+ +
loop { + event = await serial_read() + match event { + PUSH_MESSAGES_WAITING → send SYNC_NEXT_MESSAGE until empty + RESP_CONTACT_MSG_V3 → decode.rs extracts payload + → match first byte: + 0x00 plain text + 0x01 identity → frames::parse_identity + 0x02 typed CBOR plaintext + 0xEE → crypto::decrypt_static + 0xDD → session::load + ratchet::decrypt + → dispatch.rs routes typed msg + to chat store / bitcoin relay / + alerts / presence / ... + RESP_CONTACT → contact list update + RESP_SELF_INFO → record our node_id + } +}
+ +

Chunk reassembly happens in listener/session.rs, keyed by (sender_pubkey_prefix, chunk_id). Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.

+ +

File Map

+ + + + + + + + + + + + + + + + + + +
FileSizeRole
mesh/mod.rs52 KBPublic API, send paths, federation integration
mesh/protocol.rs26 KBFrame encoding/decoding, command builders
mesh/serial.rs15 KBUSB driver, device detection, handshake
mesh/crypto.rs10 KBX25519 ECDH, ChaCha20-Poly1305, HKDF
mesh/ratchet.rs16 KBDouble Ratchet implementation
mesh/message_types.rs23 KB23 typed message discriminators + CBOR schemas
mesh/bitcoin_relay.rs17 KBTxRelay / LightningRelay binary framing
mesh/listener/dispatch.rs29 KBTyped-message routing into chat/relay/alerts
mesh/listener/session.rs14 KBRatchet session persistence + chunk reassembly
mesh/x3dh.rsPrekey / SessionInit bootstrap
mesh/outbox.rsRetry queue for unacked sends
mesh/steganography.rsWeather/sensor framing for deniable traffic
api/rpc/mesh/typed_messages.rsAll mesh.* RPC handlers
neode-ui/src/stores/mesh.ts14 KBPinia store consumed by all mesh Vue views
+ +
+ +

Summary scoreboard

+
+
23
Message types
+
160
Bytes / frame
+
2
Transports
+
5
Wire markers
+
~6k
LoC in mesh/
+
FS
Forward-secure
+
+ +
+ Bottom line. Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link. +
+ +
+ + diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index d8f1cac8..84aa2666 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -15,6 +15,21 @@ export interface RPCResponse { } } +/// Mirrors `crate::federation::pending::PendingPeerRequest` on the backend. +export type PendingState = 'pending' | 'sent' | 'approved' | 'rejected' | 'expired' + +export interface PendingPeerRequest { + id: string + from_nostr_pubkey: string + from_nostr_npub: string + from_did: string + from_name: string | null + message: string | null + received_at: string + state: PendingState + outbound: boolean +} + function getCsrfToken(): string | null { const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/) if (match) return match[1]! @@ -262,7 +277,7 @@ class RPCClient { // ─── Node Identity ─────────────────────────────────────────────── - async getNodeDid(): Promise<{ did: string; pubkey: string }> { + async getNodeDid(): Promise<{ did: string; pubkey: string; nostr_pubkey?: string; nostr_npub?: string }> { return this.call({ method: 'node.did', params: {}, @@ -579,6 +594,21 @@ class RPCClient { }) } + async meshContactsList(): Promise<{ + contacts: Array<{ pubkey: string; alias?: string | null; notes?: string | null; pinned?: boolean; blocked?: boolean }> + }> { + return this.call({ method: 'mesh.contacts-list', params: {} }) + } + + async meshContactsSave( + pubkey: string, + alias?: string | null, + ): Promise<{ saved: boolean; pubkey: string; alias: string | null }> { + const params: Record = { pubkey } + if (alias !== undefined) params.alias = alias + return this.call({ method: 'mesh.contacts-save', params }) + } + async federationListNodes(): Promise<{ nodes: Array<{ did: string @@ -590,6 +620,7 @@ class RPCClient { last_seen?: string last_state?: { timestamp: string + node_name?: string apps: Array<{ id: string; status: string; version?: string }> cpu_usage_percent?: number mem_used_bytes?: number @@ -598,6 +629,7 @@ class RPCClient { disk_total_bytes?: number uptime_secs?: number tor_active?: boolean + nostr_npub?: string } }> }> { @@ -624,6 +656,92 @@ class RPCClient { }) } + // Nostr peer-discovery — see `core/archipelago/src/nostr_handshake.rs`. + // None of these methods ever exchange the local onion address on a public + // relay. `handshake.discover` returns presence-only events (DID + npub); + // `handshake.connect` ships a NIP-44-encrypted PeerRequest with no onion; + // `handshake.poll` queues inbound requests into the federation pending + // inbox for manual approval (it does NOT auto-accept). + + async nostrDiscoveryStatus(): Promise<{ enabled: boolean }> { + return this.call({ method: 'nostr.discovery-status', params: {} }) + } + + async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> { + return this.call({ + method: 'nostr.set-discovery', + params: { enabled }, + timeout: 30000, + }) + } + + async handshakeDiscover(): Promise<{ + nodes: Array<{ + nostr_pubkey: string + nostr_npub: string + did: string + version: string + }> + }> { + return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 }) + } + + async handshakeConnect( + recipient: string, + message?: string, + name?: string, + ): Promise<{ ok: boolean; sent_to: string; id: string }> { + return this.call({ + method: 'handshake.connect', + params: { + recipient_nostr_pubkey: recipient, + ...(message ? { message } : {}), + ...(name ? { name } : {}), + }, + timeout: 30000, + }) + } + + async handshakePoll(): Promise<{ + polled: number + new_requests: PendingPeerRequest[] + applied_invites: string[] + rejected_outbound: string[] + skipped: string[] + }> { + return this.call({ method: 'handshake.poll', params: {}, timeout: 30000 }) + } + + async federationListPendingRequests(): Promise<{ + requests: PendingPeerRequest[] + }> { + return this.call({ method: 'federation.list-pending-requests', params: {} }) + } + + async federationApproveRequest(id: string): Promise<{ approved: boolean; id: string }> { + return this.call({ + method: 'federation.approve-request', + params: { id }, + timeout: 30000, + }) + } + + async federationRejectRequest( + id: string, + reason?: string, + notify = false, + ): Promise<{ rejected: boolean; id: string }> { + return this.call({ + method: 'federation.reject-request', + params: { + id, + ...(reason ? { reason } : {}), + notify, + }, + timeout: 30000, + }) + } + async federationSyncState(): Promise<{ synced: number failed: number diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue index 2448531b..1297e0b4 100644 --- a/neode-ui/src/views/Federation.vue +++ b/neode-ui/src/views/Federation.vue @@ -45,6 +45,54 @@ @clear-invite="inviteCode = ''" /> + +
+
+
+ Nostr discoverability + {{ discoveryEnabled ? 'On' : 'Off' }} +
+

+ When on, this node publishes a presence event (DID + npub only — never an onion) + so other nodes can find you and request to peer. Inbound requests land in the + panel below for your approval. Off by default. +

+
+
+ + +
+
+
{{ discoveryError }}
+ + + + + @@ -97,7 +152,10 @@ import QuickActions from './federation/QuickActions.vue' import NodeList from './federation/NodeList.vue' import NodeDetailModal from './federation/NodeDetailModal.vue' import JoinModal from './federation/JoinModal.vue' +import PendingRequestsPanel from './federation/PendingRequestsPanel.vue' +import DiscoverModal from './federation/DiscoverModal.vue' import type { FederatedNode, DwnStatus, SyncResult } from './federation/types' +import type { PendingPeerRequest } from '@/api/rpc-client' import { nodeName, timeAgo } from './federation/utils' const transportStore = useTransportStore() @@ -205,6 +263,89 @@ const rotateSuccess = ref('') // Dead node cleanup const cleaningNodes = ref(false) +// Nostr discoverability + pending peer requests +const discoveryEnabled = ref(false) +const discoveryToggling = ref(false) +const discoveryError = ref('') +const showDiscoverModal = ref(false) +const pendingRequests = ref([]) +const pollingHandshake = ref(false) +const pendingBusyId = ref(null) + +async function loadDiscoveryState() { + try { + const result = await rpcClient.nostrDiscoveryStatus() + discoveryEnabled.value = !!result.enabled + } catch { + discoveryEnabled.value = false + } +} + +async function toggleDiscovery() { + discoveryToggling.value = true + discoveryError.value = '' + const next = !discoveryEnabled.value + try { + const result = await rpcClient.nostrSetDiscovery(next) + discoveryEnabled.value = !!result.enabled + } catch (e: unknown) { + discoveryError.value = e instanceof Error ? e.message : 'Failed to toggle discoverability' + } finally { + discoveryToggling.value = false + } +} + +async function loadPendingRequests() { + try { + const result = await rpcClient.federationListPendingRequests() + pendingRequests.value = result.requests + } catch (e: unknown) { + discoveryError.value = e instanceof Error ? e.message : 'Failed to load pending requests' + } +} + +async function pollHandshake() { + pollingHandshake.value = true + discoveryError.value = '' + try { + await rpcClient.handshakePoll() + await loadPendingRequests() + // If a poll applied a PeerInvite, the federation node list also changed. + await loadNodes() + } catch (e: unknown) { + discoveryError.value = e instanceof Error ? e.message : 'Poll failed' + } finally { + pollingHandshake.value = false + } +} + +async function approvePending(id: string) { + pendingBusyId.value = id + discoveryError.value = '' + try { + await rpcClient.federationApproveRequest(id) + await loadPendingRequests() + await loadNodes() + } catch (e: unknown) { + discoveryError.value = e instanceof Error ? e.message : 'Approve failed' + } finally { + pendingBusyId.value = null + } +} + +async function rejectPending(id: string) { + pendingBusyId.value = id + discoveryError.value = '' + try { + await rpcClient.federationRejectRequest(id) + await loadPendingRequests() + } catch (e: unknown) { + discoveryError.value = e instanceof Error ? e.message : 'Reject failed' + } finally { + pendingBusyId.value = null + } +} + function isOnlineCheck(node: FederatedNode): boolean { if (!node.last_seen) return false const lastSeen = new Date(node.last_seen).getTime() @@ -382,6 +523,8 @@ async function rotateDid(password: string) { onMounted(async () => { loadNodes() loadDwnStatus() + loadDiscoveryState() + loadPendingRequests() transportStore.fetchPeers() try { const result = await rpcClient.getNodeDid() diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 47825bcf..011ee21e 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -145,7 +145,7 @@
VPN
- {{ vpnConnected ? (vpnStatus.provider || 'Connected') : 'Not configured' }} + {{ vpnConnected ? 'WireGuard' : 'Not configured' }}
Bitcoin
diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index f3b78b8e..e7bad830 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -114,7 +114,7 @@ VPN
- {{ networkData.vpnConnected ? 'WireGuard / NostrVPN' : 'Not Connected' }} + {{ networkData.vpnConnected ? 'WireGuard' : 'Not Connected' }} - -
-
- npub - {{ nodeNpub }} -
- -
- - -
-
- relay (tor) - {{ relayOnion }} -
- -
-
-
- relay (direct) - {{ relayDirect }} -
- -
- - -
-
-
-
- WireGuard -
- {{ networkData.wgIp || 'Not active' }} -
-
-
-
- NostrVPN -
- {{ networkData.vpnIp || (networkData.vpnConnected ? 'Pair a device' : 'Not active') }} + +
+
+
+ Server Address
+ {{ networkData.wgIp || 'Starting...' }} + {{ networkData.wgPubkey }}
@@ -225,15 +193,14 @@ {{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }}
-
+
- {{ peer.type === 'nostrvpn' ? 'NVP' : 'WG' }} - - {{ peer.name }} + WG +
{{ peer.ip?.replace(/\/\d+$/, '') || '' }} - @@ -244,6 +211,53 @@
+ +
+
+
+

Network Interfaces

+

Detected hardware and virtual interfaces

+
+ +
+ + + +
+ +
+ @@ -268,54 +282,10 @@
- +
-
- - -
-
- -
-

Step 2 of 2 — Join the mesh

-
-

Scan with the NostrVPN app

- -
- -
-
-
Network{{ inviteData.network_id }}
- -
-
-
Node npub{{ inviteData.npub }}
- -
-
-
Relay {{ (inviteData.relays?.length || 0) > 1 ? i + 1 : '' }}{{ relay }}
- -
-
-
-
- - -
-
- -
-

Step 1 of 2 — Enter your phone's npub

-

Open the NostrVPN app on your phone, go to Settings, and copy your npub.

- - -
-
-
-
+
+

Scan with the WireGuard app

{{ peerQrData.peer_ip }}

@@ -324,8 +294,10 @@
-
-

Generate a static WireGuard config for the standard WireGuard app.

+
+
+
+

Generate a WireGuard config for the standard WireGuard app.

@@ -337,52 +309,7 @@ -
- -
-
-
-

Network Interfaces

-

Detected hardware and virtual interfaces

-
- -
- - - -
- +
).wg_pubkey as string ?? '' } if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled } } catch { /* keep defaults */ } finally { networkLoading.value = false } } -// Node npub for NostrVPN -const nodeNpub = ref('') -const copiedNpub = ref(false) -async function copyNpub() { - if (!nodeNpub.value) return - try { await navigator.clipboard.writeText(nodeNpub.value) } catch { /* fallback */ } - copiedNpub.value = true - setTimeout(() => { copiedNpub.value = false }, 2000) -} - -// Private relay URLs -const relayOnion = ref('') -const relayDirect = ref('') -const copiedField = ref('') -async function copyText(text: string, field: string) { - if (!text) return - try { await navigator.clipboard.writeText(text) } catch { /* fallback */ } - copiedField.value = field - setTimeout(() => { copiedField.value = '' }, 2000) -} - // VPN peer management const showAddDeviceModal = ref(false) const newPeerName = ref('') @@ -578,52 +484,14 @@ async function removePeer(name: string) { finally { removingPeer.value = '' } } -const deviceTab = ref<'nvpn' | 'wg'>('nvpn') const showingNewDevice = ref(false) -const showMeshDetails = ref(false) -const inviteData = ref<{ invite_url: string; qr_svg: string; npub: string; network_id: string; relays?: string[] } | null>(null) -const generatingInvite = ref(false) -const copiedInvite = ref(false) -const participantNpub = ref('') function closeDeviceModal() { showAddDeviceModal.value = false peerQrData.value = null - inviteData.value = null newPeerName.value = '' peerError.value = '' showingNewDevice.value = false - showMeshDetails.value = false - participantNpub.value = '' -} - -async function generateInviteWithNpub() { - const npub = participantNpub.value.trim() - if (!npub.startsWith('npub1')) return - generatingInvite.value = true - peerError.value = '' - try { - const res = await rpcClient.call<{ invite_url: string; qr_svg: string; npub: string; network_id: string; relays?: string[] }>({ - method: 'vpn.invite', - params: { npub }, - }) - inviteData.value = res - // Add to device list immediately - const short = npub.length > 20 ? `${npub.slice(0, 12)}...${npub.slice(-6)}` : npub - vpnPeers.value.push({ name: short, ip: 'mesh', type: 'nostrvpn', npub }) - loadVpnPeers() - } catch (e) { - peerError.value = e instanceof Error ? e.message : 'Failed to generate invite' - } finally { - generatingInvite.value = false - } -} - -async function copyInvite() { - if (!inviteData.value?.invite_url) return - try { await navigator.clipboard.writeText(inviteData.value.invite_url) } catch { /* fallback */ } - copiedInvite.value = true - setTimeout(() => { copiedInvite.value = false }, 2000) } async function copyPeerConfig() { diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 2bc38ac9..a5b49998 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -43,7 +43,7 @@ export const APP_PORTS: Record = { 'routstr': 8200, 'indeedhub': 7778, 'botfights': 9100, - 'gitea': 3001, + 'gitea': 3000, 'dwn': 3100, 'endurain': 8080, } diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 9b6407bc..b15996f8 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -1,6 +1,6 @@ import type { MarketplaceApp } from './types' -const R = '23.182.128.160:3000/lfg2025' +const R = 'git.tx1138.com/lfg2025' // ---------- Dynamic catalog from registry ---------- export interface CatalogFeatured { @@ -24,9 +24,9 @@ const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache /** Remote catalog URLs — tried in order. First success wins. */ const CATALOG_URLS = [ - // Primary: Gitea raw file (dynamic, updated without frontend rebuild) - // Legacy (down): https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json - // Fallback: direct IP if DNS fails + // Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild) + 'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json', + // Fallback: direct IP (HTTP, only works if CSP allows http://$host:*) 'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json', // Last resort: local static file (baked into frontend build) '/catalog.json', diff --git a/neode-ui/src/views/federation/DiscoverModal.vue b/neode-ui/src/views/federation/DiscoverModal.vue new file mode 100644 index 00000000..a89c057b --- /dev/null +++ b/neode-ui/src/views/federation/DiscoverModal.vue @@ -0,0 +1,192 @@ + + + diff --git a/neode-ui/src/views/federation/PendingRequestsPanel.vue b/neode-ui/src/views/federation/PendingRequestsPanel.vue new file mode 100644 index 00000000..bbeea9f6 --- /dev/null +++ b/neode-ui/src/views/federation/PendingRequestsPanel.vue @@ -0,0 +1,128 @@ + + + diff --git a/neode-ui/src/views/settings/VpnStatusSection.vue b/neode-ui/src/views/settings/VpnStatusSection.vue index 6f5a91ac..667f7cf6 100644 --- a/neode-ui/src/views/settings/VpnStatusSection.vue +++ b/neode-ui/src/views/settings/VpnStatusSection.vue @@ -70,7 +70,7 @@ onMounted(fetchVpnStatus)
Provider
-
{{ vpnStatus.provider || 'nostr-vpn' }}
+
{{ vpnStatus.provider || 'wireguard' }}
Peers
diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index b90f4dc4..c6a8a0b7 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -6,7 +6,7 @@ # ./scripts/deploy-to-target.sh # Sync and rebuild # ./scripts/deploy-to-target.sh --quick # Sync only, no rebuild # ./scripts/deploy-to-target.sh --live # Deploy to live system (default: 192.168.1.228) -# ./scripts/deploy-to-target.sh --both # Deploy to 228, then copy to 198 +# ./scripts/deploy-to-target.sh --both # Deploy to 228, then copy to 198 + 253 # ./scripts/deploy-to-target.sh --frontend-only # Frontend-only deploy (skip Rust build + container rebuilds) # ./scripts/deploy-to-target.sh --demo # Demo mode: Bitcoin pruning enabled (smaller disk) # ./scripts/deploy-to-target.sh --dry-run --live # Show what would be deployed without executing @@ -55,6 +55,7 @@ CANARY=false TAILSCALE=false TAILSCALE_NODE="" FLEET=false +RESET_MESH=false for arg in "$@"; do case $arg in --quick) QUICK=true ;; @@ -68,6 +69,7 @@ for arg in "$@"; do --tailscale-node=*) TAILSCALE_NODE="${arg#*=}" ;; --fleet) FLEET=true ;; --all) FLEET=true ;; + --reset-mesh) RESET_MESH=true ;; esac done @@ -93,8 +95,8 @@ if [ "$FLEET" = true ]; then echo "FAILED: .228 unreachable"; exit 1 fi echo "" - echo "Phase 2: Copy to .198 (LAN secondary — skip if unreachable)" - "$0" --both 2>/dev/null || echo " .198 unreachable, skipping" + echo "Phase 2: Copy to .198 + .253 (LAN secondaries — skip if unreachable)" + "$0" --both 2>/dev/null || echo " LAN secondaries unreachable, skipping" echo "" echo "Phase 3: Deploy to all Tailscale nodes (Arch 1/2/3)" "$SCRIPT_DIR/deploy-tailscale.sh" --all || { echo "WARNING: Some Tailscale nodes failed"; } @@ -333,22 +335,19 @@ if [ "$CANARY" = true ]; then exit 0 fi -# When --both: deploy to 228 first, then copy to 198 -if [ "$BOTH" = true ]; then - echo "Deploying to both servers (228, then 198)..." - # Release lock so the recursive --live call can acquire it - rm -rf "$LOCK_DIR" 2>/dev/null; trap - EXIT - "$0" --live +# ── deploy_secondary: copy built binary+frontend from .228 to a secondary node ── +# Usage: deploy_secondary (e.g. deploy_secondary archipelago@192.168.1.198 198) +deploy_secondary() { + local SEC_TARGET="$1" + local SEC_LABEL="$2" + local SEC_IP="${SEC_TARGET#*@}" + echo "" - echo "📤 Copying to 192.168.1.198 (no rsync/cargo on that node)..." - TARGET_198="archipelago@192.168.1.198" - if ! scp $SSH_OPTS "archipelago@192.168.1.228:$TARGET_DIR/core/target/release/archipelago" /tmp/archipelago-both 2>/dev/null; then - echo " ERROR: Failed to copy binary from .228 — is the build available?" - exit 1 - fi - scp $SSH_OPTS /tmp/archipelago-both "$TARGET_198:/tmp/archipelago-new" - ssh $SSH_OPTS "archipelago@192.168.1.228" "cd '$TARGET_DIR' && tar cf - web/dist/neode-ui 2>/dev/null" | ssh $SSH_OPTS "$TARGET_198" "mkdir -p /tmp/web-deploy && cd /tmp/web-deploy && tar xf -" - ssh $SSH_OPTS "$TARGET_198" ' + echo "📤 Copying to $SEC_IP (no rsync/cargo on that node)..." + + scp $SSH_OPTS /tmp/archipelago-both "$SEC_TARGET:/tmp/archipelago-new" + ssh $SSH_OPTS "archipelago@192.168.1.228" "cd '$TARGET_DIR' && tar cf - web/dist/neode-ui 2>/dev/null" | ssh $SSH_OPTS "$SEC_TARGET" "mkdir -p /tmp/web-deploy && cd /tmp/web-deploy && tar xf -" + ssh $SSH_OPTS "$SEC_TARGET" ' sudo systemctl stop archipelago sudo cp /tmp/archipelago-new /usr/local/bin/archipelago sudo chmod +x /usr/local/bin/archipelago @@ -358,49 +357,49 @@ if [ "$BOTH" = true ]; then sudo chown -R 1000:1000 /opt/archipelago/web-ui ' - # Deploy AIUI to 198 if available + # Deploy AIUI if available AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist" if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then - echo " Deploying AIUI to 198..." - ssh $SSH_OPTS "$TARGET_198" "sudo mkdir -p /opt/archipelago/web-ui/aiui && sudo rm -rf /opt/archipelago/web-ui/aiui/*" - cd "$AIUI_DIST" && tar --no-xattrs -cf - . | ssh $SSH_OPTS "$TARGET_198" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/" + echo " Deploying AIUI to .$SEC_LABEL..." + ssh $SSH_OPTS "$SEC_TARGET" "sudo mkdir -p /opt/archipelago/web-ui/aiui && sudo rm -rf /opt/archipelago/web-ui/aiui/*" + cd "$AIUI_DIST" && tar --no-xattrs -cf - . | ssh $SSH_OPTS "$SEC_TARGET" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/" cd "$PROJECT_DIR" - ssh $SSH_OPTS "$TARGET_198" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui" + ssh $SSH_OPTS "$SEC_TARGET" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui" fi - # Sync nginx config + snippets + fixes to 198 + # Sync nginx config + snippets NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets" if [ -f "$NGINX_CFG" ]; then - echo " Syncing nginx config to 198..." - scp $SSH_OPTS "$NGINX_CFG" "$TARGET_198:/tmp/nginx-archipelago.conf" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_198" ' + echo " Syncing nginx config to .$SEC_LABEL..." + scp $SSH_OPTS "$NGINX_CFG" "$SEC_TARGET:/tmp/nginx-archipelago.conf" 2>/dev/null || true + ssh $SSH_OPTS "$SEC_TARGET" ' sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf sudo sed -i "s|proxy_pass http://127.0.0.1:3141/;|proxy_pass http://127.0.0.1:3142/;|g" /etc/nginx/sites-available/archipelago rm -f /tmp/nginx-archipelago.conf ' 2>/dev/null || true fi - # Sync nginx snippets to 198 if [ -d "$SNIPPETS_DIR" ]; then - echo " Syncing nginx snippets to 198..." - ssh $SSH_OPTS "$TARGET_198" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true + echo " Syncing nginx snippets to .$SEC_LABEL..." + ssh $SSH_OPTS "$SEC_TARGET" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true for f in "$SNIPPETS_DIR"/*.conf; do - [ -f "$f" ] && scp $SSH_OPTS "$f" "$TARGET_198:/tmp/nginx-snippet-$(basename "$f")" 2>/dev/null || true + [ -f "$f" ] && scp $SSH_OPTS "$f" "$SEC_TARGET:/tmp/nginx-snippet-$(basename "$f")" 2>/dev/null || true done - ssh $SSH_OPTS "$TARGET_198" ' + ssh $SSH_OPTS "$SEC_TARGET" ' for f in /tmp/nginx-snippet-*.conf; do [ -f "$f" ] && sudo mv "$f" "/etc/nginx/snippets/$(basename "$f" | sed "s/^nginx-snippet-//")" done ' 2>/dev/null || true fi - ssh $SSH_OPTS "$TARGET_198" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " nginx config test failed"' 2>/dev/null || true - # Sync systemd service file to 198 + ssh $SSH_OPTS "$SEC_TARGET" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " nginx config test failed"' 2>/dev/null || true + + # Sync systemd service file SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service" if [ -f "$SERVICE_FILE" ]; then - echo " Syncing systemd service to 198..." - scp $SSH_OPTS "$SERVICE_FILE" "$TARGET_198:/tmp/archipelago.service" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_198" ' + echo " Syncing systemd service to .$SEC_LABEL..." + scp $SSH_OPTS "$SERVICE_FILE" "$SEC_TARGET:/tmp/archipelago.service" 2>/dev/null || true + ssh $SSH_OPTS "$SEC_TARGET" ' if ! diff -q /tmp/archipelago.service /etc/systemd/system/archipelago.service >/dev/null 2>&1; then sudo cp /tmp/archipelago.service /etc/systemd/system/archipelago.service sudo systemctl daemon-reload @@ -412,12 +411,12 @@ if [ "$BOTH" = true ]; then ' 2>/dev/null || true fi - # Deploy udev rule for mesh radio to 198 + # Deploy udev rule for mesh radio UDEV_RULE="$PROJECT_DIR/image-recipe/configs/99-mesh-radio.rules" if [ -f "$UDEV_RULE" ]; then - echo " Syncing udev rule to 198..." - scp $SSH_OPTS "$UDEV_RULE" "$TARGET_198:/tmp/99-mesh-radio.rules" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_198" ' + echo " Syncing udev rule to .$SEC_LABEL..." + scp $SSH_OPTS "$UDEV_RULE" "$SEC_TARGET:/tmp/99-mesh-radio.rules" 2>/dev/null || true + ssh $SSH_OPTS "$SEC_TARGET" ' if ! diff -q /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules >/dev/null 2>&1; then sudo cp /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules sudo udevadm control --reload-rules @@ -430,8 +429,8 @@ if [ "$BOTH" = true ]; then ' 2>/dev/null || true fi - # Dev mode + FileBrowser on 198 - ssh $SSH_OPTS "$TARGET_198" ' + # Dev mode + FileBrowser + ssh $SSH_OPTS "$SEC_TARGET" ' # Dev mode if ! grep -q "ARCHIPELAGO_DEV_MODE=true" /etc/systemd/system/archipelago.service.d/override.conf 2>/dev/null; then sudo mkdir -p /etc/systemd/system/archipelago.service.d @@ -454,9 +453,10 @@ if [ "$BOTH" = true ]; then fi ' 2>/dev/null || true - # Write deploy manifest to .198 + # Write deploy manifest + local DEPLOY_TS DEPLOY_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) - ssh $SSH_OPTS "$TARGET_198" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" << MANIFEST_198_EOF + ssh $SSH_OPTS "$SEC_TARGET" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" <<-MANIFEST_SEC_EOF { "commit": "$DEPLOY_COMMIT_FULL", "commit_short": "$DEPLOY_COMMIT", @@ -464,30 +464,50 @@ if [ "$BOTH" = true ]; then "dirty": $DEPLOY_DIRTY, "deployed_at": "$DEPLOY_TS", "deployed_from": "$(hostname)", - "target": "$TARGET_198" + "target": "$SEC_TARGET" } -MANIFEST_198_EOF +MANIFEST_SEC_EOF - ssh $SSH_OPTS "$TARGET_198" "sudo systemctl start archipelago && sudo systemctl restart nginx" + ssh $SSH_OPTS "$SEC_TARGET" "sudo systemctl start archipelago && sudo systemctl restart nginx" - # Run container doctor on .198 - echo " Running container doctor on .198..." - "$SCRIPT_DIR/container-doctor.sh" "$TARGET_198" 2>&1 | sed 's/^/ /' || true + # Run container doctor + echo " Running container doctor on .$SEC_LABEL..." + "$SCRIPT_DIR/container-doctor.sh" "$SEC_TARGET" 2>&1 | sed 's/^/ /' || true - # Post-deploy health check on .198 - echo " Checking .198 health..." - HEALTH_198="fail" + # Post-deploy health check + echo " Checking .$SEC_LABEL health..." + local HEALTH="fail" for i in $(seq 1 12); do sleep 5 - HEALTH_198=$(curl -s --max-time 5 "http://192.168.1.198/health" 2>/dev/null || { echo "WARNING: Health check failed for 192.168.1.198" >&2; echo ""; }) - if [ "$HEALTH_198" = "OK" ]; then - echo " ✅ 192.168.1.198 deployed (health OK after $((i * 5))s)" + HEALTH=$(curl -s --max-time 5 "http://$SEC_IP/health" 2>/dev/null || { echo "WARNING: Health check failed for $SEC_IP" >&2; echo ""; }) + if [ "$HEALTH" = "OK" ]; then + echo " ✅ $SEC_IP deployed (health OK after $((i * 5))s)" break fi done - if [ "$HEALTH_198" != "OK" ]; then - echo " ⚠️ 192.168.1.198 deployed but health check failed after 60s" + if [ "$HEALTH" != "OK" ]; then + echo " ⚠️ $SEC_IP deployed but health check failed after 60s" fi +} + +# When --both: deploy to 228 first, then copy to 198 + 253 +if [ "$BOTH" = true ]; then + echo "Deploying to all LAN servers (228, then 198 + 253)..." + # Release lock so the recursive --live call can acquire it + rm -rf "$LOCK_DIR" 2>/dev/null; trap - EXIT + "$0" --live + echo "" + + # Fetch built binary from .228 (shared by all secondary nodes) + if ! scp $SSH_OPTS "archipelago@192.168.1.228:$TARGET_DIR/core/target/release/archipelago" /tmp/archipelago-both 2>/dev/null; then + echo " ERROR: Failed to copy binary from .228 — is the build available?" + exit 1 + fi + + # Deploy to each secondary node + deploy_secondary "archipelago@192.168.1.198" "198" + deploy_secondary "archipelago@192.168.1.253" "253" + rm -f /tmp/archipelago-both exit 0 fi @@ -547,6 +567,10 @@ if [ "$LIVE" = true ]; then elif ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then progress "Deploying backend binary" ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl stop archipelago --no-block 2>/dev/null; sleep 2; sudo kill -9 $(pgrep -x archipelago) 2>/dev/null; sleep 1; true' + if [ "$RESET_MESH" = true ]; then + echo " Wiping mesh cache (peers/messages/sessions) per --reset-mesh" + ssh $SSH_OPTS "$TARGET_HOST" 'sudo rm -f /var/lib/archipelago/messages.json /var/lib/archipelago/sessions.json /var/lib/archipelago/mesh-outbox.json 2>/dev/null; true' + fi ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/" fi diff --git a/scripts/first-boot-containers.sh b/scripts/first-boot-containers.sh index da2783b8..067f5723 100644 --- a/scripts/first-boot-containers.sh +++ b/scripts/first-boot-containers.sh @@ -109,13 +109,13 @@ if [ -f "$UNBUNDLED_MARKER" ]; then log "Creating FileBrowser (noauth)..." mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data mkdir -p /var/lib/archipelago/filebrowser/{Documents,Photos,Music,Videos,Downloads} - chown -R 1000:1000 /var/lib/archipelago/filebrowser - chown -R 1000:1000 /var/lib/archipelago/filebrowser-data + chown -R 100000:100000 /var/lib/archipelago/filebrowser + chown -R 100000:100000 /var/lib/archipelago/filebrowser-data # Write config with database on persistent volume cat > /var/lib/archipelago/filebrowser-data/.filebrowser.json <<'FBEOF' {"port":80,"baseURL":"","address":"0.0.0.0","database":"/data/filebrowser.db","root":"/srv","log":"stdout"} FBEOF - chown 1000:1000 /var/lib/archipelago/filebrowser-data/.filebrowser.json + chown 100000:100000 /var/lib/archipelago/filebrowser-data/.filebrowser.json pull_with_fallback "${FILEBROWSER_IMAGE}" $DOCKER run -d --name filebrowser --restart unless-stopped \ --network archy-net \ @@ -141,25 +141,25 @@ FBEOF chown -R 1000:1000 /var/lib/archipelago/secrets fi - # Generate WireGuard keys for VPN - if [ ! -f /var/lib/archipelago/wireguard/wg0.conf ]; then + # Generate WireGuard keys for standalone VPN (archipelago-wg service) + WG_DIR="/var/lib/archipelago/wireguard" + if [ ! -f "$WG_DIR/private.key" ]; then log "Generating WireGuard keys..." - mkdir -p /var/lib/archipelago/wireguard /etc/wireguard - PRIVKEY=$(wg genkey) - PUBKEY=$(echo "$PRIVKEY" | wg pubkey) - cat > /var/lib/archipelago/wireguard/wg0.conf </dev/null || true - wg-quick up wg0 2>>"$LOG" || true - log " WireGuard configured: pubkey=$PUBKEY" + mkdir -p "$WG_DIR" + wg genkey > "$WG_DIR/private.key" 2>/dev/null + chmod 600 "$WG_DIR/private.key" + wg pubkey < "$WG_DIR/private.key" > "$WG_DIR/public.key" + chown -R 1000:1000 "$WG_DIR" + log " WireGuard keypair generated: pubkey=$(cat "$WG_DIR/public.key")" fi + # Start standalone WireGuard service (wg0:51820 on 10.44.0.1/16) + modprobe wireguard 2>/dev/null || true + systemctl enable --now archipelago-wg 2>/dev/null || true + systemctl enable --now archipelago-wg-address 2>/dev/null || true + if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then + ufw allow 51820/udp >/dev/null 2>&1 || true + fi + log " Standalone WireGuard started (wg0:51820)" log "Unbundled first-boot complete" exit 0 @@ -242,98 +242,10 @@ else log "nostr-rs-relay binary not found — skipping relay setup" fi -# ── NostrVPN: configure native system service with node identity ────── -# The nvpn binary may have GLIBC mismatch (built for newer glibc than target OS). -# Write config.toml directly as fallback — the Rust backend reads it for vpn.invite/status. -NOSTR_SECRET=$(cat /var/lib/archipelago/identity/nostr_secret 2>/dev/null) -NOSTR_PUBKEY=$(cat /var/lib/archipelago/identity/nostr_pubkey 2>/dev/null) -if [ -n "$NOSTR_SECRET" ]; then - NVPN_CONFIG_DIR="/home/archipelago/.config/nvpn" - DAEMON_CONFIG_DIR="/var/lib/archipelago/nostr-vpn/.config/nvpn" - mkdir -p "$NVPN_CONFIG_DIR" "$DAEMON_CONFIG_DIR" - - # Try nvpn CLI first (may fail with GLIBC mismatch) - NVPN_CLI_OK=false - if command -v nvpn >/dev/null 2>&1; then - if [ ! -f "$NVPN_CONFIG_DIR/config.toml" ]; then - if su -l archipelago -c "nvpn init" 2>/dev/null; then - NVPN_CLI_OK=true - su -l archipelago -c "nvpn set --config '$NVPN_CONFIG_DIR/config.toml'" 2>/dev/null || true - else - log "NostrVPN: nvpn init failed (likely GLIBC mismatch) — using direct config" - fi - else - NVPN_CLI_OK=true - fi - fi - - # Get server's public IP for WireGuard endpoint - HOST_IP=$(cat /var/lib/archipelago/host-ip.env 2>/dev/null | grep ARCHIPELAGO_HOST_IP | cut -d= -f2) - [ -z "$HOST_IP" ] && HOST_IP=$(curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}') - - if $NVPN_CLI_OK && [ -f "$NVPN_CONFIG_DIR/config.toml" ]; then - # nvpn CLI works — use it to configure - su -l archipelago -c "nvpn set --endpoint '${HOST_IP}:51821'" 2>/dev/null || true - # Direct relay (public IP) — only if not behind NAT - if [ -n "$HOST_IP" ] && ! echo "$HOST_IP" | grep -qE '^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)'; then - su -l archipelago -c "nvpn relay add 'ws://${HOST_IP}:7777'" 2>/dev/null || true - fi - RELAY_ONION=$(cat /var/lib/archipelago/tor-hostnames/relay 2>/dev/null) - if [ -n "$RELAY_ONION" ]; then - su -l archipelago -c "nvpn relay add 'ws://${RELAY_ONION}:7777'" 2>/dev/null || true - fi - fi - - # Fallback: write config.toml directly if it doesn't exist yet. - # Uses hex keys — the Rust backend converts hex to npub1/nsec1 at read time. - if [ ! -f "$DAEMON_CONFIG_DIR/config.toml" ] && [ ! -f "$NVPN_CONFIG_DIR/config.toml" ]; then - # Build relay list - RELAYS="" - RELAY_ONION=$(cat /var/lib/archipelago/tor-hostnames/relay 2>/dev/null) - if [ -n "$RELAY_ONION" ]; then - RELAYS="\"ws://${RELAY_ONION}:7777\"" - fi - if [ -n "$HOST_IP" ] && ! echo "$HOST_IP" | grep -qE '^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)'; then - [ -n "$RELAYS" ] && RELAYS="$RELAYS, " - RELAYS="${RELAYS}\"ws://${HOST_IP}:7777\"" - fi - [ -z "$RELAYS" ] && RELAYS='"wss://relay.damus.io", "wss://relay.primal.net"' - - cat > "$DAEMON_CONFIG_DIR/config.toml" < /var/lib/archipelago/nostr-vpn/env </dev/null || true - systemctl enable --now nostr-vpn 2>/dev/null || true - log "NostrVPN configured with node identity and started" -else - log "NostrVPN: no Nostr identity yet — will configure after onboarding" -fi +# ── NostrVPN: DISABLED — using standalone WireGuard only ────────────── +# NostrVPN (nvpn) is disabled for now. Standalone WireGuard (archipelago-wg) +# handles VPN with QR-based peer provisioning via the web UI. +log "NostrVPN disabled — standalone WireGuard only (wg0:51820)" # Wait for a container to be healthy (accepting connections) wait_for_container() { @@ -497,6 +409,8 @@ grep -q "^archipelago:" /etc/subuid 2>/dev/null || { echo "archipelago:100000:65536" >> /etc/subgid log " subuid/subgid configured" } +# Apply podman migrations after subuid/subgid changes (per official tutorial) +$DOCKER system migrate 2>/dev/null || true # Ensure /etc/hosts is readable (rootless podman needs it) chmod 644 /etc/hosts 2>/dev/null diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 52f050ec..da217634 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -10,8 +10,8 @@ # to verify against the registry. # Archipelago app registries (primary + fallback) -ARCHY_REGISTRY="23.182.128.160:3000/lfg2025" -ARCHY_REGISTRY_FALLBACK="git.tx1138.com/lfg2025" +ARCHY_REGISTRY="git.tx1138.com/lfg2025" +ARCHY_REGISTRY_FALLBACK="23.182.128.160:3000/lfg2025" # Bitcoin stack BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"