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) <noreply@anthropic.com>
This commit is contained in:
parent
6760d11a57
commit
0c02d06a66
@ -75,6 +75,8 @@ impl RpcHandler {
|
|||||||
"handshake.discover" => self.handle_handshake_discover().await,
|
"handshake.discover" => self.handle_handshake_discover().await,
|
||||||
"handshake.connect" => self.handle_handshake_connect(params).await,
|
"handshake.connect" => self.handle_handshake_connect(params).await,
|
||||||
"handshake.poll" => self.handle_handshake_poll().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
|
// TOTP 2FA
|
||||||
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
||||||
|
|||||||
@ -1,14 +1,37 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
use crate::credentials;
|
use crate::credentials;
|
||||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
use crate::federation::{self, pending, FederatedNode, TrustLevel};
|
||||||
use crate::identity;
|
use crate::identity;
|
||||||
|
use crate::mesh;
|
||||||
use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
|
use crate::nostr_handshake;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
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 {
|
impl RpcHandler {
|
||||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||||
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||||
@ -65,6 +88,12 @@ impl RpcHandler {
|
|||||||
|
|
||||||
info!(peer_did = %node.did, "Joined federation with peer");
|
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
|
// Store federation membership as DWN message
|
||||||
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
||||||
let dwn_data = serde_json::json!({
|
let dwn_data = serde_json::json!({
|
||||||
@ -315,8 +344,20 @@ impl RpcHandler {
|
|||||||
let tor_active = data.server_info.tor_address.is_some();
|
let tor_active = data.server_info.tor_address.is_some();
|
||||||
|
|
||||||
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
|
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(
|
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)?)
|
Ok(serde_json::to_value(&state)?)
|
||||||
@ -395,6 +436,10 @@ impl RpcHandler {
|
|||||||
federation::add_node(&self.config.data_dir, node).await?;
|
federation::add_node(&self.config.data_dir, node).await?;
|
||||||
info!(peer_did = %did, "Peer joined our federation");
|
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 }))
|
Ok(serde_json::json!({ "accepted": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -698,11 +743,31 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let old_pubkey = node.pubkey.clone();
|
let old_pubkey = node.pubkey.clone();
|
||||||
|
let rotated_name = node.name.clone();
|
||||||
node.did = new_did.to_string();
|
node.did = new_did.to_string();
|
||||||
node.pubkey = new_pubkey.to_string();
|
node.pubkey = new_pubkey.to_string();
|
||||||
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
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!(
|
info!(
|
||||||
old_did = %old_did,
|
old_did = %old_did,
|
||||||
new_did = %new_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<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 super::RpcHandler;
|
||||||
use crate::{nostr_handshake, peers};
|
use crate::federation::pending::{
|
||||||
use anyhow::Result;
|
self, PendingPeerRequest, PendingState,
|
||||||
|
};
|
||||||
|
use crate::nostr_handshake::{self, HandshakeMessage};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::FromBech32;
|
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 {
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||||
|
// 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 identity_dir = self.config.data_dir.join("identity");
|
||||||
let nodes = nostr_handshake::discover_nodes(
|
let nodes = nostr_handshake::discover_nodes(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
@ -16,59 +124,90 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "nodes": nodes }))
|
Ok(serde_json::json!({ "nodes": nodes }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send encrypted connection request to a peer's Nostr pubkey.
|
/// Send a `PeerRequest` to a discovered node. Onion is never sent.
|
||||||
/// Params: { recipient_nostr_pubkey }
|
/// Params: `{ recipient_nostr_pubkey, message?, name? }`.
|
||||||
pub(super) async fn handle_handshake_connect(
|
pub(super) async fn handle_handshake_connect(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
// Accept either hex pubkey or npub1... bech32 format
|
|
||||||
let recipient_raw = params
|
let recipient_raw = params
|
||||||
.get("recipient_nostr_pubkey")
|
.get("recipient_nostr_pubkey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
.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)
|
nostr_sdk::PublicKey::from_bech32(recipient_raw)
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
||||||
.to_hex()
|
.to_hex()
|
||||||
} else {
|
} else {
|
||||||
recipient_raw.to_string()
|
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 (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let our_onion = data
|
let our_did =
|
||||||
.server_info
|
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||||
.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_version = &data.server_info.version;
|
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");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
nostr_handshake::send_connect_request(
|
nostr_handshake::send_peer_request(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
recipient,
|
&recipient_hex,
|
||||||
our_onion,
|
|
||||||
our_node_pubkey,
|
|
||||||
&our_did,
|
&our_did,
|
||||||
our_version,
|
our_version,
|
||||||
our_name,
|
our_name,
|
||||||
|
message,
|
||||||
&self.config.nostr_relays,
|
&self.config.nostr_relays,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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).
|
/// Poll relays for inbound NIP-44 handshake messages, then dispatch:
|
||||||
/// Auto-adds peers and auto-responds to requests.
|
/// - `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<serde_json::Value> {
|
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||||
|
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||||
|
// touch the relays. The poll endpoint is a hard no-op until they
|
||||||
|
// 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::<PendingPeerRequest>::new(),
|
||||||
|
"applied_invites": Vec::<String>::new(),
|
||||||
|
"rejected_outbound": Vec::<String>::new(),
|
||||||
|
"skipped": Vec::<String>::new(),
|
||||||
|
"discovery_disabled": true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let handshakes = nostr_handshake::poll_handshakes(
|
let handshakes = nostr_handshake::poll_handshakes(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
@ -78,72 +217,159 @@ impl RpcHandler {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
|
||||||
let mut added_peers = Vec::new();
|
let mut applied_invites: Vec<String> = Vec::new();
|
||||||
|
let mut rejected_outbound: Vec<String> = Vec::new();
|
||||||
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for hs in &handshakes {
|
for hs in &handshakes {
|
||||||
let (onion, node_pubkey, name) = match &hs.message {
|
match &hs.message {
|
||||||
nostr_handshake::HandshakeMessage::ConnectRequest {
|
HandshakeMessage::PeerRequest {
|
||||||
onion,
|
from_did,
|
||||||
node_pubkey,
|
version: _,
|
||||||
name,
|
name,
|
||||||
..
|
message,
|
||||||
} => {
|
} => {
|
||||||
// Auto-respond with our details
|
match pending::insert_inbound(
|
||||||
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
|
&self.config.data_dir,
|
||||||
let our_did = crate::identity::did_key_from_pubkey_hex(
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
&data.server_info.pubkey,
|
||||||
)
|
)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let _ = nostr_handshake::send_connect_response(
|
let local_onion = data
|
||||||
&identity_dir,
|
.server_info
|
||||||
&hs.from_nostr_pubkey,
|
.tor_address
|
||||||
our_onion,
|
.clone()
|
||||||
&data.server_info.pubkey,
|
.unwrap_or_default();
|
||||||
&our_did,
|
let local_pubkey = data.server_info.pubkey.clone();
|
||||||
&data.server_info.version,
|
|
||||||
data.server_info.name.as_deref(),
|
let identity_dir2 = self.config.data_dir.join("identity");
|
||||||
&self.config.nostr_relays,
|
let node_identity =
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
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;
|
.await;
|
||||||
}
|
}
|
||||||
(onion.clone(), node_pubkey.clone(), name.clone())
|
|
||||||
}
|
|
||||||
nostr_handshake::HandshakeMessage::ConnectResponse {
|
|
||||||
onion,
|
|
||||||
node_pubkey,
|
|
||||||
name,
|
|
||||||
..
|
|
||||||
} => (onion.clone(), node_pubkey.clone(), name.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-add as peer
|
pending::set_state(
|
||||||
let peer = peers::KnownPeer {
|
&self.config.data_dir,
|
||||||
onion,
|
&row_id,
|
||||||
pubkey: node_pubkey.clone(),
|
PendingState::Approved,
|
||||||
name,
|
)
|
||||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
.await?;
|
||||||
};
|
applied_invites.push(node.did);
|
||||||
let _ = peers::add_peer(&self.config.data_dir, peer).await;
|
}
|
||||||
added_peers.push(node_pubkey);
|
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<serde_json::Value> = 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!({
|
Ok(serde_json::json!({
|
||||||
"handshakes": serialized,
|
"polled": handshakes.len(),
|
||||||
"added_peers": added_peers,
|
"new_requests": new_requests,
|
||||||
|
"applied_invites": applied_invites,
|
||||||
|
"rejected_outbound": rejected_outbound,
|
||||||
|
"skipped": skipped,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,6 +123,7 @@ impl RpcHandler {
|
|||||||
&req_msg.to_string(),
|
&req_msg.to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
// Also add them as a pending peer locally
|
// Also add them as a pending peer locally
|
||||||
|
|||||||
@ -910,6 +910,7 @@ pub(super) async fn get_app_config(
|
|||||||
"GITEA__packages__ENABLED=true".to_string(),
|
"GITEA__packages__ENABLED=true".to_string(),
|
||||||
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true".to_string(),
|
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true".to_string(),
|
||||||
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(),
|
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(),
|
||||||
|
"GITEA__security__X_FRAME_OPTIONS=".to_string(),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -261,6 +261,7 @@ impl RpcHandler {
|
|||||||
if !is_tailscale {
|
if !is_tailscale {
|
||||||
run_args.push("--cap-drop=ALL");
|
run_args.push("--cap-drop=ALL");
|
||||||
run_args.push("--security-opt=no-new-privileges:true");
|
run_args.push("--security-opt=no-new-privileges:true");
|
||||||
|
run_args.push("--pids-limit=4096");
|
||||||
for cap in &security_caps {
|
for cap in &security_caps {
|
||||||
run_args.push(cap);
|
run_args.push(cap);
|
||||||
}
|
}
|
||||||
@ -600,11 +601,11 @@ impl RpcHandler {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.context("Failed to start image pull")?;
|
.context("Failed to start image pull")?;
|
||||||
|
|
||||||
// Wrap the entire pull (stderr progress + wait) in a 60s timeout.
|
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
|
||||||
// If the registry is unreachable, the pull hangs on DNS/TCP and the
|
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
|
||||||
// stderr reader never returns — so the timeout must cover everything.
|
// to pull. 60s was too short and caused premature retries on slow registries.
|
||||||
let pull_result = tokio::time::timeout(
|
let pull_result = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(60),
|
std::time::Duration::from_secs(600),
|
||||||
async {
|
async {
|
||||||
if let Some(stderr) = child.stderr.take() {
|
if let Some(stderr) = child.stderr.take() {
|
||||||
let reader = BufReader::new(stderr);
|
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 host_ip = &self.config.host_ip;
|
||||||
let root_url = format!("GITEA__server__ROOT_URL=http://{}:3001/", host_ip);
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args(["exec", "gitea", "sh", "-c",
|
.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()
|
.output()
|
||||||
.await;
|
.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" {
|
if package_id == "nextcloud" {
|
||||||
|
|||||||
@ -138,6 +138,8 @@ impl RpcHandler {
|
|||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let db_pass = super::config::read_or_generate_secret("immich-db-password").await;
|
||||||
|
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
.args([
|
.args([
|
||||||
"run",
|
"run",
|
||||||
@ -148,10 +150,24 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"immich-net",
|
"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",
|
"-v",
|
||||||
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
||||||
"-e",
|
"-e",
|
||||||
"POSTGRES_PASSWORD=immichpass",
|
&format!("POSTGRES_PASSWORD={}", db_pass),
|
||||||
"-e",
|
"-e",
|
||||||
"POSTGRES_USER=postgres",
|
"POSTGRES_USER=postgres",
|
||||||
"-e",
|
"-e",
|
||||||
@ -172,6 +188,15 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"immich-net",
|
"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",
|
"git.tx1138.com/lfg2025/valkey:7-alpine",
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
@ -188,6 +213,12 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"immich-net",
|
"immich-net",
|
||||||
|
"--network-alias",
|
||||||
|
"immich_server",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
|
"--memory=2g",
|
||||||
|
"--pids-limit=4096",
|
||||||
"-p",
|
"-p",
|
||||||
"2283:2283",
|
"2283:2283",
|
||||||
"-v",
|
"-v",
|
||||||
@ -197,7 +228,7 @@ impl RpcHandler {
|
|||||||
"-e",
|
"-e",
|
||||||
"DB_USERNAME=postgres",
|
"DB_USERNAME=postgres",
|
||||||
"-e",
|
"-e",
|
||||||
"DB_PASSWORD=immichpass",
|
&format!("DB_PASSWORD={}", db_pass),
|
||||||
"-e",
|
"-e",
|
||||||
"DB_DATABASE_NAME=immich",
|
"DB_DATABASE_NAME=immich",
|
||||||
"-e",
|
"-e",
|
||||||
@ -276,6 +307,20 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"penpot-net",
|
"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",
|
"-v",
|
||||||
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||||
"-e",
|
"-e",
|
||||||
@ -300,6 +345,15 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"penpot-net",
|
"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",
|
"-e",
|
||||||
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||||
"git.tx1138.com/lfg2025/valkey:8.1",
|
"git.tx1138.com/lfg2025/valkey:8.1",
|
||||||
@ -318,6 +372,12 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"penpot-net",
|
"penpot-net",
|
||||||
|
"--network-alias",
|
||||||
|
"penpot-backend",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
|
"--memory=1g",
|
||||||
|
"--pids-limit=4096",
|
||||||
"-v",
|
"-v",
|
||||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||||
"-e",
|
"-e",
|
||||||
@ -354,6 +414,12 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"penpot-net",
|
"penpot-net",
|
||||||
|
"--network-alias",
|
||||||
|
"penpot-exporter",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
|
"--memory=512m",
|
||||||
|
"--pids-limit=2048",
|
||||||
"-e",
|
"-e",
|
||||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||||
"-e",
|
"-e",
|
||||||
@ -376,6 +442,12 @@ impl RpcHandler {
|
|||||||
"unless-stopped",
|
"unless-stopped",
|
||||||
"--network",
|
"--network",
|
||||||
"penpot-net",
|
"penpot-net",
|
||||||
|
"--network-alias",
|
||||||
|
"penpot-frontend",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
|
"--memory=512m",
|
||||||
|
"--pids-limit=2048",
|
||||||
"-p",
|
"-p",
|
||||||
"9001:8080",
|
"9001:8080",
|
||||||
"-v",
|
"-v",
|
||||||
@ -473,7 +545,18 @@ impl RpcHandler {
|
|||||||
"--restart", "unless-stopped",
|
"--restart", "unless-stopped",
|
||||||
"--network", "archy-net",
|
"--network", "archy-net",
|
||||||
"--network-alias", "archy-btcpay-db",
|
"--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",
|
"--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",
|
"-v", "/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data",
|
||||||
"-e", "POSTGRES_DB=btcpay",
|
"-e", "POSTGRES_DB=btcpay",
|
||||||
"-e", "POSTGRES_USER=btcpay",
|
"-e", "POSTGRES_USER=btcpay",
|
||||||
@ -501,7 +584,10 @@ impl RpcHandler {
|
|||||||
"--restart", "unless-stopped",
|
"--restart", "unless-stopped",
|
||||||
"--network", "archy-net",
|
"--network", "archy-net",
|
||||||
"--network-alias", "archy-nbxplorer",
|
"--network-alias", "archy-nbxplorer",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
"--memory=512m",
|
"--memory=512m",
|
||||||
|
"--pids-limit=4096",
|
||||||
"-p", "32838:32838",
|
"-p", "32838:32838",
|
||||||
"-v", "/var/lib/archipelago/nbxplorer:/data",
|
"-v", "/var/lib/archipelago/nbxplorer:/data",
|
||||||
"-e", "NBXPLORER_DATADIR=/data",
|
"-e", "NBXPLORER_DATADIR=/data",
|
||||||
@ -531,7 +617,10 @@ impl RpcHandler {
|
|||||||
"--restart", "unless-stopped",
|
"--restart", "unless-stopped",
|
||||||
"--network", "archy-net",
|
"--network", "archy-net",
|
||||||
"--network-alias", "btcpay-server",
|
"--network-alias", "btcpay-server",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
"--memory=1g",
|
"--memory=1g",
|
||||||
|
"--pids-limit=4096",
|
||||||
"-p", "23000:49392",
|
"-p", "23000:49392",
|
||||||
"-v", "/var/lib/archipelago/btcpay:/datadir",
|
"-v", "/var/lib/archipelago/btcpay:/datadir",
|
||||||
"-e", "ASPNETCORE_URLS=http://0.0.0.0:49392",
|
"-e", "ASPNETCORE_URLS=http://0.0.0.0:49392",
|
||||||
@ -632,7 +721,18 @@ impl RpcHandler {
|
|||||||
"--restart", "unless-stopped",
|
"--restart", "unless-stopped",
|
||||||
"--network", "archy-net",
|
"--network", "archy-net",
|
||||||
"--network-alias", "archy-mempool-db",
|
"--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",
|
"--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",
|
"-v", "/var/lib/archipelago/mysql-mempool:/var/lib/mysql",
|
||||||
"-e", "MYSQL_DATABASE=mempool",
|
"-e", "MYSQL_DATABASE=mempool",
|
||||||
"-e", "MYSQL_USER=mempool",
|
"-e", "MYSQL_USER=mempool",
|
||||||
@ -652,7 +752,10 @@ impl RpcHandler {
|
|||||||
"--restart", "unless-stopped",
|
"--restart", "unless-stopped",
|
||||||
"--network", "archy-net",
|
"--network", "archy-net",
|
||||||
"--network-alias", "mempool-api",
|
"--network-alias", "mempool-api",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
"--memory=512m",
|
"--memory=512m",
|
||||||
|
"--pids-limit=4096",
|
||||||
"-p", "8999:8999",
|
"-p", "8999:8999",
|
||||||
"-v", "/var/lib/archipelago/mempool:/data",
|
"-v", "/var/lib/archipelago/mempool:/data",
|
||||||
"-e", "MEMPOOL_BACKEND=electrum",
|
"-e", "MEMPOOL_BACKEND=electrum",
|
||||||
@ -682,7 +785,10 @@ impl RpcHandler {
|
|||||||
"--restart", "unless-stopped",
|
"--restart", "unless-stopped",
|
||||||
"--network", "archy-net",
|
"--network", "archy-net",
|
||||||
"--network-alias", "mempool",
|
"--network-alias", "mempool",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges:true",
|
||||||
"--memory=256m",
|
"--memory=256m",
|
||||||
|
"--pids-limit=2048",
|
||||||
"-p", "4080:8080",
|
"-p", "4080:8080",
|
||||||
"-e", "FRONTEND_HTTP_PORT=8080",
|
"-e", "FRONTEND_HTTP_PORT=8080",
|
||||||
"-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
|
"-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
|
||||||
@ -718,7 +824,7 @@ impl RpcHandler {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|r| r.enabled)
|
.find(|r| r.enabled)
|
||||||
.map(|r| r.url)
|
.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!(
|
let user_tmp = format!(
|
||||||
"{}/.local/share/containers/tmp",
|
"{}/.local/share/containers/tmp",
|
||||||
@ -740,16 +846,13 @@ impl RpcHandler {
|
|||||||
format!("{}/indeedhub:1.0.0", registry),
|
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 {
|
for img in &images {
|
||||||
info!("Pulling {}", img);
|
info!("Pulling {}", img);
|
||||||
let status = tokio::process::Command::new("podman")
|
pull_image_with_retry(img).await
|
||||||
.args(["pull", img, "--tls-verify=false"])
|
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
|
||||||
.env("TMPDIR", &user_tmp)
|
|
||||||
.status()
|
|
||||||
.await;
|
|
||||||
if !status.map(|s| s.success()).unwrap_or(false) {
|
|
||||||
tracing::warn!("Failed to pull {}", img);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create indeedhub-net
|
// Create indeedhub-net
|
||||||
|
|||||||
@ -90,6 +90,15 @@ impl RpcHandler {
|
|||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let pubkey = data.server_info.pubkey.clone();
|
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
|
// Load signing key for E2E encryption
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
|||||||
@ -587,42 +587,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NostrVPN mesh participants (from nvpn config)
|
// NostrVPN peer loading removed — standalone WireGuard only
|
||||||
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::<toml::Table>() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({ "peers": peers }))
|
Ok(serde_json::json!({ "peers": peers }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -199,7 +199,12 @@ impl Default for Config {
|
|||||||
port_offset: 10000,
|
port_offset: 10000,
|
||||||
bitcoin_simulation: BitcoinSimulation::Mock,
|
bitcoin_simulation: BitcoinSimulation::Mock,
|
||||||
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
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![
|
nostr_relays: vec![
|
||||||
"wss://relay.damus.io".into(),
|
"wss://relay.damus.io".into(),
|
||||||
"wss://relay.nostr.info".into(),
|
"wss://relay.nostr.info".into(),
|
||||||
@ -223,7 +228,7 @@ mod tests {
|
|||||||
assert_eq!(config.host_ip, "127.0.0.1");
|
assert_eq!(config.host_ip, "127.0.0.1");
|
||||||
assert!(!config.dev_mode);
|
assert!(!config.dev_mode);
|
||||||
assert_eq!(config.port_offset, 10000);
|
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_relays.len(), 2);
|
||||||
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
|
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
|
||||||
}
|
}
|
||||||
@ -333,9 +338,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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();
|
let config = Config::default();
|
||||||
assert!(config.nostr_discovery_enabled);
|
assert!(!config.nostr_discovery_enabled);
|
||||||
assert!(config.nostr_tor_proxy.is_some());
|
assert!(config.nostr_tor_proxy.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,16 +44,16 @@ impl Default for RegistryConfig {
|
|||||||
Self {
|
Self {
|
||||||
registries: vec![
|
registries: vec![
|
||||||
Registry {
|
Registry {
|
||||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
url: "git.tx1138.com/lfg2025".to_string(),
|
||||||
name: "Archipelago Primary".to_string(),
|
name: "Archipelago Primary".to_string(),
|
||||||
tls_verify: false,
|
tls_verify: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
},
|
},
|
||||||
Registry {
|
Registry {
|
||||||
url: "git.tx1138.com/lfg2025".to_string(),
|
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||||
name: "Archipelago Legacy".to_string(),
|
name: "Archipelago Fallback".to_string(),
|
||||||
tls_verify: true,
|
tls_verify: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 10,
|
priority: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::path::Path;
|
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};
|
use super::types::{FederatedNode, FederationInvite, TrustLevel};
|
||||||
|
|
||||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||||
@ -94,10 +94,28 @@ pub async fn accept_invite(
|
|||||||
) -> Result<FederatedNode> {
|
) -> Result<FederatedNode> {
|
||||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||||
|
|
||||||
// Check not already federated
|
// Make accept idempotent: drop any existing entry that conflicts with
|
||||||
let nodes = load_nodes(data_dir).await?;
|
// this invite — same DID (same node, refreshing the link), same onion
|
||||||
if nodes.iter().any(|n| n.did == did) {
|
// (node rotated identity but kept its hidden service), or same pubkey
|
||||||
anyhow::bail!("Already federated with node {}", did);
|
// (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 {
|
let node = FederatedNode {
|
||||||
@ -226,7 +244,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 dir = tempfile::tempdir().unwrap();
|
||||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||||
.await
|
.await
|
||||||
@ -244,8 +266,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Accepting the same invite again should fail
|
accept_invite(
|
||||||
let result = accept_invite(
|
|
||||||
dir2.path(),
|
dir2.path(),
|
||||||
&code,
|
&code,
|
||||||
"did:key:zLocal",
|
"did:key:zLocal",
|
||||||
@ -253,7 +274,10 @@ mod tests {
|
|||||||
"localpub",
|
"localpub",
|
||||||
|_| "test-sig".to_string(),
|
|_| "test-sig".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
assert!(result.is_err());
|
.unwrap();
|
||||||
|
|
||||||
|
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||||
|
assert_eq!(nodes.len(), 1, "re-accept should not duplicate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
//! sync container status, health metrics, and availability.
|
//! sync container status, health metrics, and availability.
|
||||||
|
|
||||||
mod invites;
|
mod invites;
|
||||||
|
pub mod pending;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod types;
|
mod types;
|
||||||
|
|||||||
312
core/archipelago/src/federation/pending.rs
Normal file
312
core/archipelago/src/federation/pending.rs
Normal file
@ -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<String>,
|
||||||
|
/// Optional one-line message the requester attached.
|
||||||
|
pub message: Option<String>,
|
||||||
|
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<PendingPeerRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_pending(data_dir: &Path) -> Result<Vec<PendingPeerRequest>> {
|
||||||
|
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<PendingPeerRequest>) {
|
||||||
|
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<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
) -> Result<Option<PendingPeerRequest>> {
|
||||||
|
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<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
) -> Result<PendingPeerRequest> {
|
||||||
|
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<Option<PendingPeerRequest>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -264,6 +264,7 @@ mod tests {
|
|||||||
disk_total_bytes: None,
|
disk_total_bytes: None,
|
||||||
uptime_secs: Some(86400),
|
uptime_secs: Some(86400),
|
||||||
tor_active: Some(true),
|
tor_active: Some(true),
|
||||||
|
nostr_npub: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
update_node_state(dir.path(), "did:key:z1", state)
|
update_node_state(dir.path(), "did:key:z1", state)
|
||||||
|
|||||||
@ -74,6 +74,7 @@ pub fn build_local_state(
|
|||||||
uptime: u64,
|
uptime: u64,
|
||||||
tor_active: bool,
|
tor_active: bool,
|
||||||
server_name: Option<String>,
|
server_name: Option<String>,
|
||||||
|
nostr_npub: Option<String>,
|
||||||
) -> NodeStateSnapshot {
|
) -> NodeStateSnapshot {
|
||||||
NodeStateSnapshot {
|
NodeStateSnapshot {
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
@ -86,6 +87,7 @@ pub fn build_local_state(
|
|||||||
disk_total_bytes: Some(disk_total),
|
disk_total_bytes: Some(disk_total),
|
||||||
uptime_secs: Some(uptime),
|
uptime_secs: Some(uptime),
|
||||||
tor_active: Some(tor_active),
|
tor_active: Some(tor_active),
|
||||||
|
nostr_npub,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,6 +182,7 @@ mod tests {
|
|||||||
3600,
|
3600,
|
||||||
true,
|
true,
|
||||||
Some("Test Node".to_string()),
|
Some("Test Node".to_string()),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(state.apps.len(), 1);
|
assert_eq!(state.apps.len(), 1);
|
||||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||||
|
|||||||
@ -59,6 +59,11 @@ pub struct NodeStateSnapshot {
|
|||||||
pub uptime_secs: Option<u64>,
|
pub uptime_secs: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tor_active: Option<bool>,
|
pub tor_active: Option<bool>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status of a single app/container on a remote node.
|
/// Status of a single app/container on a remote node.
|
||||||
|
|||||||
@ -201,16 +201,34 @@ impl MeshState {
|
|||||||
|
|
||||||
pub async fn store_message(&self, msg: MeshMessage) {
|
pub async fn store_message(&self, msg: MeshMessage) {
|
||||||
let mut messages = self.messages.write().await;
|
let mut messages = self.messages.write().await;
|
||||||
// Deduplicate: skip if we already have a message with the same text,
|
// Deduplicate RECEIVED messages only — a Sent record is the user's
|
||||||
// peer, and timestamp within 30 seconds (prevents echo-back doubles)
|
// own action and must ALWAYS be shown, even when the display text
|
||||||
let dominated = messages.iter().rev().take(20).any(|m| {
|
// collides with an earlier one (e.g. two 👍 reactions to different
|
||||||
m.peer_contact_id == msg.peer_contact_id
|
// 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
|
&& m.plaintext == msg.plaintext
|
||||||
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
|
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
|
||||||
});
|
})
|
||||||
|
};
|
||||||
if dominated {
|
if dominated {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
messages.push_back(msg);
|
messages.push_back(msg);
|
||||||
if messages.len() > MAX_MESSAGES {
|
if messages.len() > MAX_MESSAGES {
|
||||||
messages.pop_front();
|
messages.pop_front();
|
||||||
|
|||||||
@ -628,6 +628,12 @@ impl MeshService {
|
|||||||
|
|
||||||
/// Send raw wire payload bytes to a peer (no Sent-record bookkeeping).
|
/// Send raw wire payload bytes to a peer (no Sent-record bookkeeping).
|
||||||
/// Callers are responsible for storing the MeshMessage record afterwards.
|
/// 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<u8>) -> Result<()> {
|
async fn send_raw_payload(&self, contact_id: u32, payload: Vec<u8>) -> Result<()> {
|
||||||
let status = self.state.status.read().await;
|
let status = self.state.status.read().await;
|
||||||
if !status.device_connected {
|
if !status.device_connected {
|
||||||
@ -635,14 +641,6 @@ impl MeshService {
|
|||||||
}
|
}
|
||||||
drop(status);
|
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?;
|
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||||
|
|
||||||
self.state.send_cmd(listener::MeshCommand::SendText {
|
self.state.send_cmd(listener::MeshCommand::SendText {
|
||||||
@ -714,17 +712,11 @@ impl MeshService {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
if exceeds_lora {
|
// No federation path — fall through to send_raw_payload, which
|
||||||
// No federation path — fall back to send-side chunking. Receive
|
// hands the wire to the lower DM-via-channel layer. That layer
|
||||||
// side already handles MC-framed base64 reassembly for up to 20
|
// (`send_dm_via_channel` in listener/session.rs) handles both
|
||||||
// chunks (~3KB) per message, which is plenty for ContentRef or
|
// single-frame and chunked transmission internally; we must NOT
|
||||||
// long replies when the peer is LoRa-only.
|
// pre-chunk here as well or the receiver sees garbage.
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
self.send_raw_payload(contact_id, wire).await?;
|
self.send_raw_payload(contact_id, wire).await?;
|
||||||
Ok(self
|
Ok(self
|
||||||
|
|||||||
@ -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
|
//! Goals:
|
||||||
//! them privately via NIP-44 encrypted DMs:
|
//! - 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)
|
//! Result: the only thing ever visible on a public Nostr relay is the
|
||||||
//! 2. To connect, Node A sends NIP-44 encrypted DM to Node B's Nostr pubkey
|
//! presence event (a DID + a npub + a version). Everything actionable
|
||||||
//! containing A's onion address + Ed25519 node pubkey
|
//! lives inside NIP-44 ciphertext addressed to a specific nostr 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.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@ -22,26 +35,39 @@ use tracing::warn;
|
|||||||
|
|
||||||
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum HandshakeMessage {
|
pub enum HandshakeMessage {
|
||||||
#[serde(rename = "connect-request")]
|
/// Inbound peer-discovery request. The sender proves they hold the
|
||||||
ConnectRequest {
|
/// nostr secret key by signing the kind-4 envelope (Nostr does this
|
||||||
onion: String,
|
/// at the protocol layer). They claim a `from_did` here, but we do
|
||||||
node_pubkey: String,
|
/// not trust it until the federation invite round-trip completes.
|
||||||
did: String,
|
#[serde(rename = "peer-request")]
|
||||||
version: String,
|
PeerRequest {
|
||||||
name: Option<String>,
|
from_did: String,
|
||||||
},
|
|
||||||
#[serde(rename = "connect-response")]
|
|
||||||
ConnectResponse {
|
|
||||||
onion: String,
|
|
||||||
node_pubkey: String,
|
|
||||||
did: String,
|
|
||||||
version: String,
|
version: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// 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<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of polling for incoming handshake messages
|
/// Result of polling for incoming handshake messages
|
||||||
@ -219,16 +245,16 @@ pub async fn discover_nodes(
|
|||||||
Ok(nodes)
|
Ok(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send an encrypted connection request to a peer's Nostr pubkey.
|
/// Encrypt and publish a `HandshakeMessage` to a recipient's nostr pubkey.
|
||||||
/// Uses NIP-44 encrypted DM (kind 4) containing our onion address.
|
/// Used by both the request-side (PeerRequest) and the approver-side
|
||||||
pub async fn send_connect_request(
|
/// (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,
|
identity_dir: &Path,
|
||||||
recipient_nostr_pubkey: &str,
|
recipient_nostr_pubkey: &str,
|
||||||
our_onion: &str,
|
msg: &HandshakeMessage,
|
||||||
our_node_pubkey: &str,
|
|
||||||
our_did: &str,
|
|
||||||
our_version: &str,
|
|
||||||
our_name: Option<&str>,
|
|
||||||
relays: &[String],
|
relays: &[String],
|
||||||
tor_proxy: Option<&str>,
|
tor_proxy: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@ -239,20 +265,10 @@ pub async fn send_connect_request(
|
|||||||
let keys = load_nostr_keys(identity_dir)
|
let keys = load_nostr_keys(identity_dir)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
.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)
|
let plaintext = serde_json::to_string(msg).context("Failed to serialize handshake")?;
|
||||||
.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 encrypted = nip44::encrypt(
|
let encrypted = nip44::encrypt(
|
||||||
keys.secret_key(),
|
keys.secret_key(),
|
||||||
&recipient_pk,
|
&recipient_pk,
|
||||||
@ -265,79 +281,86 @@ pub async fn send_connect_request(
|
|||||||
for url in relays {
|
for url in relays {
|
||||||
let _ = client.add_relay(url).await;
|
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");
|
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kind 4 encrypted DM with p-tag for recipient
|
let builder =
|
||||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
EventBuilder::new(Kind::EncryptedDirectMessage, encrypted).tag(Tag::public_key(recipient_pk));
|
||||||
.tag(Tag::public_key(recipient_pk));
|
|
||||||
let _ = client.send_event_builder(builder).await;
|
let _ = client.send_event_builder(builder).await;
|
||||||
client.disconnect().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!(
|
tracing::info!(
|
||||||
"🤝 Sent encrypted connect request to {}...{}",
|
"🤝 Sent peer-request to {}...{}",
|
||||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send an encrypted connection response to a peer.
|
/// Send a `PeerInvite` reply containing a one-shot federation invite code.
|
||||||
pub async fn send_connect_response(
|
/// 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,
|
identity_dir: &Path,
|
||||||
recipient_nostr_pubkey: &str,
|
recipient_nostr_pubkey: &str,
|
||||||
our_onion: &str,
|
invite_code: &str,
|
||||||
our_node_pubkey: &str,
|
|
||||||
our_did: &str,
|
|
||||||
our_version: &str,
|
|
||||||
our_name: Option<&str>,
|
|
||||||
relays: &[String],
|
relays: &[String],
|
||||||
tor_proxy: Option<&str>,
|
tor_proxy: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if relays.is_empty() {
|
let msg = HandshakeMessage::PeerInvite {
|
||||||
anyhow::bail!("No relays configured");
|
invite_code: invite_code.to_string(),
|
||||||
}
|
|
||||||
|
|
||||||
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 plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
tracing::info!(
|
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[..8.min(recipient_nostr_pubkey.len())],
|
||||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||||
);
|
);
|
||||||
|
|||||||
@ -222,6 +222,7 @@ mod tests {
|
|||||||
disk_total_bytes: Some(1_800_000_000_000),
|
disk_total_bytes: Some(1_800_000_000_000),
|
||||||
uptime_secs: Some(86400),
|
uptime_secs: Some(86400),
|
||||||
tor_active: Some(true),
|
tor_active: Some(true),
|
||||||
|
nostr_npub: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,6 +254,7 @@ mod tests {
|
|||||||
disk_total_bytes: Some(1_800_000_000_000),
|
disk_total_bytes: Some(1_800_000_000_000),
|
||||||
uptime_secs: Some(86700), // Changed
|
uptime_secs: Some(86700), // Changed
|
||||||
tor_active: Some(true),
|
tor_active: Some(true),
|
||||||
|
nostr_npub: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -281,17 +281,14 @@ pub fn generate_wireguard_conf(config: &WireGuardConfig) -> String {
|
|||||||
|
|
||||||
/// Get the current VPN status by checking network interfaces.
|
/// Get the current VPN status by checking network interfaces.
|
||||||
pub async fn get_status() -> VpnStatus {
|
pub async fn get_status() -> VpnStatus {
|
||||||
// Check for NostrVPN (native system service)
|
// NostrVPN disabled — standalone WireGuard only
|
||||||
if let Ok(nvpn) = get_nostr_vpn_status().await {
|
|
||||||
return nvpn;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for Tailscale interface
|
// Check for Tailscale interface
|
||||||
if let Ok(tailscale) = get_tailscale_status().await {
|
if let Ok(tailscale) = get_tailscale_status().await {
|
||||||
return tailscale;
|
return tailscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for WireGuard interface
|
// Check for WireGuard interface (wg0)
|
||||||
if let Ok(wg) = get_wireguard_status().await {
|
if let Ok(wg) = get_wireguard_status().await {
|
||||||
return wg;
|
return wg;
|
||||||
}
|
}
|
||||||
|
|||||||
899
docs/lora-functionality.html
Normal file
899
docs/lora-functionality.html
Normal file
@ -0,0 +1,899 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Archipelago — LoRa & Mesh Functionality Guide</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #000000;
|
||||||
|
--glass-card: rgba(0, 0, 0, 0.65);
|
||||||
|
--glass-dark: rgba(0, 0, 0, 0.35);
|
||||||
|
--glass-darker: rgba(0, 0, 0, 0.6);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
--glass-highlight: rgba(255, 255, 255, 0.22);
|
||||||
|
--glass-blur: 18px;
|
||||||
|
--glass-blur-strong: 24px;
|
||||||
|
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
|
--text: rgba(255, 255, 255, 0.9);
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.6);
|
||||||
|
--accent: #fb923c;
|
||||||
|
--accent-dim: rgba(251, 146, 60, 0.15);
|
||||||
|
--green: #4ade80;
|
||||||
|
--green-dim: rgba(74, 222, 128, 0.15);
|
||||||
|
--red: #ef4444;
|
||||||
|
--red-dim: rgba(239, 68, 68, 0.12);
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--blue-dim: rgba(59, 130, 246, 0.12);
|
||||||
|
--yellow: #facc15;
|
||||||
|
--yellow-dim: rgba(250, 204, 21, 0.12);
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--purple-dim: rgba(167, 139, 250, 0.12);
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border-right: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 0;
|
||||||
|
z-index: 100;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||||
|
}
|
||||||
|
nav .logo { padding: 0 24px 20px; margin-bottom: 16px; }
|
||||||
|
nav .logo h1 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 18px; font-weight: 700;
|
||||||
|
color: var(--accent); letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
nav .logo p { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
nav .nav-section {
|
||||||
|
padding: 12px 16px 4px;
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
nav a:hover, nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-left: 280px;
|
||||||
|
max-width: 960px;
|
||||||
|
padding: 48px 48px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 28px; font-weight: 700;
|
||||||
|
margin: 64px 0 8px;
|
||||||
|
padding-top: 24px;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
h2:first-of-type { margin-top: 0; }
|
||||||
|
h3 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 20px; font-weight: 600;
|
||||||
|
margin: 40px 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 16px; font-weight: 600;
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
p { margin: 8px 0 16px; color: var(--text); }
|
||||||
|
ul, ol { margin: 8px 0 16px 24px; color: var(--text); }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero { text-align: center; padding: 48px 0 56px; margin-bottom: 24px; }
|
||||||
|
.hero h1 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 42px; font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #f59e0b);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero .tagline {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 12px auto 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
.hero .meta {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex; gap: 16px;
|
||||||
|
justify-content: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hero .meta span {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--glass-dark);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.card-sm {
|
||||||
|
background: var(--glass-darker);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.card-sm:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
|
||||||
|
.card-sm p { font-size: 13px; color: var(--text-muted); margin: 0; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||||
|
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||||
|
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
|
||||||
|
.badge-blue { background: var(--blue-dim); color: var(--blue); }
|
||||||
|
.badge-purple { background: var(--purple-dim); color: var(--purple); }
|
||||||
|
.badge-accent { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(255, 255, 255, 0.04); }
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
pre code { background: none; padding: 0; color: var(--text); }
|
||||||
|
|
||||||
|
.diagram {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Menlo', 'Monaco', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.diagram .highlight { color: var(--accent); font-weight: 600; }
|
||||||
|
.diagram .green { color: var(--green); }
|
||||||
|
.diagram .blue { color: var(--blue); }
|
||||||
|
.diagram .red { color: var(--red); }
|
||||||
|
.diagram .purple { color: var(--purple); }
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-left: 3px solid;
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
.callout-info { border-color: var(--blue); }
|
||||||
|
.callout-warn { border-color: var(--yellow); }
|
||||||
|
.callout-danger { border-color: var(--red); }
|
||||||
|
.callout-success { border-color: var(--green); }
|
||||||
|
.callout-learn {
|
||||||
|
border-color: var(--purple);
|
||||||
|
background: rgba(167, 139, 250, 0.06);
|
||||||
|
position: relative;
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
.callout-learn::before {
|
||||||
|
content: 'Layman Analogy';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px; left: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
.callout strong { display: block; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.score-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.score-card {
|
||||||
|
background: var(--glass-darker);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform var(--transition);
|
||||||
|
}
|
||||||
|
.score-card:hover { transform: translateY(-2px); }
|
||||||
|
.score-card .score {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 28px; font-weight: 800;
|
||||||
|
margin: 4px 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.score-card .label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
margin: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
nav { display: none; }
|
||||||
|
main { margin-left: 0; padding: 20px; }
|
||||||
|
.hero h1 { font-size: 32px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<div class="logo">
|
||||||
|
<h1>Archipelago</h1>
|
||||||
|
<p>LoRa & Mesh Guide</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-section">Overview</div>
|
||||||
|
<a href="#intro">Introduction</a>
|
||||||
|
<a href="#layman">What is LoRa?</a>
|
||||||
|
<a href="#why">Why Archipelago uses it</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Stack</div>
|
||||||
|
<a href="#hardware">Hardware & Firmware</a>
|
||||||
|
<a href="#serial">USB Serial Transport</a>
|
||||||
|
<a href="#wire">Wire Format</a>
|
||||||
|
<a href="#crypto">Encryption Layers</a>
|
||||||
|
<a href="#fragmentation">Fragmentation</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Routing</div>
|
||||||
|
<a href="#dual-transport">Dual Transport</a>
|
||||||
|
<a href="#addressing">Addressing</a>
|
||||||
|
<a href="#synthetic">Federation Contacts</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Messages</div>
|
||||||
|
<a href="#msg-overview">All 23 Types</a>
|
||||||
|
<a href="#msg-text">Text / Reply / Edit</a>
|
||||||
|
<a href="#msg-social">Reactions & Receipts</a>
|
||||||
|
<a href="#msg-content">Content / Files</a>
|
||||||
|
<a href="#msg-bitcoin">Bitcoin & Lightning</a>
|
||||||
|
<a href="#msg-safety">Alerts & Presence</a>
|
||||||
|
<a href="#msg-identity">Identity & Keys</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Operations</div>
|
||||||
|
<a href="#rpc">RPC API</a>
|
||||||
|
<a href="#ui">User Interface</a>
|
||||||
|
<a href="#listener">Listener Loop</a>
|
||||||
|
<a href="#files">File Map</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>LoRa & Mesh Functionality</h1>
|
||||||
|
<p class="tagline">How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Meshcore Companion USB</span>
|
||||||
|
<span>Double Ratchet E2E</span>
|
||||||
|
<span>23 Message Types</span>
|
||||||
|
<span>160-byte LoRa Frame</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 id="intro">Introduction</h2>
|
||||||
|
<p>This document explains Archipelago's mesh subsystem — the code under <code>core/archipelago/src/mesh/</code> that lets nodes talk to each other over <strong>LoRa radio</strong> 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.</p>
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<h2 id="layman">What is LoRa? <span class="badge badge-purple">Layman</span></h2>
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>Think of LoRa as a whisper that travels 10 kilometers.</strong>
|
||||||
|
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 <strong>160 bytes</strong> at a time, and each whisper takes a second or two to complete.
|
||||||
|
</div>
|
||||||
|
<p>Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses <em>chirp spread spectrum</em> (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.</p>
|
||||||
|
<p>Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running <strong>Meshcore firmware</strong>, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.</p>
|
||||||
|
|
||||||
|
<h2 id="why">Why Archipelago uses it</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Off-grid safety</h4>
|
||||||
|
<p>Dead-man switch and emergency alerts reach family without cell coverage.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Censorship resistance</h4>
|
||||||
|
<p>No ISP, no DNS, no TLS termination — just radio waves between nodes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Bitcoin when internet is down</h4>
|
||||||
|
<p>Relay signed transactions and Lightning payments through on-grid peers.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Truly peer-to-peer chat</h4>
|
||||||
|
<p>Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 id="hardware">Hardware & Firmware</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Component</th><th>Role</th><th>Examples</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>MCU</strong></td><td>Runs Meshcore firmware, talks USB serial</td><td>ESP32, nRF52840</td></tr>
|
||||||
|
<tr><td><strong>Radio</strong></td><td>Semtech LoRa transceiver</td><td>SX1262, SX1276</td></tr>
|
||||||
|
<tr><td><strong>Board</strong></td><td>MCU + radio + USB + antenna</td><td>Heltec V3, T-Beam, RAK WisBlock, Station G2</td></tr>
|
||||||
|
<tr><td><strong>Firmware</strong></td><td>Mesh routing + Companion USB protocol</td><td>Meshcore</td></tr>
|
||||||
|
<tr><td><strong>Connection</strong></td><td>USB CDC-ACM serial</td><td><code>/dev/mesh-radio</code> (udev symlink), <code>/dev/ttyUSB*</code>, <code>/dev/ttyACM*</code></td></tr>
|
||||||
|
<tr><td><strong>Link params</strong></td><td>115200 baud, 8N1</td><td>Set in <code>mesh/serial.rs</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>It's a modem.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="serial">USB Serial Transport</h2>
|
||||||
|
<p>Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with <code>'<'</code> and listens for <code>'>'</code>.</p>
|
||||||
|
|
||||||
|
<div class="diagram">Host → Device: <span class="highlight">0x3C</span> '<' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||||
|
Device → Host: <span class="highlight">0x3E</span> '>' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||||
|
|
||||||
|
Baud: 115200 Framing: 8N1 Source: mesh/serial.rs</div>
|
||||||
|
|
||||||
|
<p>The frame body is a Meshcore <em>Companion</em> command or response. Archipelago builds these in <code>mesh/protocol.rs</code> and parses replies in <code>mesh/listener/decode.rs</code>.</p>
|
||||||
|
|
||||||
|
<h3>Companion commands Archipelago uses</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Code</th><th>Name</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>0x01</code></td><td>APP_START</td><td>Handshake; device returns its node_id and name</td></tr>
|
||||||
|
<tr><td><code>0x02</code></td><td>SEND_TXT_MSG</td><td>Send payload to a contact (targeted by 6-byte pubkey prefix)</td></tr>
|
||||||
|
<tr><td><code>0x03</code></td><td>SEND_CHANNEL_TXT_MSG</td><td>Broadcast on a channel (no specific recipient)</td></tr>
|
||||||
|
<tr><td><code>0x04</code></td><td>GET_CONTACTS</td><td>Pull the device's contact table</td></tr>
|
||||||
|
<tr><td><code>0x06</code></td><td>SET_DEVICE_TIME</td><td>Sync Unix timestamp for message dating</td></tr>
|
||||||
|
<tr><td><code>0x07</code></td><td>SEND_SELF_ADVERT</td><td>Broadcast our identity onto the mesh</td></tr>
|
||||||
|
<tr><td><code>0x08</code></td><td>SET_ADVERT_NAME</td><td>Set our display name</td></tr>
|
||||||
|
<tr><td><code>0x0A</code></td><td>SYNC_NEXT_MESSAGE</td><td>Pop the next queued inbound message</td></tr>
|
||||||
|
<tr><td><code>0x0B</code></td><td>SET_RADIO_PARAMS</td><td>Frequency, spreading factor, bandwidth</td></tr>
|
||||||
|
<tr><td><code>0x0C</code></td><td>SET_RADIO_TX_POWER</td><td>Transmit power (dBm)</td></tr>
|
||||||
|
<tr><td><code>0x38</code></td><td>GET_STATS</td><td>Device statistics</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Responses and push notifications</h3>
|
||||||
|
<p>Responses begin with a status byte. Codes <code>< 0x80</code> are replies to a command we sent; codes <code>>= 0x80</code> are asynchronous push events from the device.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Code</th><th>Name</th><th>Meaning</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>0x00</code></td><td>RESP_OK</td><td>Command accepted</td></tr>
|
||||||
|
<tr><td><code>0x01</code></td><td>RESP_ERR</td><td>Command failed + error code</td></tr>
|
||||||
|
<tr><td><code>0x03</code></td><td>RESP_CONTACT</td><td>One contact entry (32-byte pubkey + metadata)</td></tr>
|
||||||
|
<tr><td><code>0x05</code></td><td>RESP_SELF_INFO</td><td>Our node_id and name after APP_START</td></tr>
|
||||||
|
<tr><td><code>0x10</code></td><td>RESP_CONTACT_MSG_V3</td><td>Direct inbound message (SNR + sender prefix + payload)</td></tr>
|
||||||
|
<tr><td><code>0x11</code></td><td>RESP_CHANNEL_MSG_V3</td><td>Channel broadcast inbound</td></tr>
|
||||||
|
<tr><td><code>0x83</code></td><td>PUSH_MESSAGES_WAITING</td><td>Async: new messages in queue, call SYNC_NEXT_MESSAGE</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="wire">Wire Format — the payload byte 0</h2>
|
||||||
|
<p>Once a frame reaches the message payload, Archipelago looks at the <strong>first byte</strong> to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.</p>
|
||||||
|
|
||||||
|
<div class="diagram"><span class="highlight">0x00</span> Plain text (legacy, unencrypted)
|
||||||
|
<span class="highlight">0x01</span> Identity broadcast (ARCHY:2 / ARCHY:3)
|
||||||
|
<span class="highlight">0x02</span> Typed CBOR envelope (plaintext, used for debug or intra-LAN)
|
||||||
|
<span class="highlight">0xEE</span> Encrypted typed — ChaCha20-Poly1305 w/ static shared secret
|
||||||
|
<span class="highlight">0xDD</span> Ratcheted typed — Double Ratchet, forward-secure</div>
|
||||||
|
|
||||||
|
<p>Markers <code>0xEE</code> and <code>0xDD</code> are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.</p>
|
||||||
|
|
||||||
|
<h3>0xEE — static-key encrypted envelope</h3>
|
||||||
|
<pre><code>[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.</li>
|
||||||
|
<li>Cipher: ChaCha20-Poly1305 AEAD.</li>
|
||||||
|
<li>Max plaintext: <code>160 − 1 − 12 − 16 = 131</code> bytes (see <code>crypto::MAX_ENCRYPTED_PLAINTEXT</code>).</li>
|
||||||
|
<li>Properties: confidential + authenticated, <em>but</em> compromise of a key decrypts all history.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>0xDD — Double Ratchet envelope</h3>
|
||||||
|
<pre><code>[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).</li>
|
||||||
|
<li>Handles out-of-order delivery via a skipped-keys cache.</li>
|
||||||
|
<li>Properties: forward secrecy + post-compromise recovery. Used for <code>mesh.*</code> chat once a session is established.</li>
|
||||||
|
<li>Implementation: <code>mesh/ratchet.rs</code>, session load/save in <code>mesh/listener/session.rs</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>Static key vs. ratchet = a safe vs. a self-shredding envelope.</strong>
|
||||||
|
The <code>0xEE</code> lane is like a locked safe: one key opens everything. The <code>0xDD</code> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="crypto">Encryption Layers</h2>
|
||||||
|
<p>Three cryptographic primitives combine to produce the <code>0xDD</code> ratchet flow:</p>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>X25519 ECDH</h4>
|
||||||
|
<p>Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>HKDF-SHA256</h4>
|
||||||
|
<p>Derives root key, chain key, and message key at each ratchet step.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>ChaCha20-Poly1305</h4>
|
||||||
|
<p>Symmetric AEAD used for the actual payload encryption + authentication tag.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Session bootstrap — X3DH-like handshake</h3>
|
||||||
|
<p>Before the ratchet can start, peers exchange a <strong>PrekeyBundle</strong> (type 5) and a <strong>SessionInit</strong> (type 6). Those two messages are carried by the <code>0xEE</code> static-key envelope, because the ratchet session doesn't exist yet. Once <code>SessionInit</code> is processed, subsequent traffic switches to <code>0xDD</code>. See <code>mesh/x3dh.rs</code>.</p>
|
||||||
|
|
||||||
|
<h2 id="fragmentation">Fragmentation — how a 500-byte message rides a 160-byte pipe</h2>
|
||||||
|
<p>The LoRa frame budget is <strong>160 bytes</strong> (<code>protocol::MAX_MESSAGE_LEN</code>). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.</p>
|
||||||
|
|
||||||
|
<div class="diagram"><span class="highlight">Chunk header</span> ┌──────────┬──────────┬────────────┐
|
||||||
|
│ type (1) │ id (1) │ total (1) │
|
||||||
|
└──────────┴──────────┴────────────┘
|
||||||
|
<span class="highlight">Chunk body</span> 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</div>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<div class="callout callout-info">
|
||||||
|
<strong>Escape hatch: federation fallback.</strong> If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago <em>skips LoRa entirely</em> and routes the message over Tor federation instead. See the <code>ContentRef</code> path in <code>rpc/mesh/typed_messages.rs</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="dual-transport">Dual Transport — LoRa + Tor federation</h2>
|
||||||
|
<p>Archipelago treats LoRa and Tor federation as <strong>two lanes of the same highway</strong>. 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.</p>
|
||||||
|
|
||||||
|
<div class="diagram"> ┌──────────────────┐
|
||||||
|
│ mesh.send(...) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌──────────┴──────────┐
|
||||||
|
│ Is peer synthetic? │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
No │ Yes
|
||||||
|
┌──────────┘ └──────────┐
|
||||||
|
▼ ▼
|
||||||
|
<span class="highlight">LoRa radio</span> <span class="blue">Tor federation</span>
|
||||||
|
(160-byte frame) (unlimited, slower setup)
|
||||||
|
│ │
|
||||||
|
│ if > 160 B && synth ──────┘ (fallback)
|
||||||
|
▼
|
||||||
|
Chunked over LoRa
|
||||||
|
or refused if no fallback</div>
|
||||||
|
|
||||||
|
<h2 id="addressing">Addressing</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Contact ID</strong> — 32-bit handle from Meshcore's contact table. Used by <code>SEND_TXT_MSG</code>.</li>
|
||||||
|
<li><strong>Pubkey prefix</strong> — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.</li>
|
||||||
|
<li><strong>DID / onion</strong> — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="synthetic">Synthetic federation contacts</h2>
|
||||||
|
<p>To let the chat list show federation peers <em>before</em> any message arrives, Archipelago inserts <strong>synthetic contacts</strong> into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (<code>≥ 0x8000_0000</code>), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 id="msg-overview">All 23 Message Types</h2>
|
||||||
|
<p>Every typed message is a CBOR envelope identified by a single <code>MeshMessageType</code> byte. The <strong>Transport</strong> column shows which marker carries it on the wire and which Companion command is used.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>ID</th><th>Type</th><th>Purpose</th><th>Marker</th><th>Cmd</th><th>Chunked?</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>0</td><td>Text</td><td>Plain chat message</td><td>0xDD</td><td>0x02</td><td>If >160 B</td></tr>
|
||||||
|
<tr><td>1</td><td>Alert</td><td>Emergency / dead-man heartbeat</td><td>0xDD</td><td>0x02/0x03</td><td>No (short)</td></tr>
|
||||||
|
<tr><td>2</td><td>Invoice</td><td>Lightning / BOLT11 invoice</td><td>0xDD</td><td>0x02</td><td>Usually</td></tr>
|
||||||
|
<tr><td>3</td><td>PsbtHash</td><td>Unsigned tx hash for co-signing</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>4</td><td>Coordinate</td><td>GPS location share</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>5</td><td>PrekeyBundle</td><td>X3DH bootstrap (pre-session)</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>6</td><td>SessionInit</td><td>Initial ratchet message</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>7</td><td>BlockHeader</td><td>Bitcoin block height/hash</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||||
|
<tr><td>8</td><td>TxRelay</td><td>Signed Bitcoin tx for on-grid peer to broadcast</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||||
|
<tr><td>9</td><td>TxRelayResponse</td><td>txid or error from the relay peer</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>10</td><td>LightningRelay</td><td>BOLT11 to pay via on-grid peer</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||||
|
<tr><td>11</td><td>LightningRelayResponse</td><td>payment_hash or error</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>12</td><td>TxConfirmation</td><td>Depth update (1/2/3 confs)</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>13</td><td>Reply</td><td>Quoted reply to a previous message</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
|
||||||
|
<tr><td>14</td><td>Reaction</td><td>Emoji reaction on MessageKey</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>15</td><td>ReadReceipt</td><td>"Seen up to MessageKey X"</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>16</td><td>Forward</td><td>Re-forwarded original w/ provenance</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||||
|
<tr><td>17</td><td>Edit</td><td>In-place text replacement</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
|
||||||
|
<tr><td>18</td><td>Delete</td><td>Tombstone for earlier message</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>19</td><td>ContentRef</td><td>CID of blob held by sender (file/image)</td><td>0xDD</td><td>0x02 or Tor</td><td>Federation fallback</td></tr>
|
||||||
|
<tr><td>20</td><td>Presence</td><td>Heartbeat + last-activity epoch</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||||
|
<tr><td>21</td><td>ChannelInvite</td><td>Group membership announcement</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||||
|
<tr><td>22</td><td>ContactCard</td><td>Shareable federation node card</td><td>0xDD</td><td>0x02</td><td>Maybe</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-text">Text, Reply, Edit, Delete, Forward</h2>
|
||||||
|
|
||||||
|
<h3>Text (type 0)</h3>
|
||||||
|
<p><strong>Sender path.</strong> <code>rpc.mesh.send</code> → <code>typed_messages::send_text</code> → CBOR-encode the <code>Text{body}</code> variant → ratchet-encrypt → prefix <code>0xDD</code> → if under 160 B, send in one <code>SEND_TXT_MSG</code> frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.</p>
|
||||||
|
|
||||||
|
<h3>Reply (type 13)</h3>
|
||||||
|
<p>Same as Text, but the CBOR envelope carries a <code>MessageKey</code> pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.</p>
|
||||||
|
|
||||||
|
<h3>Edit (type 17)</h3>
|
||||||
|
<p>Envelope contains the original <code>MessageKey</code> plus the new body. Receiver updates its local store in-place and tags the entry "edited".</p>
|
||||||
|
|
||||||
|
<h3>Delete (type 18)</h3>
|
||||||
|
<p>Tombstone only: <code>MessageKey</code> with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.</p>
|
||||||
|
|
||||||
|
<h3>Forward (type 16)</h3>
|
||||||
|
<p>Wraps original <code>{sender_name, original_timestamp, body}</code> so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are <em>almost always</em> chunked.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-social">Reaction, ReadReceipt, Presence</h2>
|
||||||
|
|
||||||
|
<h3>Reaction (type 14)</h3>
|
||||||
|
<p>Envelope: <code>{target: MessageKey, emoji: String}</code>. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see <code>MessageActions</code> in <code>neode-ui</code>).</p>
|
||||||
|
|
||||||
|
<h3>ReadReceipt (type 15)</h3>
|
||||||
|
<p>Envelope: <code>{up_to: MessageKey}</code>. 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).</p>
|
||||||
|
|
||||||
|
<h3>Presence (type 20)</h3>
|
||||||
|
<p>Periodic heartbeat carrying <code>{last_activity_epoch}</code>. Broadcast on a channel (<code>SEND_CHANNEL_TXT_MSG</code>, cmd <code>0x03</code>) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.</p>
|
||||||
|
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>Like a lighthouse beacon.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="msg-content">ContentRef — files and images without bloating the radio</h2>
|
||||||
|
<p>LoRa cannot move a 500 KB image. The <code>ContentRef</code> type (19) solves this by sending only a <strong>pointer</strong> — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.</p>
|
||||||
|
|
||||||
|
<div class="diagram">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</div>
|
||||||
|
|
||||||
|
<div class="callout callout-info">
|
||||||
|
<strong>Resolution bug fix note.</strong> An earlier revision of <code>ContentRef</code> 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 <code>5f7ebf14</code>) resolves the owning peer by DID and falls back to name-match only if DID lookup fails.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="msg-bitcoin">Bitcoin & Lightning over LoRa</h2>
|
||||||
|
<p>Archipelago uses the mesh as a <strong>Bitcoin transport of last resort</strong>. 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.</p>
|
||||||
|
|
||||||
|
<h3>TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)</h3>
|
||||||
|
<div class="diagram">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} │
|
||||||
|
└────────────────────────┘</div>
|
||||||
|
|
||||||
|
<p>The binary framing in <code>mesh/bitcoin_relay.rs</code> 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.</p>
|
||||||
|
|
||||||
|
<h3>LightningRelay (10) → LightningRelayResponse (11)</h3>
|
||||||
|
<p>Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns <code>payment_hash</code> or an error. Invoices are often long enough to chunk.</p>
|
||||||
|
|
||||||
|
<h3>Invoice (2) and PsbtHash (3)</h3>
|
||||||
|
<p>These are <em>not</em> relays — they're peer-to-peer handoffs. <code>Invoice</code> delivers a BOLT11 to be paid by the recipient. <code>PsbtHash</code> carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.</p>
|
||||||
|
|
||||||
|
<h3>BlockHeader (7)</h3>
|
||||||
|
<p>Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via <code>SEND_CHANNEL_TXT_MSG</code>) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-safety">Alerts, Coordinates, Dead-Man</h2>
|
||||||
|
|
||||||
|
<h3>Alert (type 1)</h3>
|
||||||
|
<p>Envelope: <code>{kind, message, sender_contact_id}</code>. Kinds include <code>Emergency</code> and <code>Deadman</code>. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).</p>
|
||||||
|
|
||||||
|
<h3>Dead-man switch</h3>
|
||||||
|
<p>A background task in <code>mesh/alerts.rs</code> sends a <code>Deadman</code> 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.</p>
|
||||||
|
|
||||||
|
<h3>Coordinate (type 4)</h3>
|
||||||
|
<p>Envelope: <code>{lat, lon, accuracy_m}</code> with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.</p>
|
||||||
|
|
||||||
|
<h3>ChannelInvite (type 21)</h3>
|
||||||
|
<p>Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via <code>SEND_CHANNEL_TXT_MSG</code>.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-identity">Identity, PrekeyBundle, ContactCard</h2>
|
||||||
|
|
||||||
|
<h3>Identity broadcast (marker 0x01, ARCHY:2/3)</h3>
|
||||||
|
<p>The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed <code>0x01</code>. This is how peers discover each other. The payload encodes protocol version (<code>ARCHY:2</code> or <code>ARCHY:3</code>) and the raw pubkey. Carried by <code>CMD_SEND_SELF_ADVERT</code> (<code>0x07</code>).</p>
|
||||||
|
|
||||||
|
<h3>PrekeyBundle (type 5) and SessionInit (type 6)</h3>
|
||||||
|
<p>X3DH handshake. <code>PrekeyBundle</code> advertises a signed prekey; <code>SessionInit</code> consumes it to derive the initial ratchet root key. Both ride on <code>0xEE</code> (static-key encryption), because the ratchet session they're creating doesn't yet exist.</p>
|
||||||
|
|
||||||
|
<h3>ContactCard (type 22)</h3>
|
||||||
|
<p>A shareable card containing <code>{did, onion_address, pubkey, display_name}</code>. 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.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 id="rpc">RPC API — what callers actually invoke</h2>
|
||||||
|
<p>Every user-facing action goes through the RPC dispatcher (<code>api/rpc/dispatcher.rs</code>, lines 287+) and ends in <code>api/rpc/mesh/typed_messages.rs</code>. The tables below show the public surface.</p>
|
||||||
|
|
||||||
|
<h3>Core commands</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>RPC</th><th>Effect</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mesh.status</code></td><td>Device info, peer count, enabled state</td></tr>
|
||||||
|
<tr><td><code>mesh.peers</code></td><td>List all discovered peers with RSSI / SNR / hop count</td></tr>
|
||||||
|
<tr><td><code>mesh.messages</code></td><td>Retrieve stored mesh messages</td></tr>
|
||||||
|
<tr><td><code>mesh.send</code></td><td>Send plain text to a specific peer</td></tr>
|
||||||
|
<tr><td><code>mesh.send-channel</code></td><td>Broadcast on a channel</td></tr>
|
||||||
|
<tr><td><code>mesh.broadcast</code></td><td>Mesh-wide announcement</td></tr>
|
||||||
|
<tr><td><code>mesh.configure</code></td><td>Set device params (name, power, channel)</td></tr>
|
||||||
|
<tr><td><code>mesh.debug-dump</code></td><td>Raw state for debugging</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Rich message commands</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>RPC</th><th>Msg Type</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mesh.send-invoice</code></td><td>Invoice (2)</td><td>Deliver BOLT11 to peer</td></tr>
|
||||||
|
<tr><td><code>mesh.send-coordinate</code></td><td>Coordinate (4)</td><td>Single frame, fixed-point</td></tr>
|
||||||
|
<tr><td><code>mesh.send-alert</code></td><td>Alert (1)</td><td>Emergency or deadman</td></tr>
|
||||||
|
<tr><td><code>mesh.send-content</code></td><td>ContentRef (19)</td><td>Stores blob, sends CID</td></tr>
|
||||||
|
<tr><td><code>mesh.fetch-content</code></td><td>—</td><td>Pulls blob via federation</td></tr>
|
||||||
|
<tr><td><code>mesh.send-psbt</code></td><td>PsbtHash (3)</td><td>Hash only, full PSBT via fetch</td></tr>
|
||||||
|
<tr><td><code>mesh.send-reply</code></td><td>Reply (13)</td><td>Quoted response</td></tr>
|
||||||
|
<tr><td><code>mesh.send-reaction</code></td><td>Reaction (14)</td><td>Emoji</td></tr>
|
||||||
|
<tr><td><code>mesh.send-read-receipt</code></td><td>ReadReceipt (15)</td><td>Cumulative "seen up to"</td></tr>
|
||||||
|
<tr><td><code>mesh.forward-message</code></td><td>Forward (16)</td><td>Wraps original + provenance</td></tr>
|
||||||
|
<tr><td><code>mesh.edit-message</code></td><td>Edit (17)</td><td>In-place text replacement</td></tr>
|
||||||
|
<tr><td><code>mesh.delete-message</code></td><td>Delete (18)</td><td>Tombstone</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="ui">User Interface</h2>
|
||||||
|
<p>The Vue side lives under <code>neode-ui/src/views/mesh/</code> with state in <code>stores/mesh.ts</code>. Notable panels:</p>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Mesh chat</h4>
|
||||||
|
<p>Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>MeshBitcoinPanel</h4>
|
||||||
|
<p>UI for TxRelay / LightningRelay submission and confirmation tracking.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>MeshDeadmanPanel</h4>
|
||||||
|
<p>Configure dead-man interval, pick recipients, show last heartbeat time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Unified inbox</h4>
|
||||||
|
<p>Federation and mesh chats appear side-by-side; the transport is invisible to the user.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="listener">Listener loop — how inbound traffic is decoded</h2>
|
||||||
|
<p>A long-running async task in <code>mesh/listener/mod.rs</code> owns the serial device and feeds events into the rest of the system.</p>
|
||||||
|
|
||||||
|
<div class="diagram">loop {
|
||||||
|
event = await serial_read()
|
||||||
|
match event {
|
||||||
|
<span class="green">PUSH_MESSAGES_WAITING</span> → send SYNC_NEXT_MESSAGE until empty
|
||||||
|
<span class="green">RESP_CONTACT_MSG_V3</span> → decode.rs extracts payload
|
||||||
|
→ match first byte:
|
||||||
|
<span class="highlight">0x00</span> plain text
|
||||||
|
<span class="highlight">0x01</span> identity → frames::parse_identity
|
||||||
|
<span class="highlight">0x02</span> typed CBOR plaintext
|
||||||
|
<span class="highlight">0xEE</span> → crypto::decrypt_static
|
||||||
|
<span class="highlight">0xDD</span> → session::load + ratchet::decrypt
|
||||||
|
→ dispatch.rs routes typed msg
|
||||||
|
to chat store / bitcoin relay /
|
||||||
|
alerts / presence / ...
|
||||||
|
<span class="green">RESP_CONTACT</span> → contact list update
|
||||||
|
<span class="green">RESP_SELF_INFO</span> → record our node_id
|
||||||
|
}
|
||||||
|
}</div>
|
||||||
|
|
||||||
|
<p>Chunk reassembly happens in <code>listener/session.rs</code>, keyed by <code>(sender_pubkey_prefix, chunk_id)</code>. Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.</p>
|
||||||
|
|
||||||
|
<h2 id="files">File Map</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>File</th><th>Size</th><th>Role</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mesh/mod.rs</code></td><td>52 KB</td><td>Public API, send paths, federation integration</td></tr>
|
||||||
|
<tr><td><code>mesh/protocol.rs</code></td><td>26 KB</td><td>Frame encoding/decoding, command builders</td></tr>
|
||||||
|
<tr><td><code>mesh/serial.rs</code></td><td>15 KB</td><td>USB driver, device detection, handshake</td></tr>
|
||||||
|
<tr><td><code>mesh/crypto.rs</code></td><td>10 KB</td><td>X25519 ECDH, ChaCha20-Poly1305, HKDF</td></tr>
|
||||||
|
<tr><td><code>mesh/ratchet.rs</code></td><td>16 KB</td><td>Double Ratchet implementation</td></tr>
|
||||||
|
<tr><td><code>mesh/message_types.rs</code></td><td>23 KB</td><td>23 typed message discriminators + CBOR schemas</td></tr>
|
||||||
|
<tr><td><code>mesh/bitcoin_relay.rs</code></td><td>17 KB</td><td>TxRelay / LightningRelay binary framing</td></tr>
|
||||||
|
<tr><td><code>mesh/listener/dispatch.rs</code></td><td>29 KB</td><td>Typed-message routing into chat/relay/alerts</td></tr>
|
||||||
|
<tr><td><code>mesh/listener/session.rs</code></td><td>14 KB</td><td>Ratchet session persistence + chunk reassembly</td></tr>
|
||||||
|
<tr><td><code>mesh/x3dh.rs</code></td><td>—</td><td>Prekey / SessionInit bootstrap</td></tr>
|
||||||
|
<tr><td><code>mesh/outbox.rs</code></td><td>—</td><td>Retry queue for unacked sends</td></tr>
|
||||||
|
<tr><td><code>mesh/steganography.rs</code></td><td>—</td><td>Weather/sensor framing for deniable traffic</td></tr>
|
||||||
|
<tr><td><code>api/rpc/mesh/typed_messages.rs</code></td><td>—</td><td>All <code>mesh.*</code> RPC handlers</td></tr>
|
||||||
|
<tr><td><code>neode-ui/src/stores/mesh.ts</code></td><td>14 KB</td><td>Pinia store consumed by all mesh Vue views</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Summary scoreboard</h2>
|
||||||
|
<div class="score-grid">
|
||||||
|
<div class="score-card"><div class="score">23</div><div class="label">Message types</div></div>
|
||||||
|
<div class="score-card"><div class="score">160</div><div class="label">Bytes / frame</div></div>
|
||||||
|
<div class="score-card"><div class="score">2</div><div class="label">Transports</div></div>
|
||||||
|
<div class="score-card"><div class="score">5</div><div class="label">Wire markers</div></div>
|
||||||
|
<div class="score-card"><div class="score">~6k</div><div class="label">LoC in mesh/</div></div>
|
||||||
|
<div class="score-card"><div class="score">FS</div><div class="label">Forward-secure</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout callout-success">
|
||||||
|
<strong>Bottom line.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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 && \
|
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
|
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
|
# Enable services
|
||||||
RUN systemctl enable NetworkManager || true && \
|
RUN systemctl enable NetworkManager || true && \
|
||||||
systemctl enable ssh || 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'
|
cat > /mnt/target/var/lib/archipelago/config/registries.json <<'DYNREG'
|
||||||
{
|
{
|
||||||
"registries": [
|
"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 Primary", "tls_verify": true, "enabled": true, "priority": 0},
|
||||||
{"url": "git.tx1138.com/lfg2025", "name": "Archipelago Legacy", "tls_verify": true, "enabled": true, "priority": 10}
|
{"url": "23.182.128.160:3000/lfg2025", "name": "Archipelago Fallback", "tls_verify": false, "enabled": true, "priority": 10}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
DYNREG
|
DYNREG
|
||||||
|
|||||||
26
neode-ui/package-lock.json
generated
26
neode-ui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.2.0-alpha",
|
"version": "1.3.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.2.0-alpha",
|
"version": "1.3.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
@ -146,7 +146,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -1808,7 +1807,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@ -1832,7 +1830,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -3843,7 +3840,6 @@
|
|||||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/geojson": "*"
|
"@types/geojson": "*"
|
||||||
}
|
}
|
||||||
@ -3887,7 +3883,6 @@
|
|||||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
@ -4388,7 +4383,6 @@
|
|||||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@ -4850,7 +4844,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -5852,7 +5845,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@ -8027,7 +8019,6 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@ -8077,7 +8068,6 @@
|
|||||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.1.0",
|
"cssstyle": "^4.1.0",
|
||||||
"data-urls": "^5.0.0",
|
"data-urls": "^5.0.0",
|
||||||
@ -8180,8 +8170,7 @@
|
|||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@ -8933,7 +8922,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@ -9586,7 +9574,6 @@
|
|||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@ -10850,7 +10837,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -11106,7 +11092,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -11348,7 +11333,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@ -11511,7 +11495,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -11525,7 +11508,6 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@ -11618,7 +11600,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.30",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/compiler-sfc": "3.5.30",
|
"@vue/compiler-sfc": "3.5.30",
|
||||||
@ -12195,7 +12176,6 @@
|
|||||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
|
|||||||
899
neode-ui/public/architecture/index.html
Normal file
899
neode-ui/public/architecture/index.html
Normal file
@ -0,0 +1,899 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Archipelago — LoRa & Mesh Functionality Guide</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #000000;
|
||||||
|
--glass-card: rgba(0, 0, 0, 0.65);
|
||||||
|
--glass-dark: rgba(0, 0, 0, 0.35);
|
||||||
|
--glass-darker: rgba(0, 0, 0, 0.6);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
--glass-highlight: rgba(255, 255, 255, 0.22);
|
||||||
|
--glass-blur: 18px;
|
||||||
|
--glass-blur-strong: 24px;
|
||||||
|
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||||
|
--text: rgba(255, 255, 255, 0.9);
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.6);
|
||||||
|
--accent: #fb923c;
|
||||||
|
--accent-dim: rgba(251, 146, 60, 0.15);
|
||||||
|
--green: #4ade80;
|
||||||
|
--green-dim: rgba(74, 222, 128, 0.15);
|
||||||
|
--red: #ef4444;
|
||||||
|
--red-dim: rgba(239, 68, 68, 0.12);
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--blue-dim: rgba(59, 130, 246, 0.12);
|
||||||
|
--yellow: #facc15;
|
||||||
|
--yellow-dim: rgba(250, 204, 21, 0.12);
|
||||||
|
--purple: #a78bfa;
|
||||||
|
--purple-dim: rgba(167, 139, 250, 0.12);
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 280px;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border-right: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 0;
|
||||||
|
z-index: 100;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||||
|
}
|
||||||
|
nav .logo { padding: 0 24px 20px; margin-bottom: 16px; }
|
||||||
|
nav .logo h1 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 18px; font-weight: 700;
|
||||||
|
color: var(--accent); letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
nav .logo p { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
nav .nav-section {
|
||||||
|
padding: 12px 16px 4px;
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 6px 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
nav a:hover, nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-left: 280px;
|
||||||
|
max-width: 960px;
|
||||||
|
padding: 48px 48px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 28px; font-weight: 700;
|
||||||
|
margin: 64px 0 8px;
|
||||||
|
padding-top: 24px;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
h2:first-of-type { margin-top: 0; }
|
||||||
|
h3 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 20px; font-weight: 600;
|
||||||
|
margin: 40px 0 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 16px; font-weight: 600;
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
p { margin: 8px 0 16px; color: var(--text); }
|
||||||
|
ul, ol { margin: 8px 0 16px 24px; color: var(--text); }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero { text-align: center; padding: 48px 0 56px; margin-bottom: 24px; }
|
||||||
|
.hero h1 {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 42px; font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #f59e0b);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.hero .tagline {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 12px auto 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
.hero .meta {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex; gap: 16px;
|
||||||
|
justify-content: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hero .meta span {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--glass-dark);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.card-sm {
|
||||||
|
background: var(--glass-darker);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.card-sm:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
|
||||||
|
.card-sm p { font-size: 13px; color: var(--text-muted); margin: 0; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||||
|
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||||
|
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
|
||||||
|
.badge-blue { background: var(--blue-dim); color: var(--blue); }
|
||||||
|
.badge-purple { background: var(--purple-dim); color: var(--purple); }
|
||||||
|
.badge-accent { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: rgba(255, 255, 255, 0.04); }
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
pre code { background: none; padding: 0; color: var(--text); }
|
||||||
|
|
||||||
|
.diagram {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Menlo', 'Monaco', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.diagram .highlight { color: var(--accent); font-weight: 600; }
|
||||||
|
.diagram .green { color: var(--green); }
|
||||||
|
.diagram .blue { color: var(--blue); }
|
||||||
|
.diagram .red { color: var(--red); }
|
||||||
|
.diagram .purple { color: var(--purple); }
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
background: var(--glass-card);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
border-left: 3px solid;
|
||||||
|
box-shadow: var(--shadow-glass);
|
||||||
|
}
|
||||||
|
.callout-info { border-color: var(--blue); }
|
||||||
|
.callout-warn { border-color: var(--yellow); }
|
||||||
|
.callout-danger { border-color: var(--red); }
|
||||||
|
.callout-success { border-color: var(--green); }
|
||||||
|
.callout-learn {
|
||||||
|
border-color: var(--purple);
|
||||||
|
background: rgba(167, 139, 250, 0.06);
|
||||||
|
position: relative;
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
.callout-learn::before {
|
||||||
|
content: 'Layman Analogy';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px; left: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
|
.callout strong { display: block; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.score-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.score-card {
|
||||||
|
background: var(--glass-darker);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform var(--transition);
|
||||||
|
}
|
||||||
|
.score-card:hover { transform: translateY(-2px); }
|
||||||
|
.score-card .score {
|
||||||
|
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||||
|
font-size: 28px; font-weight: 800;
|
||||||
|
margin: 4px 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.score-card .label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
margin: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
nav { display: none; }
|
||||||
|
main { margin-left: 0; padding: 20px; }
|
||||||
|
.hero h1 { font-size: 32px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<div class="logo">
|
||||||
|
<h1>Archipelago</h1>
|
||||||
|
<p>LoRa & Mesh Guide</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-section">Overview</div>
|
||||||
|
<a href="#intro">Introduction</a>
|
||||||
|
<a href="#layman">What is LoRa?</a>
|
||||||
|
<a href="#why">Why Archipelago uses it</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Stack</div>
|
||||||
|
<a href="#hardware">Hardware & Firmware</a>
|
||||||
|
<a href="#serial">USB Serial Transport</a>
|
||||||
|
<a href="#wire">Wire Format</a>
|
||||||
|
<a href="#crypto">Encryption Layers</a>
|
||||||
|
<a href="#fragmentation">Fragmentation</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Routing</div>
|
||||||
|
<a href="#dual-transport">Dual Transport</a>
|
||||||
|
<a href="#addressing">Addressing</a>
|
||||||
|
<a href="#synthetic">Federation Contacts</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Messages</div>
|
||||||
|
<a href="#msg-overview">All 23 Types</a>
|
||||||
|
<a href="#msg-text">Text / Reply / Edit</a>
|
||||||
|
<a href="#msg-social">Reactions & Receipts</a>
|
||||||
|
<a href="#msg-content">Content / Files</a>
|
||||||
|
<a href="#msg-bitcoin">Bitcoin & Lightning</a>
|
||||||
|
<a href="#msg-safety">Alerts & Presence</a>
|
||||||
|
<a href="#msg-identity">Identity & Keys</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Operations</div>
|
||||||
|
<a href="#rpc">RPC API</a>
|
||||||
|
<a href="#ui">User Interface</a>
|
||||||
|
<a href="#listener">Listener Loop</a>
|
||||||
|
<a href="#files">File Map</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>LoRa & Mesh Functionality</h1>
|
||||||
|
<p class="tagline">How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Meshcore Companion USB</span>
|
||||||
|
<span>Double Ratchet E2E</span>
|
||||||
|
<span>23 Message Types</span>
|
||||||
|
<span>160-byte LoRa Frame</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 id="intro">Introduction</h2>
|
||||||
|
<p>This document explains Archipelago's mesh subsystem — the code under <code>core/archipelago/src/mesh/</code> that lets nodes talk to each other over <strong>LoRa radio</strong> 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.</p>
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<h2 id="layman">What is LoRa? <span class="badge badge-purple">Layman</span></h2>
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>Think of LoRa as a whisper that travels 10 kilometers.</strong>
|
||||||
|
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 <strong>160 bytes</strong> at a time, and each whisper takes a second or two to complete.
|
||||||
|
</div>
|
||||||
|
<p>Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses <em>chirp spread spectrum</em> (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.</p>
|
||||||
|
<p>Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running <strong>Meshcore firmware</strong>, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.</p>
|
||||||
|
|
||||||
|
<h2 id="why">Why Archipelago uses it</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Off-grid safety</h4>
|
||||||
|
<p>Dead-man switch and emergency alerts reach family without cell coverage.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Censorship resistance</h4>
|
||||||
|
<p>No ISP, no DNS, no TLS termination — just radio waves between nodes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Bitcoin when internet is down</h4>
|
||||||
|
<p>Relay signed transactions and Lightning payments through on-grid peers.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Truly peer-to-peer chat</h4>
|
||||||
|
<p>Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 id="hardware">Hardware & Firmware</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Component</th><th>Role</th><th>Examples</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>MCU</strong></td><td>Runs Meshcore firmware, talks USB serial</td><td>ESP32, nRF52840</td></tr>
|
||||||
|
<tr><td><strong>Radio</strong></td><td>Semtech LoRa transceiver</td><td>SX1262, SX1276</td></tr>
|
||||||
|
<tr><td><strong>Board</strong></td><td>MCU + radio + USB + antenna</td><td>Heltec V3, T-Beam, RAK WisBlock, Station G2</td></tr>
|
||||||
|
<tr><td><strong>Firmware</strong></td><td>Mesh routing + Companion USB protocol</td><td>Meshcore</td></tr>
|
||||||
|
<tr><td><strong>Connection</strong></td><td>USB CDC-ACM serial</td><td><code>/dev/mesh-radio</code> (udev symlink), <code>/dev/ttyUSB*</code>, <code>/dev/ttyACM*</code></td></tr>
|
||||||
|
<tr><td><strong>Link params</strong></td><td>115200 baud, 8N1</td><td>Set in <code>mesh/serial.rs</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>It's a modem.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="serial">USB Serial Transport</h2>
|
||||||
|
<p>Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with <code>'<'</code> and listens for <code>'>'</code>.</p>
|
||||||
|
|
||||||
|
<div class="diagram">Host → Device: <span class="highlight">0x3C</span> '<' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||||
|
Device → Host: <span class="highlight">0x3E</span> '>' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||||
|
|
||||||
|
Baud: 115200 Framing: 8N1 Source: mesh/serial.rs</div>
|
||||||
|
|
||||||
|
<p>The frame body is a Meshcore <em>Companion</em> command or response. Archipelago builds these in <code>mesh/protocol.rs</code> and parses replies in <code>mesh/listener/decode.rs</code>.</p>
|
||||||
|
|
||||||
|
<h3>Companion commands Archipelago uses</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Code</th><th>Name</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>0x01</code></td><td>APP_START</td><td>Handshake; device returns its node_id and name</td></tr>
|
||||||
|
<tr><td><code>0x02</code></td><td>SEND_TXT_MSG</td><td>Send payload to a contact (targeted by 6-byte pubkey prefix)</td></tr>
|
||||||
|
<tr><td><code>0x03</code></td><td>SEND_CHANNEL_TXT_MSG</td><td>Broadcast on a channel (no specific recipient)</td></tr>
|
||||||
|
<tr><td><code>0x04</code></td><td>GET_CONTACTS</td><td>Pull the device's contact table</td></tr>
|
||||||
|
<tr><td><code>0x06</code></td><td>SET_DEVICE_TIME</td><td>Sync Unix timestamp for message dating</td></tr>
|
||||||
|
<tr><td><code>0x07</code></td><td>SEND_SELF_ADVERT</td><td>Broadcast our identity onto the mesh</td></tr>
|
||||||
|
<tr><td><code>0x08</code></td><td>SET_ADVERT_NAME</td><td>Set our display name</td></tr>
|
||||||
|
<tr><td><code>0x0A</code></td><td>SYNC_NEXT_MESSAGE</td><td>Pop the next queued inbound message</td></tr>
|
||||||
|
<tr><td><code>0x0B</code></td><td>SET_RADIO_PARAMS</td><td>Frequency, spreading factor, bandwidth</td></tr>
|
||||||
|
<tr><td><code>0x0C</code></td><td>SET_RADIO_TX_POWER</td><td>Transmit power (dBm)</td></tr>
|
||||||
|
<tr><td><code>0x38</code></td><td>GET_STATS</td><td>Device statistics</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Responses and push notifications</h3>
|
||||||
|
<p>Responses begin with a status byte. Codes <code>< 0x80</code> are replies to a command we sent; codes <code>>= 0x80</code> are asynchronous push events from the device.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Code</th><th>Name</th><th>Meaning</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>0x00</code></td><td>RESP_OK</td><td>Command accepted</td></tr>
|
||||||
|
<tr><td><code>0x01</code></td><td>RESP_ERR</td><td>Command failed + error code</td></tr>
|
||||||
|
<tr><td><code>0x03</code></td><td>RESP_CONTACT</td><td>One contact entry (32-byte pubkey + metadata)</td></tr>
|
||||||
|
<tr><td><code>0x05</code></td><td>RESP_SELF_INFO</td><td>Our node_id and name after APP_START</td></tr>
|
||||||
|
<tr><td><code>0x10</code></td><td>RESP_CONTACT_MSG_V3</td><td>Direct inbound message (SNR + sender prefix + payload)</td></tr>
|
||||||
|
<tr><td><code>0x11</code></td><td>RESP_CHANNEL_MSG_V3</td><td>Channel broadcast inbound</td></tr>
|
||||||
|
<tr><td><code>0x83</code></td><td>PUSH_MESSAGES_WAITING</td><td>Async: new messages in queue, call SYNC_NEXT_MESSAGE</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="wire">Wire Format — the payload byte 0</h2>
|
||||||
|
<p>Once a frame reaches the message payload, Archipelago looks at the <strong>first byte</strong> to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.</p>
|
||||||
|
|
||||||
|
<div class="diagram"><span class="highlight">0x00</span> Plain text (legacy, unencrypted)
|
||||||
|
<span class="highlight">0x01</span> Identity broadcast (ARCHY:2 / ARCHY:3)
|
||||||
|
<span class="highlight">0x02</span> Typed CBOR envelope (plaintext, used for debug or intra-LAN)
|
||||||
|
<span class="highlight">0xEE</span> Encrypted typed — ChaCha20-Poly1305 w/ static shared secret
|
||||||
|
<span class="highlight">0xDD</span> Ratcheted typed — Double Ratchet, forward-secure</div>
|
||||||
|
|
||||||
|
<p>Markers <code>0xEE</code> and <code>0xDD</code> are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.</p>
|
||||||
|
|
||||||
|
<h3>0xEE — static-key encrypted envelope</h3>
|
||||||
|
<pre><code>[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.</li>
|
||||||
|
<li>Cipher: ChaCha20-Poly1305 AEAD.</li>
|
||||||
|
<li>Max plaintext: <code>160 − 1 − 12 − 16 = 131</code> bytes (see <code>crypto::MAX_ENCRYPTED_PLAINTEXT</code>).</li>
|
||||||
|
<li>Properties: confidential + authenticated, <em>but</em> compromise of a key decrypts all history.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>0xDD — Double Ratchet envelope</h3>
|
||||||
|
<pre><code>[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).</li>
|
||||||
|
<li>Handles out-of-order delivery via a skipped-keys cache.</li>
|
||||||
|
<li>Properties: forward secrecy + post-compromise recovery. Used for <code>mesh.*</code> chat once a session is established.</li>
|
||||||
|
<li>Implementation: <code>mesh/ratchet.rs</code>, session load/save in <code>mesh/listener/session.rs</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>Static key vs. ratchet = a safe vs. a self-shredding envelope.</strong>
|
||||||
|
The <code>0xEE</code> lane is like a locked safe: one key opens everything. The <code>0xDD</code> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="crypto">Encryption Layers</h2>
|
||||||
|
<p>Three cryptographic primitives combine to produce the <code>0xDD</code> ratchet flow:</p>
|
||||||
|
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>X25519 ECDH</h4>
|
||||||
|
<p>Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>HKDF-SHA256</h4>
|
||||||
|
<p>Derives root key, chain key, and message key at each ratchet step.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>ChaCha20-Poly1305</h4>
|
||||||
|
<p>Symmetric AEAD used for the actual payload encryption + authentication tag.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Session bootstrap — X3DH-like handshake</h3>
|
||||||
|
<p>Before the ratchet can start, peers exchange a <strong>PrekeyBundle</strong> (type 5) and a <strong>SessionInit</strong> (type 6). Those two messages are carried by the <code>0xEE</code> static-key envelope, because the ratchet session doesn't exist yet. Once <code>SessionInit</code> is processed, subsequent traffic switches to <code>0xDD</code>. See <code>mesh/x3dh.rs</code>.</p>
|
||||||
|
|
||||||
|
<h2 id="fragmentation">Fragmentation — how a 500-byte message rides a 160-byte pipe</h2>
|
||||||
|
<p>The LoRa frame budget is <strong>160 bytes</strong> (<code>protocol::MAX_MESSAGE_LEN</code>). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.</p>
|
||||||
|
|
||||||
|
<div class="diagram"><span class="highlight">Chunk header</span> ┌──────────┬──────────┬────────────┐
|
||||||
|
│ type (1) │ id (1) │ total (1) │
|
||||||
|
└──────────┴──────────┴────────────┘
|
||||||
|
<span class="highlight">Chunk body</span> 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</div>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<div class="callout callout-info">
|
||||||
|
<strong>Escape hatch: federation fallback.</strong> If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago <em>skips LoRa entirely</em> and routes the message over Tor federation instead. See the <code>ContentRef</code> path in <code>rpc/mesh/typed_messages.rs</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="dual-transport">Dual Transport — LoRa + Tor federation</h2>
|
||||||
|
<p>Archipelago treats LoRa and Tor federation as <strong>two lanes of the same highway</strong>. 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.</p>
|
||||||
|
|
||||||
|
<div class="diagram"> ┌──────────────────┐
|
||||||
|
│ mesh.send(...) │
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌──────────┴──────────┐
|
||||||
|
│ Is peer synthetic? │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
No │ Yes
|
||||||
|
┌──────────┘ └──────────┐
|
||||||
|
▼ ▼
|
||||||
|
<span class="highlight">LoRa radio</span> <span class="blue">Tor federation</span>
|
||||||
|
(160-byte frame) (unlimited, slower setup)
|
||||||
|
│ │
|
||||||
|
│ if > 160 B && synth ──────┘ (fallback)
|
||||||
|
▼
|
||||||
|
Chunked over LoRa
|
||||||
|
or refused if no fallback</div>
|
||||||
|
|
||||||
|
<h2 id="addressing">Addressing</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Contact ID</strong> — 32-bit handle from Meshcore's contact table. Used by <code>SEND_TXT_MSG</code>.</li>
|
||||||
|
<li><strong>Pubkey prefix</strong> — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.</li>
|
||||||
|
<li><strong>DID / onion</strong> — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="synthetic">Synthetic federation contacts</h2>
|
||||||
|
<p>To let the chat list show federation peers <em>before</em> any message arrives, Archipelago inserts <strong>synthetic contacts</strong> into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (<code>≥ 0x8000_0000</code>), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 id="msg-overview">All 23 Message Types</h2>
|
||||||
|
<p>Every typed message is a CBOR envelope identified by a single <code>MeshMessageType</code> byte. The <strong>Transport</strong> column shows which marker carries it on the wire and which Companion command is used.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>ID</th><th>Type</th><th>Purpose</th><th>Marker</th><th>Cmd</th><th>Chunked?</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>0</td><td>Text</td><td>Plain chat message</td><td>0xDD</td><td>0x02</td><td>If >160 B</td></tr>
|
||||||
|
<tr><td>1</td><td>Alert</td><td>Emergency / dead-man heartbeat</td><td>0xDD</td><td>0x02/0x03</td><td>No (short)</td></tr>
|
||||||
|
<tr><td>2</td><td>Invoice</td><td>Lightning / BOLT11 invoice</td><td>0xDD</td><td>0x02</td><td>Usually</td></tr>
|
||||||
|
<tr><td>3</td><td>PsbtHash</td><td>Unsigned tx hash for co-signing</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>4</td><td>Coordinate</td><td>GPS location share</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>5</td><td>PrekeyBundle</td><td>X3DH bootstrap (pre-session)</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>6</td><td>SessionInit</td><td>Initial ratchet message</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>7</td><td>BlockHeader</td><td>Bitcoin block height/hash</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||||
|
<tr><td>8</td><td>TxRelay</td><td>Signed Bitcoin tx for on-grid peer to broadcast</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||||
|
<tr><td>9</td><td>TxRelayResponse</td><td>txid or error from the relay peer</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>10</td><td>LightningRelay</td><td>BOLT11 to pay via on-grid peer</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||||
|
<tr><td>11</td><td>LightningRelayResponse</td><td>payment_hash or error</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>12</td><td>TxConfirmation</td><td>Depth update (1/2/3 confs)</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>13</td><td>Reply</td><td>Quoted reply to a previous message</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
|
||||||
|
<tr><td>14</td><td>Reaction</td><td>Emoji reaction on MessageKey</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>15</td><td>ReadReceipt</td><td>"Seen up to MessageKey X"</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>16</td><td>Forward</td><td>Re-forwarded original w/ provenance</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||||
|
<tr><td>17</td><td>Edit</td><td>In-place text replacement</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
|
||||||
|
<tr><td>18</td><td>Delete</td><td>Tombstone for earlier message</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||||
|
<tr><td>19</td><td>ContentRef</td><td>CID of blob held by sender (file/image)</td><td>0xDD</td><td>0x02 or Tor</td><td>Federation fallback</td></tr>
|
||||||
|
<tr><td>20</td><td>Presence</td><td>Heartbeat + last-activity epoch</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||||
|
<tr><td>21</td><td>ChannelInvite</td><td>Group membership announcement</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||||
|
<tr><td>22</td><td>ContactCard</td><td>Shareable federation node card</td><td>0xDD</td><td>0x02</td><td>Maybe</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-text">Text, Reply, Edit, Delete, Forward</h2>
|
||||||
|
|
||||||
|
<h3>Text (type 0)</h3>
|
||||||
|
<p><strong>Sender path.</strong> <code>rpc.mesh.send</code> → <code>typed_messages::send_text</code> → CBOR-encode the <code>Text{body}</code> variant → ratchet-encrypt → prefix <code>0xDD</code> → if under 160 B, send in one <code>SEND_TXT_MSG</code> frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.</p>
|
||||||
|
|
||||||
|
<h3>Reply (type 13)</h3>
|
||||||
|
<p>Same as Text, but the CBOR envelope carries a <code>MessageKey</code> pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.</p>
|
||||||
|
|
||||||
|
<h3>Edit (type 17)</h3>
|
||||||
|
<p>Envelope contains the original <code>MessageKey</code> plus the new body. Receiver updates its local store in-place and tags the entry "edited".</p>
|
||||||
|
|
||||||
|
<h3>Delete (type 18)</h3>
|
||||||
|
<p>Tombstone only: <code>MessageKey</code> with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.</p>
|
||||||
|
|
||||||
|
<h3>Forward (type 16)</h3>
|
||||||
|
<p>Wraps original <code>{sender_name, original_timestamp, body}</code> so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are <em>almost always</em> chunked.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-social">Reaction, ReadReceipt, Presence</h2>
|
||||||
|
|
||||||
|
<h3>Reaction (type 14)</h3>
|
||||||
|
<p>Envelope: <code>{target: MessageKey, emoji: String}</code>. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see <code>MessageActions</code> in <code>neode-ui</code>).</p>
|
||||||
|
|
||||||
|
<h3>ReadReceipt (type 15)</h3>
|
||||||
|
<p>Envelope: <code>{up_to: MessageKey}</code>. 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).</p>
|
||||||
|
|
||||||
|
<h3>Presence (type 20)</h3>
|
||||||
|
<p>Periodic heartbeat carrying <code>{last_activity_epoch}</code>. Broadcast on a channel (<code>SEND_CHANNEL_TXT_MSG</code>, cmd <code>0x03</code>) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.</p>
|
||||||
|
|
||||||
|
<div class="callout callout-learn">
|
||||||
|
<strong>Like a lighthouse beacon.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="msg-content">ContentRef — files and images without bloating the radio</h2>
|
||||||
|
<p>LoRa cannot move a 500 KB image. The <code>ContentRef</code> type (19) solves this by sending only a <strong>pointer</strong> — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.</p>
|
||||||
|
|
||||||
|
<div class="diagram">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</div>
|
||||||
|
|
||||||
|
<div class="callout callout-info">
|
||||||
|
<strong>Resolution bug fix note.</strong> An earlier revision of <code>ContentRef</code> 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 <code>5f7ebf14</code>) resolves the owning peer by DID and falls back to name-match only if DID lookup fails.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="msg-bitcoin">Bitcoin & Lightning over LoRa</h2>
|
||||||
|
<p>Archipelago uses the mesh as a <strong>Bitcoin transport of last resort</strong>. 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.</p>
|
||||||
|
|
||||||
|
<h3>TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)</h3>
|
||||||
|
<div class="diagram">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} │
|
||||||
|
└────────────────────────┘</div>
|
||||||
|
|
||||||
|
<p>The binary framing in <code>mesh/bitcoin_relay.rs</code> 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.</p>
|
||||||
|
|
||||||
|
<h3>LightningRelay (10) → LightningRelayResponse (11)</h3>
|
||||||
|
<p>Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns <code>payment_hash</code> or an error. Invoices are often long enough to chunk.</p>
|
||||||
|
|
||||||
|
<h3>Invoice (2) and PsbtHash (3)</h3>
|
||||||
|
<p>These are <em>not</em> relays — they're peer-to-peer handoffs. <code>Invoice</code> delivers a BOLT11 to be paid by the recipient. <code>PsbtHash</code> carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.</p>
|
||||||
|
|
||||||
|
<h3>BlockHeader (7)</h3>
|
||||||
|
<p>Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via <code>SEND_CHANNEL_TXT_MSG</code>) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-safety">Alerts, Coordinates, Dead-Man</h2>
|
||||||
|
|
||||||
|
<h3>Alert (type 1)</h3>
|
||||||
|
<p>Envelope: <code>{kind, message, sender_contact_id}</code>. Kinds include <code>Emergency</code> and <code>Deadman</code>. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).</p>
|
||||||
|
|
||||||
|
<h3>Dead-man switch</h3>
|
||||||
|
<p>A background task in <code>mesh/alerts.rs</code> sends a <code>Deadman</code> 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.</p>
|
||||||
|
|
||||||
|
<h3>Coordinate (type 4)</h3>
|
||||||
|
<p>Envelope: <code>{lat, lon, accuracy_m}</code> with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.</p>
|
||||||
|
|
||||||
|
<h3>ChannelInvite (type 21)</h3>
|
||||||
|
<p>Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via <code>SEND_CHANNEL_TXT_MSG</code>.</p>
|
||||||
|
|
||||||
|
<h2 id="msg-identity">Identity, PrekeyBundle, ContactCard</h2>
|
||||||
|
|
||||||
|
<h3>Identity broadcast (marker 0x01, ARCHY:2/3)</h3>
|
||||||
|
<p>The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed <code>0x01</code>. This is how peers discover each other. The payload encodes protocol version (<code>ARCHY:2</code> or <code>ARCHY:3</code>) and the raw pubkey. Carried by <code>CMD_SEND_SELF_ADVERT</code> (<code>0x07</code>).</p>
|
||||||
|
|
||||||
|
<h3>PrekeyBundle (type 5) and SessionInit (type 6)</h3>
|
||||||
|
<p>X3DH handshake. <code>PrekeyBundle</code> advertises a signed prekey; <code>SessionInit</code> consumes it to derive the initial ratchet root key. Both ride on <code>0xEE</code> (static-key encryption), because the ratchet session they're creating doesn't yet exist.</p>
|
||||||
|
|
||||||
|
<h3>ContactCard (type 22)</h3>
|
||||||
|
<p>A shareable card containing <code>{did, onion_address, pubkey, display_name}</code>. 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.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2 id="rpc">RPC API — what callers actually invoke</h2>
|
||||||
|
<p>Every user-facing action goes through the RPC dispatcher (<code>api/rpc/dispatcher.rs</code>, lines 287+) and ends in <code>api/rpc/mesh/typed_messages.rs</code>. The tables below show the public surface.</p>
|
||||||
|
|
||||||
|
<h3>Core commands</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>RPC</th><th>Effect</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mesh.status</code></td><td>Device info, peer count, enabled state</td></tr>
|
||||||
|
<tr><td><code>mesh.peers</code></td><td>List all discovered peers with RSSI / SNR / hop count</td></tr>
|
||||||
|
<tr><td><code>mesh.messages</code></td><td>Retrieve stored mesh messages</td></tr>
|
||||||
|
<tr><td><code>mesh.send</code></td><td>Send plain text to a specific peer</td></tr>
|
||||||
|
<tr><td><code>mesh.send-channel</code></td><td>Broadcast on a channel</td></tr>
|
||||||
|
<tr><td><code>mesh.broadcast</code></td><td>Mesh-wide announcement</td></tr>
|
||||||
|
<tr><td><code>mesh.configure</code></td><td>Set device params (name, power, channel)</td></tr>
|
||||||
|
<tr><td><code>mesh.debug-dump</code></td><td>Raw state for debugging</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Rich message commands</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>RPC</th><th>Msg Type</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mesh.send-invoice</code></td><td>Invoice (2)</td><td>Deliver BOLT11 to peer</td></tr>
|
||||||
|
<tr><td><code>mesh.send-coordinate</code></td><td>Coordinate (4)</td><td>Single frame, fixed-point</td></tr>
|
||||||
|
<tr><td><code>mesh.send-alert</code></td><td>Alert (1)</td><td>Emergency or deadman</td></tr>
|
||||||
|
<tr><td><code>mesh.send-content</code></td><td>ContentRef (19)</td><td>Stores blob, sends CID</td></tr>
|
||||||
|
<tr><td><code>mesh.fetch-content</code></td><td>—</td><td>Pulls blob via federation</td></tr>
|
||||||
|
<tr><td><code>mesh.send-psbt</code></td><td>PsbtHash (3)</td><td>Hash only, full PSBT via fetch</td></tr>
|
||||||
|
<tr><td><code>mesh.send-reply</code></td><td>Reply (13)</td><td>Quoted response</td></tr>
|
||||||
|
<tr><td><code>mesh.send-reaction</code></td><td>Reaction (14)</td><td>Emoji</td></tr>
|
||||||
|
<tr><td><code>mesh.send-read-receipt</code></td><td>ReadReceipt (15)</td><td>Cumulative "seen up to"</td></tr>
|
||||||
|
<tr><td><code>mesh.forward-message</code></td><td>Forward (16)</td><td>Wraps original + provenance</td></tr>
|
||||||
|
<tr><td><code>mesh.edit-message</code></td><td>Edit (17)</td><td>In-place text replacement</td></tr>
|
||||||
|
<tr><td><code>mesh.delete-message</code></td><td>Delete (18)</td><td>Tombstone</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 id="ui">User Interface</h2>
|
||||||
|
<p>The Vue side lives under <code>neode-ui/src/views/mesh/</code> with state in <code>stores/mesh.ts</code>. Notable panels:</p>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Mesh chat</h4>
|
||||||
|
<p>Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>MeshBitcoinPanel</h4>
|
||||||
|
<p>UI for TxRelay / LightningRelay submission and confirmation tracking.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>MeshDeadmanPanel</h4>
|
||||||
|
<p>Configure dead-man interval, pick recipients, show last heartbeat time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-sm">
|
||||||
|
<h4>Unified inbox</h4>
|
||||||
|
<p>Federation and mesh chats appear side-by-side; the transport is invisible to the user.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="listener">Listener loop — how inbound traffic is decoded</h2>
|
||||||
|
<p>A long-running async task in <code>mesh/listener/mod.rs</code> owns the serial device and feeds events into the rest of the system.</p>
|
||||||
|
|
||||||
|
<div class="diagram">loop {
|
||||||
|
event = await serial_read()
|
||||||
|
match event {
|
||||||
|
<span class="green">PUSH_MESSAGES_WAITING</span> → send SYNC_NEXT_MESSAGE until empty
|
||||||
|
<span class="green">RESP_CONTACT_MSG_V3</span> → decode.rs extracts payload
|
||||||
|
→ match first byte:
|
||||||
|
<span class="highlight">0x00</span> plain text
|
||||||
|
<span class="highlight">0x01</span> identity → frames::parse_identity
|
||||||
|
<span class="highlight">0x02</span> typed CBOR plaintext
|
||||||
|
<span class="highlight">0xEE</span> → crypto::decrypt_static
|
||||||
|
<span class="highlight">0xDD</span> → session::load + ratchet::decrypt
|
||||||
|
→ dispatch.rs routes typed msg
|
||||||
|
to chat store / bitcoin relay /
|
||||||
|
alerts / presence / ...
|
||||||
|
<span class="green">RESP_CONTACT</span> → contact list update
|
||||||
|
<span class="green">RESP_SELF_INFO</span> → record our node_id
|
||||||
|
}
|
||||||
|
}</div>
|
||||||
|
|
||||||
|
<p>Chunk reassembly happens in <code>listener/session.rs</code>, keyed by <code>(sender_pubkey_prefix, chunk_id)</code>. Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.</p>
|
||||||
|
|
||||||
|
<h2 id="files">File Map</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>File</th><th>Size</th><th>Role</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mesh/mod.rs</code></td><td>52 KB</td><td>Public API, send paths, federation integration</td></tr>
|
||||||
|
<tr><td><code>mesh/protocol.rs</code></td><td>26 KB</td><td>Frame encoding/decoding, command builders</td></tr>
|
||||||
|
<tr><td><code>mesh/serial.rs</code></td><td>15 KB</td><td>USB driver, device detection, handshake</td></tr>
|
||||||
|
<tr><td><code>mesh/crypto.rs</code></td><td>10 KB</td><td>X25519 ECDH, ChaCha20-Poly1305, HKDF</td></tr>
|
||||||
|
<tr><td><code>mesh/ratchet.rs</code></td><td>16 KB</td><td>Double Ratchet implementation</td></tr>
|
||||||
|
<tr><td><code>mesh/message_types.rs</code></td><td>23 KB</td><td>23 typed message discriminators + CBOR schemas</td></tr>
|
||||||
|
<tr><td><code>mesh/bitcoin_relay.rs</code></td><td>17 KB</td><td>TxRelay / LightningRelay binary framing</td></tr>
|
||||||
|
<tr><td><code>mesh/listener/dispatch.rs</code></td><td>29 KB</td><td>Typed-message routing into chat/relay/alerts</td></tr>
|
||||||
|
<tr><td><code>mesh/listener/session.rs</code></td><td>14 KB</td><td>Ratchet session persistence + chunk reassembly</td></tr>
|
||||||
|
<tr><td><code>mesh/x3dh.rs</code></td><td>—</td><td>Prekey / SessionInit bootstrap</td></tr>
|
||||||
|
<tr><td><code>mesh/outbox.rs</code></td><td>—</td><td>Retry queue for unacked sends</td></tr>
|
||||||
|
<tr><td><code>mesh/steganography.rs</code></td><td>—</td><td>Weather/sensor framing for deniable traffic</td></tr>
|
||||||
|
<tr><td><code>api/rpc/mesh/typed_messages.rs</code></td><td>—</td><td>All <code>mesh.*</code> RPC handlers</td></tr>
|
||||||
|
<tr><td><code>neode-ui/src/stores/mesh.ts</code></td><td>14 KB</td><td>Pinia store consumed by all mesh Vue views</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Summary scoreboard</h2>
|
||||||
|
<div class="score-grid">
|
||||||
|
<div class="score-card"><div class="score">23</div><div class="label">Message types</div></div>
|
||||||
|
<div class="score-card"><div class="score">160</div><div class="label">Bytes / frame</div></div>
|
||||||
|
<div class="score-card"><div class="score">2</div><div class="label">Transports</div></div>
|
||||||
|
<div class="score-card"><div class="score">5</div><div class="label">Wire markers</div></div>
|
||||||
|
<div class="score-card"><div class="score">~6k</div><div class="label">LoC in mesh/</div></div>
|
||||||
|
<div class="score-card"><div class="score">FS</div><div class="label">Forward-secure</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="callout callout-success">
|
||||||
|
<strong>Bottom line.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -15,6 +15,21 @@ export interface RPCResponse<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
function getCsrfToken(): string | null {
|
||||||
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
|
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
|
||||||
if (match) return match[1]!
|
if (match) return match[1]!
|
||||||
@ -262,7 +277,7 @@ class RPCClient {
|
|||||||
|
|
||||||
// ─── Node Identity ───────────────────────────────────────────────
|
// ─── 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({
|
return this.call({
|
||||||
method: 'node.did',
|
method: 'node.did',
|
||||||
params: {},
|
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<string, unknown> = { pubkey }
|
||||||
|
if (alias !== undefined) params.alias = alias
|
||||||
|
return this.call({ method: 'mesh.contacts-save', params })
|
||||||
|
}
|
||||||
|
|
||||||
async federationListNodes(): Promise<{
|
async federationListNodes(): Promise<{
|
||||||
nodes: Array<{
|
nodes: Array<{
|
||||||
did: string
|
did: string
|
||||||
@ -590,6 +620,7 @@ class RPCClient {
|
|||||||
last_seen?: string
|
last_seen?: string
|
||||||
last_state?: {
|
last_state?: {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
node_name?: string
|
||||||
apps: Array<{ id: string; status: string; version?: string }>
|
apps: Array<{ id: string; status: string; version?: string }>
|
||||||
cpu_usage_percent?: number
|
cpu_usage_percent?: number
|
||||||
mem_used_bytes?: number
|
mem_used_bytes?: number
|
||||||
@ -598,6 +629,7 @@ class RPCClient {
|
|||||||
disk_total_bytes?: number
|
disk_total_bytes?: number
|
||||||
uptime_secs?: number
|
uptime_secs?: number
|
||||||
tor_active?: boolean
|
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<{
|
async federationSyncState(): Promise<{
|
||||||
synced: number
|
synced: number
|
||||||
failed: number
|
failed: number
|
||||||
|
|||||||
@ -45,6 +45,54 @@
|
|||||||
@clear-invite="inviteCode = ''"
|
@clear-invite="inviteCode = ''"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Nostr discoverability strip: opt-in toggle + Discover button.
|
||||||
|
Renders inline so the Federation page is the single place a user
|
||||||
|
manages everything related to peering. The toggle directly mutates
|
||||||
|
the `nostr_discovery_enabled` config flag — backend defaults to OFF
|
||||||
|
and nothing is published until the user explicitly turns it on. -->
|
||||||
|
<div class="glass-card p-4 mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-white">Nostr discoverability</span>
|
||||||
|
<span
|
||||||
|
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
||||||
|
:class="discoveryEnabled ? 'bg-green-500/20 text-green-300' : 'bg-white/10 text-white/50'"
|
||||||
|
>{{ discoveryEnabled ? 'On' : 'Off' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-white/60 mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||||
|
:disabled="discoveryToggling"
|
||||||
|
@click="toggleDiscovery"
|
||||||
|
>
|
||||||
|
{{ discoveryToggling ? '…' : (discoveryEnabled ? 'Disable' : 'Enable') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||||
|
:disabled="!discoveryEnabled"
|
||||||
|
@click="showDiscoverModal = true"
|
||||||
|
>
|
||||||
|
Discover Nodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="discoveryError" class="mb-4 text-xs text-red-400">{{ discoveryError }}</div>
|
||||||
|
|
||||||
|
<PendingRequestsPanel
|
||||||
|
:requests="pendingRequests"
|
||||||
|
:polling="pollingHandshake"
|
||||||
|
:busy-id="pendingBusyId"
|
||||||
|
@poll="pollHandshake"
|
||||||
|
@approve="approvePending"
|
||||||
|
@reject="rejectPending"
|
||||||
|
/>
|
||||||
|
|
||||||
<NodeList
|
<NodeList
|
||||||
:nodes="nodes"
|
:nodes="nodes"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@ -82,6 +130,13 @@
|
|||||||
@close="showJoinModal = false"
|
@close="showJoinModal = false"
|
||||||
@join="joinFederation"
|
@join="joinFederation"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DiscoverModal
|
||||||
|
:visible="showDiscoverModal"
|
||||||
|
:outbound-sent="pendingRequests"
|
||||||
|
@close="showDiscoverModal = false"
|
||||||
|
@sent="loadPendingRequests"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -97,7 +152,10 @@ import QuickActions from './federation/QuickActions.vue'
|
|||||||
import NodeList from './federation/NodeList.vue'
|
import NodeList from './federation/NodeList.vue'
|
||||||
import NodeDetailModal from './federation/NodeDetailModal.vue'
|
import NodeDetailModal from './federation/NodeDetailModal.vue'
|
||||||
import JoinModal from './federation/JoinModal.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 { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||||
|
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||||
import { nodeName, timeAgo } from './federation/utils'
|
import { nodeName, timeAgo } from './federation/utils'
|
||||||
|
|
||||||
const transportStore = useTransportStore()
|
const transportStore = useTransportStore()
|
||||||
@ -205,6 +263,89 @@ const rotateSuccess = ref('')
|
|||||||
// Dead node cleanup
|
// Dead node cleanup
|
||||||
const cleaningNodes = ref(false)
|
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<PendingPeerRequest[]>([])
|
||||||
|
const pollingHandshake = ref(false)
|
||||||
|
const pendingBusyId = ref<string | null>(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 {
|
function isOnlineCheck(node: FederatedNode): boolean {
|
||||||
if (!node.last_seen) return false
|
if (!node.last_seen) return false
|
||||||
const lastSeen = new Date(node.last_seen).getTime()
|
const lastSeen = new Date(node.last_seen).getTime()
|
||||||
@ -382,6 +523,8 @@ async function rotateDid(password: string) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadNodes()
|
loadNodes()
|
||||||
loadDwnStatus()
|
loadDwnStatus()
|
||||||
|
loadDiscoveryState()
|
||||||
|
loadPendingRequests()
|
||||||
transportStore.fetchPeers()
|
transportStore.fetchPeers()
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.getNodeDid()
|
const result = await rpcClient.getNodeDid()
|
||||||
|
|||||||
@ -145,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
|
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="vpnConnected ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">VPN</span></div>
|
||||||
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? (vpnStatus.provider || 'Connected') : 'Not configured' }}</span>
|
<span class="text-sm font-medium" :class="vpnConnected ? 'text-orange-400' : 'text-white/40'">{{ vpnConnected ? 'WireGuard' : 'Not configured' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
<div class="flex items-center gap-3"><div class="w-2 h-2 rounded-full" :class="systemStats.bitcoinAvailable ? 'bg-orange-400' : 'bg-white/40'"></div><span class="text-sm text-white/80">Bitcoin</span></div>
|
||||||
|
|||||||
@ -114,7 +114,7 @@
|
|||||||
<span class="text-white/80 text-sm">VPN</span>
|
<span class="text-white/80 text-sm">VPN</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
<span class="text-sm" :class="networkData.vpnConnected ? 'text-green-400' : 'text-white/40'">
|
||||||
{{ networkData.vpnConnected ? 'WireGuard / NostrVPN' : 'Not Connected' }}
|
{{ networkData.vpnConnected ? 'WireGuard' : 'Not Connected' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left" @click="showDnsModal = true">
|
<button class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left" @click="showDnsModal = true">
|
||||||
@ -160,8 +160,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||||
<!-- VPN Card -->
|
<!-- VPN Card -->
|
||||||
<div class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
|
<div class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||||
@ -169,53 +170,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-white">VPN</h2>
|
<h2 class="text-lg font-semibold text-white">VPN</h2>
|
||||||
<p class="text-xs text-white/50">WireGuard + NostrVPN mesh</p>
|
<p class="text-xs text-white/50">Standalone WireGuard VPN</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
|
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Node npub for sharing -->
|
<!-- WireGuard Status -->
|
||||||
<div v-if="nodeNpub" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
|
<div class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-xs text-white/40 shrink-0">npub</span>
|
|
||||||
<span class="text-xs font-mono text-white/60 truncate">{{ nodeNpub }}</span>
|
|
||||||
</div>
|
|
||||||
<button @click="copyNpub" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedNpub ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Private relay URLs for mesh VPN peer discovery -->
|
|
||||||
<div v-if="relayOnion" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-xs text-purple-400/70 shrink-0">relay (tor)</span>
|
|
||||||
<span class="text-xs font-mono text-white/60 truncate">{{ relayOnion }}</span>
|
|
||||||
</div>
|
|
||||||
<button @click="copyText(relayOnion, 'onion')" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'onion' ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="relayDirect" class="mb-3 px-3 py-2 bg-white/5 rounded-lg flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-xs text-white/40 shrink-0">relay (direct)</span>
|
|
||||||
<span class="text-xs font-mono text-white/60 truncate">{{ relayDirect }}</span>
|
|
||||||
</div>
|
|
||||||
<button @click="copyText(relayDirect, 'direct')" class="text-xs text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'direct' ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VPN IPs -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
|
||||||
<div class="p-3 bg-white/5 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<div class="w-2 h-2 rounded-full" :class="networkData.wgIp ? 'bg-green-400' : 'bg-white/20'"></div>
|
<div class="w-2 h-2 rounded-full" :class="networkData.wgIp ? 'bg-green-400' : 'bg-white/20'"></div>
|
||||||
<span class="text-xs text-white/50">WireGuard</span>
|
<span class="text-xs text-white/50">Server Address</span>
|
||||||
</div>
|
|
||||||
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Not active' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 bg-white/5 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<div class="w-2 h-2 rounded-full" :class="networkData.vpnIp ? 'bg-green-400' : 'bg-white/20'"></div>
|
|
||||||
<span class="text-xs text-white/50">NostrVPN</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-mono" :class="networkData.vpnIp ? 'text-white' : 'text-white/30'">{{ networkData.vpnIp || (networkData.vpnConnected ? 'Pair a device' : 'Not active') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-sm font-mono" :class="networkData.wgIp ? 'text-white' : 'text-white/30'">{{ networkData.wgIp || 'Starting...' }}</span>
|
||||||
|
<span v-if="networkData.wgPubkey" class="block text-xs font-mono text-white/30 mt-1 truncate">{{ networkData.wgPubkey }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connected Devices -->
|
<!-- Connected Devices -->
|
||||||
@ -225,15 +193,14 @@
|
|||||||
<span class="text-xs text-white/30">{{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }}</span>
|
<span class="text-xs text-white/30">{{ vpnPeers.length }} device{{ vpnPeers.length !== 1 ? 's' : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="vpnPeers.length" class="space-y-1">
|
<div v-if="vpnPeers.length" class="space-y-1">
|
||||||
<div v-for="peer in vpnPeers" :key="peer.name + (peer.npub || '')" class="flex items-center justify-between text-xs py-1.5 px-2 bg-white/5 rounded">
|
<div v-for="peer in vpnPeers" :key="peer.name" class="flex items-center justify-between text-xs py-1.5 px-2 bg-white/5 rounded">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium" :class="peer.type === 'nostrvpn' ? 'bg-purple-500/20 text-purple-300' : 'bg-blue-500/20 text-blue-300'">{{ peer.type === 'nostrvpn' ? 'NVP' : 'WG' }}</span>
|
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-blue-500/20 text-blue-300">WG</span>
|
||||||
<button v-if="peer.type !== 'nostrvpn'" @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
|
<button @click="showPeerConfig(peer.name)" class="text-white/70 hover:text-white transition-colors cursor-pointer">{{ peer.name }}</button>
|
||||||
<span v-else class="text-white/70">{{ peer.name }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-white/40 font-mono">{{ peer.ip?.replace(/\/\d+$/, '') || '' }}</span>
|
<span class="text-white/40 font-mono">{{ peer.ip?.replace(/\/\d+$/, '') || '' }}</span>
|
||||||
<button v-if="peer.type !== 'nostrvpn'" @click="removePeer(peer.name)" :disabled="removingPeer === peer.name" class="p-0.5 rounded hover:bg-white/10 text-white/30 hover:text-red-400 transition-colors" :title="'Remove ' + peer.name">
|
<button @click="removePeer(peer.name)" :disabled="removingPeer === peer.name" class="p-0.5 rounded hover:bg-white/10 text-white/30 hover:text-red-400 transition-colors" :title="'Remove ' + peer.name">
|
||||||
<svg v-if="removingPeer === peer.name" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
<svg v-if="removingPeer === peer.name" class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||||
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
<svg v-else class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
</button>
|
</button>
|
||||||
@ -244,101 +211,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Device Modal -->
|
<!-- Network Interfaces (second column on desktop) -->
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="modal">
|
|
||||||
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="closeDeviceModal">
|
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
||||||
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
|
|
||||||
<button @click="closeDeviceModal" class="p-1 rounded hover:bg-white/10 text-white/60"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
|
|
||||||
</div>
|
|
||||||
<!-- Loading state (for existing peer config) -->
|
|
||||||
<div v-if="loadingPeerConfig" class="text-center py-8">
|
|
||||||
<svg class="w-6 h-6 animate-spin text-white/40 mx-auto" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
|
||||||
</div>
|
|
||||||
<!-- Existing peer QR view -->
|
|
||||||
<div v-else-if="peerQrData && !showingNewDevice" class="text-center">
|
|
||||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
|
||||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
|
||||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
|
||||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- New device: tab selection -->
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="!peerQrData && !inviteData" class="flex gap-1 mb-4 bg-white/5 rounded-lg p-1">
|
|
||||||
<button @click="deviceTab = 'nvpn'" :class="deviceTab === 'nvpn' ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'" class="flex-1 py-1.5 text-xs font-medium rounded-md transition-colors">NostrVPN App</button>
|
|
||||||
<button @click="deviceTab = 'wg'" :class="deviceTab === 'wg' ? 'bg-white/10 text-white' : 'text-white/40 hover:text-white/60'" class="flex-1 py-1.5 text-xs font-medium rounded-md transition-colors">WireGuard App</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="deviceTab === 'nvpn'">
|
|
||||||
<!-- Step 2: QR + mesh details -->
|
|
||||||
<div v-if="inviteData" class="text-center">
|
|
||||||
<p class="text-xs text-white/40 mb-3">Step 2 of 2 — Join the mesh</p>
|
|
||||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="inviteData.qr_svg"></div>
|
|
||||||
<p class="text-sm text-white/70 mb-4">Scan with the <strong>NostrVPN</strong> app</p>
|
|
||||||
<!-- Manual entry details -->
|
|
||||||
<div class="border-t border-white/10 pt-3 mb-4 text-left">
|
|
||||||
<button @click="showMeshDetails = !showMeshDetails" class="text-xs text-white/40 hover:text-white/60 transition-colors mb-2 flex items-center gap-1">
|
|
||||||
<svg class="w-3 h-3 transition-transform" :class="showMeshDetails ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
|
||||||
Or enter manually in the app
|
|
||||||
</button>
|
|
||||||
<div v-if="showMeshDetails" class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
|
||||||
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Network</span><span class="text-xs font-mono text-white/70 truncate block">{{ inviteData.network_id }}</span></div>
|
|
||||||
<button @click="copyText(inviteData.network_id, 'net')" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'net' ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
|
||||||
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Node npub</span><span class="text-xs font-mono text-white/70 truncate block">{{ inviteData.npub }}</span></div>
|
|
||||||
<button @click="copyText(inviteData.npub, 'invnpub')" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'invnpub' ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-for="(relay, i) in (inviteData.relays || [])" :key="relay" class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
|
||||||
<div class="min-w-0"><span class="text-[10px] text-white/40 block">Relay {{ (inviteData.relays?.length || 0) > 1 ? i + 1 : '' }}</span><span class="text-xs font-mono text-white/70 truncate block">{{ relay }}</span></div>
|
|
||||||
<button @click="copyText(relay, 'relay' + i)" class="text-[10px] text-white/40 hover:text-white shrink-0 ml-2">{{ copiedField === 'relay' + i ? 'Copied' : 'Copy' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button @click="copyInvite" class="flex-1 glass-button py-2 text-xs">{{ copiedInvite ? 'Copied!' : 'Copy Invite Link' }}</button>
|
|
||||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Step 1: Enter phone npub -->
|
|
||||||
<div v-else>
|
|
||||||
<p class="text-xs text-white/40 mb-3">Step 1 of 2 — Enter your phone's npub</p>
|
|
||||||
<p class="text-sm text-white/50 mb-3">Open the <strong class="text-white/70">NostrVPN</strong> app on your phone, go to <strong class="text-white/70">Settings</strong>, and copy your npub.</p>
|
|
||||||
<input v-model="participantNpub" type="text" placeholder="npub1..." class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 font-mono mb-3" @keyup.enter="generateInviteWithNpub" />
|
|
||||||
<button @click="generateInviteWithNpub" :disabled="generatingInvite || !participantNpub.trim().startsWith('npub1')" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ generatingInvite ? 'Setting up...' : 'Next →' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="deviceTab === 'wg'">
|
|
||||||
<div v-if="peerQrData" class="text-center">
|
|
||||||
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
|
||||||
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
|
||||||
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
|
||||||
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<p class="text-sm text-white/50 mb-3">Generate a static WireGuard config for the standard WireGuard app.</p>
|
|
||||||
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
|
|
||||||
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
|
|
||||||
<!-- Network Interfaces -->
|
|
||||||
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
@ -369,7 +242,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
|
<div class="w-2 h-2 rounded-full" :class="iface.state === 'up' ? 'bg-green-400' : 'bg-white/30'"></div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-white">{{ iface.name }}</p>
|
<p class="text-sm text-white font-medium">{{ iface.name }}</p>
|
||||||
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
<p class="text-xs text-white/50">{{ iface.type === 'wifi' ? 'WiFi' : 'Ethernet' }} · {{ iface.mac }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -383,6 +256,60 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- close VPN+Network 2-col grid -->
|
||||||
|
|
||||||
|
<!-- Add Device Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="showAddDeviceModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click="closeDeviceModal">
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
|
<div @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Connect Device</h3>
|
||||||
|
<button @click="closeDeviceModal" class="p-1 rounded hover:bg-white/10 text-white/60"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
|
||||||
|
</div>
|
||||||
|
<!-- Loading state (for existing peer config) -->
|
||||||
|
<div v-if="loadingPeerConfig" class="text-center py-8">
|
||||||
|
<svg class="w-6 h-6 animate-spin text-white/40 mx-auto" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||||
|
</div>
|
||||||
|
<!-- Existing peer QR view -->
|
||||||
|
<div v-else-if="peerQrData && !showingNewDevice" class="text-center">
|
||||||
|
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
||||||
|
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||||
|
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||||
|
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- New device: WireGuard config -->
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="peerQrData">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div>
|
||||||
|
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
|
||||||
|
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="copyPeerConfig" class="flex-1 glass-button py-2 text-xs">{{ copiedConfig ? 'Copied!' : 'Copy Config' }}</button>
|
||||||
|
<button @click="closeDeviceModal" class="flex-1 glass-button py-2 text-xs">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-white/50 mb-3">Generate a WireGuard config for the standard WireGuard app.</p>
|
||||||
|
<input v-model="newPeerName" type="text" placeholder="Device name (e.g. iPhone)" class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 mb-3" @keyup.enter="createPeer" />
|
||||||
|
<button @click="createPeer" :disabled="creatingPeer || !newPeerName.trim()" class="w-full glass-button py-2.5 text-sm font-medium disabled:opacity-30">{{ creatingPeer ? 'Generating...' : 'Generate QR Code' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="peerError" class="text-sm text-red-400 mt-2">{{ peerError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
<!-- Tor Services -->
|
<!-- Tor Services -->
|
||||||
<TorServicesCard
|
<TorServicesCard
|
||||||
:tor-services="torServices"
|
:tor-services="torServices"
|
||||||
@ -475,7 +402,7 @@ const logCount = ref(0)
|
|||||||
const networkLoading = ref(true)
|
const networkLoading = ref(true)
|
||||||
const networkData = ref({
|
const networkData = ref({
|
||||||
wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A',
|
wifiCount: 'N/A', torConnected: false, forwardCount: 'N/A',
|
||||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', vpnHostname: '', vpnPeers: 0,
|
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||||
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
|
dnsProvider: 'system', dnsServers: [] as string[], dnsDoH: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -490,32 +417,11 @@ async function loadNetworkData() {
|
|||||||
])
|
])
|
||||||
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A' }
|
if (diagRes.status === 'fulfilled') { networkData.value.torConnected = diagRes.value.tor_connected; networkData.value.wifiCount = diagRes.value.wifi_count !== undefined ? `${diagRes.value.wifi_count} configured` : 'N/A' }
|
||||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||||
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; nodeNpub.value = vpnRes.value.node_npub ?? ''; relayOnion.value = vpnRes.value.relay_onion ?? ''; relayDirect.value = vpnRes.value.relay_direct ?? '' }
|
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).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 }
|
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 }
|
} 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
|
// VPN peer management
|
||||||
const showAddDeviceModal = ref(false)
|
const showAddDeviceModal = ref(false)
|
||||||
const newPeerName = ref('')
|
const newPeerName = ref('')
|
||||||
@ -578,52 +484,14 @@ async function removePeer(name: string) {
|
|||||||
finally { removingPeer.value = '' }
|
finally { removingPeer.value = '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceTab = ref<'nvpn' | 'wg'>('nvpn')
|
|
||||||
const showingNewDevice = ref(false)
|
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() {
|
function closeDeviceModal() {
|
||||||
showAddDeviceModal.value = false
|
showAddDeviceModal.value = false
|
||||||
peerQrData.value = null
|
peerQrData.value = null
|
||||||
inviteData.value = null
|
|
||||||
newPeerName.value = ''
|
newPeerName.value = ''
|
||||||
peerError.value = ''
|
peerError.value = ''
|
||||||
showingNewDevice.value = false
|
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() {
|
async function copyPeerConfig() {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const APP_PORTS: Record<string, number> = {
|
|||||||
'routstr': 8200,
|
'routstr': 8200,
|
||||||
'indeedhub': 7778,
|
'indeedhub': 7778,
|
||||||
'botfights': 9100,
|
'botfights': 9100,
|
||||||
'gitea': 3001,
|
'gitea': 3000,
|
||||||
'dwn': 3100,
|
'dwn': 3100,
|
||||||
'endurain': 8080,
|
'endurain': 8080,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { MarketplaceApp } from './types'
|
import type { MarketplaceApp } from './types'
|
||||||
|
|
||||||
const R = '23.182.128.160:3000/lfg2025'
|
const R = 'git.tx1138.com/lfg2025'
|
||||||
|
|
||||||
// ---------- Dynamic catalog from registry ----------
|
// ---------- Dynamic catalog from registry ----------
|
||||||
export interface CatalogFeatured {
|
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. */
|
/** Remote catalog URLs — tried in order. First success wins. */
|
||||||
const CATALOG_URLS = [
|
const CATALOG_URLS = [
|
||||||
// Primary: Gitea raw file (dynamic, updated without frontend rebuild)
|
// Primary: git.tx1138.com raw file (HTTPS, dynamic, updated without frontend rebuild)
|
||||||
// Legacy (down): https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json
|
'https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||||
// Fallback: direct IP if DNS fails
|
// 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',
|
'http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json',
|
||||||
// Last resort: local static file (baked into frontend build)
|
// Last resort: local static file (baked into frontend build)
|
||||||
'/catalog.json',
|
'/catalog.json',
|
||||||
|
|||||||
192
neode-ui/src/views/federation/DiscoverModal.vue
Normal file
192
neode-ui/src/views/federation/DiscoverModal.vue
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||||
|
@click.self="$emit('close')"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||||
|
<div class="glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto relative z-10">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white">Discover Nodes</h2>
|
||||||
|
<p class="text-xs text-white/60 mt-1">
|
||||||
|
Browses Nostr presence events from configured relays. Sending a
|
||||||
|
peer request never reveals your onion — only your DID + npub +
|
||||||
|
an optional message travel inside an encrypted DM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button @click="$emit('close')" class="text-white/40 hover:text-white/70 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 glass-button rounded text-sm text-white/90 hover:text-white disabled:opacity-50"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Searching…' : 'Search Relays' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="lastSearchAt" class="text-[11px] text-white/40">
|
||||||
|
Last search: {{ lastSearchAt }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-4 text-sm text-red-400">{{ error }}</div>
|
||||||
|
|
||||||
|
<!-- Manual entry: paste an npub directly -->
|
||||||
|
<div class="mb-6 p-3 bg-white/5 rounded-lg border border-white/10">
|
||||||
|
<p class="text-xs text-white/60 mb-2">
|
||||||
|
Already know an npub? Send a peer request directly.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
v-model="manualNpub"
|
||||||
|
placeholder="npub1…"
|
||||||
|
class="flex-1 bg-black/30 text-white text-xs rounded px-3 py-2 border border-white/10 focus:border-orange-400/50 focus:outline-none font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
|
||||||
|
:disabled="!manualNpub.trim() || sendingTo === manualNpub.trim()"
|
||||||
|
@click="sendDirect()"
|
||||||
|
>
|
||||||
|
Send Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="nodes.length === 0 && !loading" class="text-center py-8 text-white/40 text-sm">
|
||||||
|
No discoverable nodes found. Either no peers are advertising on the
|
||||||
|
configured relays, or your discoverability hasn't been enabled long
|
||||||
|
enough for relays to gossip yours.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="node in nodes"
|
||||||
|
:key="node.nostr_pubkey"
|
||||||
|
class="p-3 bg-white/5 rounded-lg border border-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm text-white truncate">
|
||||||
|
{{ shortNpub(node.nostr_npub) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
|
||||||
|
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
|
||||||
|
:disabled="sendingTo === node.nostr_pubkey || alreadySentTo(node.nostr_pubkey)"
|
||||||
|
@click="sendTo(node)"
|
||||||
|
>
|
||||||
|
{{ statusFor(node) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
interface DiscoverableNode {
|
||||||
|
nostr_pubkey: string
|
||||||
|
nostr_npub: string
|
||||||
|
did: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
/// Outbound rows from the parent's pending list, used to grey out
|
||||||
|
/// "Send Request" buttons for npubs we've already requested.
|
||||||
|
outboundSent: PendingPeerRequest[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
/// Fired after a successful send so the parent can refresh its
|
||||||
|
/// pending-requests list to show the new "Sent" row.
|
||||||
|
sent: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nodes = ref<DiscoverableNode[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const lastSearchAt = ref('')
|
||||||
|
const sendingTo = ref<string | null>(null)
|
||||||
|
const manualNpub = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(v) => {
|
||||||
|
if (v && nodes.value.length === 0) refresh()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await rpcClient.handshakeDiscover()
|
||||||
|
nodes.value = result.nodes
|
||||||
|
lastSearchAt.value = new Date().toLocaleTimeString()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTo(node: DiscoverableNode) {
|
||||||
|
await sendInternal(node.nostr_pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDirect() {
|
||||||
|
const v = manualNpub.value.trim()
|
||||||
|
if (!v) return
|
||||||
|
await sendInternal(v)
|
||||||
|
manualNpub.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendInternal(target: string) {
|
||||||
|
sendingTo.value = target
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await rpcClient.handshakeConnect(target)
|
||||||
|
emit('sent')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Send failed'
|
||||||
|
} finally {
|
||||||
|
sendingTo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alreadySentTo(npubHex: string): boolean {
|
||||||
|
return props.outboundSent.some(
|
||||||
|
(r) => r.outbound && r.from_nostr_pubkey === npubHex && r.state === 'sent',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFor(node: DiscoverableNode): string {
|
||||||
|
if (sendingTo.value === node.nostr_pubkey) return 'Sending…'
|
||||||
|
if (alreadySentTo(node.nostr_pubkey)) return 'Already sent'
|
||||||
|
return 'Send Request'
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortNpub(npub: string): string {
|
||||||
|
if (!npub || npub.length < 16) return npub
|
||||||
|
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
128
neode-ui/src/views/federation/PendingRequestsPanel.vue
Normal file
128
neode-ui/src/views/federation/PendingRequestsPanel.vue
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2>
|
||||||
|
<p class="text-xs text-white/60">
|
||||||
|
Inbound requests await your approval. Outbound requests show what you've sent.
|
||||||
|
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
|
||||||
|
to Trusted manually if you want them to receive state syncs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/80 hover:text-white disabled:opacity-50"
|
||||||
|
:disabled="polling"
|
||||||
|
@click="$emit('poll')"
|
||||||
|
>
|
||||||
|
{{ polling ? 'Polling…' : 'Poll Now' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="req in visibleRequests"
|
||||||
|
:key="req.id"
|
||||||
|
class="p-3 bg-white/5 rounded-lg border border-white/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
||||||
|
:class="badgeClass(req)"
|
||||||
|
>{{ badgeLabel(req) }}</span>
|
||||||
|
<span class="text-sm text-white font-medium truncate">
|
||||||
|
{{ req.from_name || shortNpub(req.from_nostr_npub) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-white/50 font-mono truncate">{{ req.from_nostr_npub }}</div>
|
||||||
|
<div v-if="req.from_did" class="text-[11px] text-white/40 font-mono truncate">{{ req.from_did }}</div>
|
||||||
|
<p v-if="req.message" class="mt-2 text-xs text-white/70 italic">"{{ req.message }}"</p>
|
||||||
|
<p class="mt-1 text-[10px] text-white/40">{{ relative(req.received_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="req.state === 'pending' && !req.outbound" class="flex flex-col gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-green-300 hover:text-green-200 disabled:opacity-50"
|
||||||
|
:disabled="busyId === req.id"
|
||||||
|
@click="$emit('approve', req.id)"
|
||||||
|
>
|
||||||
|
{{ busyId === req.id ? '…' : 'Approve' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-red-300 hover:text-red-200 disabled:opacity-50"
|
||||||
|
:disabled="busyId === req.id"
|
||||||
|
@click="$emit('reject', req.id)"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
requests: PendingPeerRequest[]
|
||||||
|
polling: boolean
|
||||||
|
busyId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
poll: []
|
||||||
|
approve: [id: string]
|
||||||
|
reject: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Hide already-handled rows older than 24h to keep the panel from growing
|
||||||
|
// indefinitely. The backend keeps the full audit trail in the file; the UI
|
||||||
|
// only shows what's actionable or recent.
|
||||||
|
const visibleRequests = computed(() => {
|
||||||
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
||||||
|
return props.requests.filter((r) => {
|
||||||
|
if (r.state === 'pending' || r.state === 'sent') return true
|
||||||
|
const ts = new Date(r.received_at).getTime()
|
||||||
|
return Number.isFinite(ts) && ts >= cutoff
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function badgeClass(r: PendingPeerRequest): string {
|
||||||
|
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
|
||||||
|
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
|
||||||
|
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
|
||||||
|
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
|
||||||
|
return 'bg-white/10 text-white/50'
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeLabel(r: PendingPeerRequest): string {
|
||||||
|
if (r.outbound && r.state === 'sent') return 'Sent'
|
||||||
|
if (r.outbound && r.state === 'approved') return 'They approved'
|
||||||
|
if (r.outbound && r.state === 'rejected') return 'They rejected'
|
||||||
|
if (r.outbound) return r.state
|
||||||
|
if (r.state === 'pending') return 'Inbound'
|
||||||
|
return r.state
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortNpub(npub: string): string {
|
||||||
|
if (!npub || npub.length < 16) return npub
|
||||||
|
return `${npub.slice(0, 12)}…${npub.slice(-6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string): string {
|
||||||
|
const ts = new Date(iso).getTime()
|
||||||
|
if (!Number.isFinite(ts)) return iso
|
||||||
|
const diff = Date.now() - ts
|
||||||
|
const sec = Math.floor(diff / 1000)
|
||||||
|
if (sec < 60) return `${sec}s ago`
|
||||||
|
const min = Math.floor(sec / 60)
|
||||||
|
if (min < 60) return `${min}m ago`
|
||||||
|
const hr = Math.floor(min / 60)
|
||||||
|
if (hr < 24) return `${hr}h ago`
|
||||||
|
const day = Math.floor(hr / 24)
|
||||||
|
return `${day}d ago`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -70,7 +70,7 @@ onMounted(fetchVpnStatus)
|
|||||||
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
|
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
|
||||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||||
<div class="text-xs text-white/50 mb-1">Provider</div>
|
<div class="text-xs text-white/50 mb-1">Provider</div>
|
||||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'nostr-vpn' }}</div>
|
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||||
<div class="text-xs text-white/50 mb-1">Peers</div>
|
<div class="text-xs text-white/50 mb-1">Peers</div>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
# ./scripts/deploy-to-target.sh # Sync and rebuild
|
# ./scripts/deploy-to-target.sh # Sync and rebuild
|
||||||
# ./scripts/deploy-to-target.sh --quick # Sync only, no 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 --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 --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 --demo # Demo mode: Bitcoin pruning enabled (smaller disk)
|
||||||
# ./scripts/deploy-to-target.sh --dry-run --live # Show what would be deployed without executing
|
# ./scripts/deploy-to-target.sh --dry-run --live # Show what would be deployed without executing
|
||||||
@ -55,6 +55,7 @@ CANARY=false
|
|||||||
TAILSCALE=false
|
TAILSCALE=false
|
||||||
TAILSCALE_NODE=""
|
TAILSCALE_NODE=""
|
||||||
FLEET=false
|
FLEET=false
|
||||||
|
RESET_MESH=false
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case $arg in
|
case $arg in
|
||||||
--quick) QUICK=true ;;
|
--quick) QUICK=true ;;
|
||||||
@ -68,6 +69,7 @@ for arg in "$@"; do
|
|||||||
--tailscale-node=*) TAILSCALE_NODE="${arg#*=}" ;;
|
--tailscale-node=*) TAILSCALE_NODE="${arg#*=}" ;;
|
||||||
--fleet) FLEET=true ;;
|
--fleet) FLEET=true ;;
|
||||||
--all) FLEET=true ;;
|
--all) FLEET=true ;;
|
||||||
|
--reset-mesh) RESET_MESH=true ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@ -93,8 +95,8 @@ if [ "$FLEET" = true ]; then
|
|||||||
echo "FAILED: .228 unreachable"; exit 1
|
echo "FAILED: .228 unreachable"; exit 1
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Phase 2: Copy to .198 (LAN secondary — skip if unreachable)"
|
echo "Phase 2: Copy to .198 + .253 (LAN secondaries — skip if unreachable)"
|
||||||
"$0" --both 2>/dev/null || echo " .198 unreachable, skipping"
|
"$0" --both 2>/dev/null || echo " LAN secondaries unreachable, skipping"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Phase 3: Deploy to all Tailscale nodes (Arch 1/2/3)"
|
echo "Phase 3: Deploy to all Tailscale nodes (Arch 1/2/3)"
|
||||||
"$SCRIPT_DIR/deploy-tailscale.sh" --all || { echo "WARNING: Some Tailscale nodes failed"; }
|
"$SCRIPT_DIR/deploy-tailscale.sh" --all || { echo "WARNING: Some Tailscale nodes failed"; }
|
||||||
@ -333,22 +335,19 @@ if [ "$CANARY" = true ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# When --both: deploy to 228 first, then copy to 198
|
# ── deploy_secondary: copy built binary+frontend from .228 to a secondary node ──
|
||||||
if [ "$BOTH" = true ]; then
|
# Usage: deploy_secondary <user@ip> <short_label> (e.g. deploy_secondary archipelago@192.168.1.198 198)
|
||||||
echo "Deploying to both servers (228, then 198)..."
|
deploy_secondary() {
|
||||||
# Release lock so the recursive --live call can acquire it
|
local SEC_TARGET="$1"
|
||||||
rm -rf "$LOCK_DIR" 2>/dev/null; trap - EXIT
|
local SEC_LABEL="$2"
|
||||||
"$0" --live
|
local SEC_IP="${SEC_TARGET#*@}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📤 Copying to 192.168.1.198 (no rsync/cargo on that node)..."
|
echo "📤 Copying to $SEC_IP (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
|
scp $SSH_OPTS /tmp/archipelago-both "$SEC_TARGET:/tmp/archipelago-new"
|
||||||
echo " ERROR: Failed to copy binary from .228 — is the build available?"
|
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 -"
|
||||||
exit 1
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
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" '
|
|
||||||
sudo systemctl stop archipelago
|
sudo systemctl stop archipelago
|
||||||
sudo cp /tmp/archipelago-new /usr/local/bin/archipelago
|
sudo cp /tmp/archipelago-new /usr/local/bin/archipelago
|
||||||
sudo chmod +x /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
|
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"
|
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
|
||||||
if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then
|
if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then
|
||||||
echo " Deploying AIUI to 198..."
|
echo " Deploying AIUI to .$SEC_LABEL..."
|
||||||
ssh $SSH_OPTS "$TARGET_198" "sudo mkdir -p /opt/archipelago/web-ui/aiui && sudo rm -rf /opt/archipelago/web-ui/aiui/*"
|
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 "$TARGET_198" "sudo tar xf - -C /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"
|
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
|
fi
|
||||||
|
|
||||||
# Sync nginx config + snippets + fixes to 198
|
# Sync nginx config + snippets
|
||||||
NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf"
|
NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf"
|
||||||
SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets"
|
SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets"
|
||||||
if [ -f "$NGINX_CFG" ]; then
|
if [ -f "$NGINX_CFG" ]; then
|
||||||
echo " Syncing nginx config to 198..."
|
echo " Syncing nginx config to .$SEC_LABEL..."
|
||||||
scp $SSH_OPTS "$NGINX_CFG" "$TARGET_198:/tmp/nginx-archipelago.conf" 2>/dev/null || true
|
scp $SSH_OPTS "$NGINX_CFG" "$SEC_TARGET:/tmp/nginx-archipelago.conf" 2>/dev/null || true
|
||||||
ssh $SSH_OPTS "$TARGET_198" '
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago
|
sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago
|
||||||
sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf
|
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
|
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
|
rm -f /tmp/nginx-archipelago.conf
|
||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
# Sync nginx snippets to 198
|
|
||||||
if [ -d "$SNIPPETS_DIR" ]; then
|
if [ -d "$SNIPPETS_DIR" ]; then
|
||||||
echo " Syncing nginx snippets to 198..."
|
echo " Syncing nginx snippets to .$SEC_LABEL..."
|
||||||
ssh $SSH_OPTS "$TARGET_198" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true
|
ssh $SSH_OPTS "$SEC_TARGET" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true
|
||||||
for f in "$SNIPPETS_DIR"/*.conf; do
|
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
|
done
|
||||||
ssh $SSH_OPTS "$TARGET_198" '
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
for f in /tmp/nginx-snippet-*.conf; do
|
for f in /tmp/nginx-snippet-*.conf; do
|
||||||
[ -f "$f" ] && sudo mv "$f" "/etc/nginx/snippets/$(basename "$f" | sed "s/^nginx-snippet-//")"
|
[ -f "$f" ] && sudo mv "$f" "/etc/nginx/snippets/$(basename "$f" | sed "s/^nginx-snippet-//")"
|
||||||
done
|
done
|
||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
ssh $SSH_OPTS "$TARGET_198" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " nginx config test failed"' 2>/dev/null || true
|
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 to 198
|
|
||||||
|
# Sync systemd service file
|
||||||
SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service"
|
SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service"
|
||||||
if [ -f "$SERVICE_FILE" ]; then
|
if [ -f "$SERVICE_FILE" ]; then
|
||||||
echo " Syncing systemd service to 198..."
|
echo " Syncing systemd service to .$SEC_LABEL..."
|
||||||
scp $SSH_OPTS "$SERVICE_FILE" "$TARGET_198:/tmp/archipelago.service" 2>/dev/null || true
|
scp $SSH_OPTS "$SERVICE_FILE" "$SEC_TARGET:/tmp/archipelago.service" 2>/dev/null || true
|
||||||
ssh $SSH_OPTS "$TARGET_198" '
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
if ! diff -q /tmp/archipelago.service /etc/systemd/system/archipelago.service >/dev/null 2>&1; then
|
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 cp /tmp/archipelago.service /etc/systemd/system/archipelago.service
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
@ -412,12 +411,12 @@ if [ "$BOTH" = true ]; then
|
|||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
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"
|
UDEV_RULE="$PROJECT_DIR/image-recipe/configs/99-mesh-radio.rules"
|
||||||
if [ -f "$UDEV_RULE" ]; then
|
if [ -f "$UDEV_RULE" ]; then
|
||||||
echo " Syncing udev rule to 198..."
|
echo " Syncing udev rule to .$SEC_LABEL..."
|
||||||
scp $SSH_OPTS "$UDEV_RULE" "$TARGET_198:/tmp/99-mesh-radio.rules" 2>/dev/null || true
|
scp $SSH_OPTS "$UDEV_RULE" "$SEC_TARGET:/tmp/99-mesh-radio.rules" 2>/dev/null || true
|
||||||
ssh $SSH_OPTS "$TARGET_198" '
|
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
|
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 cp /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules
|
||||||
sudo udevadm control --reload-rules
|
sudo udevadm control --reload-rules
|
||||||
@ -430,8 +429,8 @@ if [ "$BOTH" = true ]; then
|
|||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Dev mode + FileBrowser on 198
|
# Dev mode + FileBrowser
|
||||||
ssh $SSH_OPTS "$TARGET_198" '
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
# Dev mode
|
# Dev mode
|
||||||
if ! grep -q "ARCHIPELAGO_DEV_MODE=true" /etc/systemd/system/archipelago.service.d/override.conf 2>/dev/null; then
|
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
|
sudo mkdir -p /etc/systemd/system/archipelago.service.d
|
||||||
@ -454,9 +453,10 @@ if [ "$BOTH" = true ]; then
|
|||||||
fi
|
fi
|
||||||
' 2>/dev/null || true
|
' 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)
|
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": "$DEPLOY_COMMIT_FULL",
|
||||||
"commit_short": "$DEPLOY_COMMIT",
|
"commit_short": "$DEPLOY_COMMIT",
|
||||||
@ -464,30 +464,50 @@ if [ "$BOTH" = true ]; then
|
|||||||
"dirty": $DEPLOY_DIRTY,
|
"dirty": $DEPLOY_DIRTY,
|
||||||
"deployed_at": "$DEPLOY_TS",
|
"deployed_at": "$DEPLOY_TS",
|
||||||
"deployed_from": "$(hostname)",
|
"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
|
# Run container doctor
|
||||||
echo " Running container doctor on .198..."
|
echo " Running container doctor on .$SEC_LABEL..."
|
||||||
"$SCRIPT_DIR/container-doctor.sh" "$TARGET_198" 2>&1 | sed 's/^/ /' || true
|
"$SCRIPT_DIR/container-doctor.sh" "$SEC_TARGET" 2>&1 | sed 's/^/ /' || true
|
||||||
|
|
||||||
# Post-deploy health check on .198
|
# Post-deploy health check
|
||||||
echo " Checking .198 health..."
|
echo " Checking .$SEC_LABEL health..."
|
||||||
HEALTH_198="fail"
|
local HEALTH="fail"
|
||||||
for i in $(seq 1 12); do
|
for i in $(seq 1 12); do
|
||||||
sleep 5
|
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 ""; })
|
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_198" = "OK" ]; then
|
if [ "$HEALTH" = "OK" ]; then
|
||||||
echo " ✅ 192.168.1.198 deployed (health OK after $((i * 5))s)"
|
echo " ✅ $SEC_IP deployed (health OK after $((i * 5))s)"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ "$HEALTH_198" != "OK" ]; then
|
if [ "$HEALTH" != "OK" ]; then
|
||||||
echo " ⚠️ 192.168.1.198 deployed but health check failed after 60s"
|
echo " ⚠️ $SEC_IP deployed but health check failed after 60s"
|
||||||
fi
|
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
|
rm -f /tmp/archipelago-both
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
elif ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then
|
||||||
progress "Deploying backend binary"
|
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'
|
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/"
|
ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -109,13 +109,13 @@ if [ -f "$UNBUNDLED_MARKER" ]; then
|
|||||||
log "Creating FileBrowser (noauth)..."
|
log "Creating FileBrowser (noauth)..."
|
||||||
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
|
mkdir -p /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
|
||||||
mkdir -p /var/lib/archipelago/filebrowser/{Documents,Photos,Music,Videos,Downloads}
|
mkdir -p /var/lib/archipelago/filebrowser/{Documents,Photos,Music,Videos,Downloads}
|
||||||
chown -R 1000:1000 /var/lib/archipelago/filebrowser
|
chown -R 100000:100000 /var/lib/archipelago/filebrowser
|
||||||
chown -R 1000:1000 /var/lib/archipelago/filebrowser-data
|
chown -R 100000:100000 /var/lib/archipelago/filebrowser-data
|
||||||
# Write config with database on persistent volume
|
# Write config with database on persistent volume
|
||||||
cat > /var/lib/archipelago/filebrowser-data/.filebrowser.json <<'FBEOF'
|
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"}
|
{"port":80,"baseURL":"","address":"0.0.0.0","database":"/data/filebrowser.db","root":"/srv","log":"stdout"}
|
||||||
FBEOF
|
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}"
|
pull_with_fallback "${FILEBROWSER_IMAGE}"
|
||||||
$DOCKER run -d --name filebrowser --restart unless-stopped \
|
$DOCKER run -d --name filebrowser --restart unless-stopped \
|
||||||
--network archy-net \
|
--network archy-net \
|
||||||
@ -141,25 +141,25 @@ FBEOF
|
|||||||
chown -R 1000:1000 /var/lib/archipelago/secrets
|
chown -R 1000:1000 /var/lib/archipelago/secrets
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate WireGuard keys for VPN
|
# Generate WireGuard keys for standalone VPN (archipelago-wg service)
|
||||||
if [ ! -f /var/lib/archipelago/wireguard/wg0.conf ]; then
|
WG_DIR="/var/lib/archipelago/wireguard"
|
||||||
|
if [ ! -f "$WG_DIR/private.key" ]; then
|
||||||
log "Generating WireGuard keys..."
|
log "Generating WireGuard keys..."
|
||||||
mkdir -p /var/lib/archipelago/wireguard /etc/wireguard
|
mkdir -p "$WG_DIR"
|
||||||
PRIVKEY=$(wg genkey)
|
wg genkey > "$WG_DIR/private.key" 2>/dev/null
|
||||||
PUBKEY=$(echo "$PRIVKEY" | wg pubkey)
|
chmod 600 "$WG_DIR/private.key"
|
||||||
cat > /var/lib/archipelago/wireguard/wg0.conf <<WGEOF
|
wg pubkey < "$WG_DIR/private.key" > "$WG_DIR/public.key"
|
||||||
[Interface]
|
chown -R 1000:1000 "$WG_DIR"
|
||||||
PrivateKey = $PRIVKEY
|
log " WireGuard keypair generated: pubkey=$(cat "$WG_DIR/public.key")"
|
||||||
Address = 10.0.0.1/24
|
|
||||||
ListenPort = 51820
|
|
||||||
WGEOF
|
|
||||||
cp /var/lib/archipelago/wireguard/wg0.conf /etc/wireguard/wg0.conf
|
|
||||||
chmod 600 /etc/wireguard/wg0.conf /var/lib/archipelago/wireguard/wg0.conf
|
|
||||||
chown -R 1000:1000 /var/lib/archipelago/wireguard
|
|
||||||
systemctl enable wg-quick@wg0 2>/dev/null || true
|
|
||||||
wg-quick up wg0 2>>"$LOG" || true
|
|
||||||
log " WireGuard configured: pubkey=$PUBKEY"
|
|
||||||
fi
|
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"
|
log "Unbundled first-boot complete"
|
||||||
exit 0
|
exit 0
|
||||||
@ -242,98 +242,10 @@ else
|
|||||||
log "nostr-rs-relay binary not found — skipping relay setup"
|
log "nostr-rs-relay binary not found — skipping relay setup"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── NostrVPN: configure native system service with node identity ──────
|
# ── NostrVPN: DISABLED — using standalone WireGuard only ──────────────
|
||||||
# The nvpn binary may have GLIBC mismatch (built for newer glibc than target OS).
|
# NostrVPN (nvpn) is disabled for now. Standalone WireGuard (archipelago-wg)
|
||||||
# Write config.toml directly as fallback — the Rust backend reads it for vpn.invite/status.
|
# handles VPN with QR-based peer provisioning via the web UI.
|
||||||
NOSTR_SECRET=$(cat /var/lib/archipelago/identity/nostr_secret 2>/dev/null)
|
log "NostrVPN disabled — standalone WireGuard only (wg0:51820)"
|
||||||
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" <<NVPNCONF
|
|
||||||
[nostr]
|
|
||||||
public_key = "${NOSTR_PUBKEY}"
|
|
||||||
secret_key = "${NOSTR_SECRET}"
|
|
||||||
relays = [${RELAYS}]
|
|
||||||
|
|
||||||
[[networks]]
|
|
||||||
network_id = "archipelago"
|
|
||||||
participants = []
|
|
||||||
NVPNCONF
|
|
||||||
chmod 600 "$DAEMON_CONFIG_DIR/config.toml"
|
|
||||||
log "NostrVPN: wrote config.toml directly (hex keys, backend converts)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Sync user config to daemon dir if nvpn CLI created it
|
|
||||||
if [ -f "$NVPN_CONFIG_DIR/config.toml" ] && [ ! -f "$DAEMON_CONFIG_DIR/config.toml" ]; then
|
|
||||||
cp "$NVPN_CONFIG_DIR/config.toml" "$DAEMON_CONFIG_DIR/config.toml"
|
|
||||||
fi
|
|
||||||
chown -R archipelago:archipelago /var/lib/archipelago/nostr-vpn
|
|
||||||
|
|
||||||
# Ensure env file exists for the service
|
|
||||||
mkdir -p /var/lib/archipelago/nostr-vpn
|
|
||||||
cat > /var/lib/archipelago/nostr-vpn/env <<NVPNENV
|
|
||||||
NOSTR_SECRET=${NOSTR_SECRET}
|
|
||||||
NOSTR_PUBKEY=${NOSTR_PUBKEY}
|
|
||||||
NVPNENV
|
|
||||||
chmod 600 /var/lib/archipelago/nostr-vpn/env
|
|
||||||
|
|
||||||
# Start NostrVPN mesh service (standalone WG already started above)
|
|
||||||
systemctl reset-failed nostr-vpn 2>/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
|
|
||||||
|
|
||||||
# Wait for a container to be healthy (accepting connections)
|
# Wait for a container to be healthy (accepting connections)
|
||||||
wait_for_container() {
|
wait_for_container() {
|
||||||
@ -497,6 +409,8 @@ grep -q "^archipelago:" /etc/subuid 2>/dev/null || {
|
|||||||
echo "archipelago:100000:65536" >> /etc/subgid
|
echo "archipelago:100000:65536" >> /etc/subgid
|
||||||
log " subuid/subgid configured"
|
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)
|
# Ensure /etc/hosts is readable (rootless podman needs it)
|
||||||
chmod 644 /etc/hosts 2>/dev/null
|
chmod 644 /etc/hosts 2>/dev/null
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,8 @@
|
|||||||
# to verify against the registry.
|
# to verify against the registry.
|
||||||
|
|
||||||
# Archipelago app registries (primary + fallback)
|
# Archipelago app registries (primary + fallback)
|
||||||
ARCHY_REGISTRY="23.182.128.160:3000/lfg2025"
|
ARCHY_REGISTRY="git.tx1138.com/lfg2025"
|
||||||
ARCHY_REGISTRY_FALLBACK="git.tx1138.com/lfg2025"
|
ARCHY_REGISTRY_FALLBACK="23.182.128.160:3000/lfg2025"
|
||||||
|
|
||||||
# Bitcoin stack
|
# Bitcoin stack
|
||||||
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"
|
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user