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:
co-authored by
Claude Opus 4.6
parent
e210376e05
commit
9dd802998c
@@ -75,6 +75,8 @@ impl RpcHandler {
|
||||
"handshake.discover" => self.handle_handshake_discover().await,
|
||||
"handshake.connect" => self.handle_handshake_connect(params).await,
|
||||
"handshake.poll" => self.handle_handshake_poll().await,
|
||||
"nostr.discovery-status" => self.handle_nostr_discovery_status().await,
|
||||
"nostr.set-discovery" => self.handle_nostr_set_discovery(params).await,
|
||||
|
||||
// TOTP 2FA
|
||||
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
use super::*;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::credentials;
|
||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
||||
use crate::federation::{self, pending, FederatedNode, TrustLevel};
|
||||
use crate::identity;
|
||||
use crate::mesh;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use crate::nostr_handshake;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
||||
|
||||
impl RpcHandler {
|
||||
/// Register a federation node with the running mesh service so it's
|
||||
/// immediately addressable as a chat target. The mesh service seeds
|
||||
/// federation peers at startup, but federation nodes added or rotated
|
||||
/// later in the session would otherwise stay invisible to the mesh
|
||||
/// chat UI until the next mesh restart, and `mesh.send` against the
|
||||
/// frontend's synthesised contact_id would fail with "Unknown
|
||||
/// federation peer". Best-effort: silently no-ops when mesh is off.
|
||||
async fn register_federation_peer_in_mesh(
|
||||
&self,
|
||||
pubkey_hex: &str,
|
||||
did: &str,
|
||||
name: Option<&str>,
|
||||
) {
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||
@@ -65,6 +88,12 @@ impl RpcHandler {
|
||||
|
||||
info!(peer_did = %node.did, "Joined federation with peer");
|
||||
|
||||
// Make the new peer immediately addressable from the mesh chat UI.
|
||||
// Without this, the row exists in the federation list but `mesh.send`
|
||||
// against it fails until the next mesh service restart re-seeds.
|
||||
self.register_federation_peer_in_mesh(&node.pubkey, &node.did, node.name.as_deref())
|
||||
.await;
|
||||
|
||||
// Store federation membership as DWN message
|
||||
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
||||
let dwn_data = serde_json::json!({
|
||||
@@ -315,8 +344,20 @@ impl RpcHandler {
|
||||
let tor_active = data.server_info.tor_address.is_some();
|
||||
|
||||
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
|
||||
|
||||
// Encode our local Nostr identity as bech32 npub so federated peers
|
||||
// can display it under our name in the mesh UI without each peer
|
||||
// having to know how to convert hex → bech32 themselves.
|
||||
let nostr_npub = tokio::fs::read_to_string(self.config.data_dir.join("identity/nostr_pubkey"))
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|hex| nostr_sdk::PublicKey::from_hex(&hex).ok())
|
||||
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok());
|
||||
|
||||
let state = federation::build_local_state(
|
||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name,
|
||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name, nostr_npub,
|
||||
);
|
||||
|
||||
Ok(serde_json::to_value(&state)?)
|
||||
@@ -395,6 +436,10 @@ impl RpcHandler {
|
||||
federation::add_node(&self.config.data_dir, node).await?;
|
||||
info!(peer_did = %did, "Peer joined our federation");
|
||||
|
||||
// Mirror into mesh state so the inbound peer is addressable from
|
||||
// the chat UI without waiting for the next mesh restart.
|
||||
self.register_federation_peer_in_mesh(pubkey, did, None).await;
|
||||
|
||||
Ok(serde_json::json!({ "accepted": true }))
|
||||
}
|
||||
|
||||
@@ -698,11 +743,31 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let old_pubkey = node.pubkey.clone();
|
||||
let rotated_name = node.name.clone();
|
||||
node.did = new_did.to_string();
|
||||
node.pubkey = new_pubkey.to_string();
|
||||
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||
|
||||
// Drop the stale mesh peer entry keyed by the old pubkey's
|
||||
// synthetic contact_id, then upsert a fresh one under the
|
||||
// new pubkey so the chat UI doesn't show two rows post-rotation.
|
||||
{
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
let state = svc.shared_state();
|
||||
let stale_id = mesh::federation_peer_contact_id(&old_pubkey);
|
||||
state.peers.write().await.remove(&stale_id);
|
||||
mesh::upsert_federation_peer(
|
||||
&state,
|
||||
new_pubkey,
|
||||
new_did,
|
||||
rotated_name.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
old_did = %old_did,
|
||||
new_did = %new_did,
|
||||
@@ -725,4 +790,142 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// federation.list-pending-requests — return the inbox of inbound peer
|
||||
/// requests received over Nostr (and our outbound `Sent` rows). Each
|
||||
/// row carries a stable `id` the FE refers to when calling
|
||||
/// `federation.approve-request` / `federation.reject-request`.
|
||||
pub(in crate::api::rpc) async fn handle_federation_list_pending_requests(
|
||||
&self,
|
||||
) -> Result<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 crate::{nostr_handshake, peers};
|
||||
use anyhow::Result;
|
||||
use crate::federation::pending::{
|
||||
self, PendingPeerRequest, PendingState,
|
||||
};
|
||||
use crate::nostr_handshake::{self, HandshakeMessage};
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::FromBech32;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||
|
||||
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
|
||||
/// config file is read once at boot and is OFF by default; this state file
|
||||
/// lets the user flip discoverability on/off at runtime via the Federation
|
||||
/// UI without restarting the service. Both the boot-time presence publish
|
||||
/// and the `handshake.poll` handler check this file before doing anything.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct NostrDiscoveryState {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
|
||||
let path = data_dir.join(NOSTR_STATE_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => NostrDiscoveryState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_discovery_state(
|
||||
data_dir: &std::path::Path,
|
||||
state: &NostrDiscoveryState,
|
||||
) -> Result<()> {
|
||||
let path = data_dir.join(NOSTR_STATE_FILE);
|
||||
let content = serde_json::to_string_pretty(state).context("serialize discovery state")?;
|
||||
tokio::fs::write(&path, content)
|
||||
.await
|
||||
.context("write discovery state")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses).
|
||||
/// Read the current runtime discoverability flag.
|
||||
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<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> {
|
||||
// Discoverability gate: respect the runtime toggle. We allow `discover`
|
||||
// to query relays as long as the user is actively browsing — they're
|
||||
// an anonymous observer of presence events, not publishing anything.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
@@ -16,59 +124,90 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "nodes": nodes }))
|
||||
}
|
||||
|
||||
/// Send encrypted connection request to a peer's Nostr pubkey.
|
||||
/// Params: { recipient_nostr_pubkey }
|
||||
/// Send a `PeerRequest` to a discovered node. Onion is never sent.
|
||||
/// Params: `{ recipient_nostr_pubkey, message?, name? }`.
|
||||
pub(super) async fn handle_handshake_connect(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
// Accept either hex pubkey or npub1... bech32 format
|
||||
let recipient_raw = params
|
||||
.get("recipient_nostr_pubkey")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
||||
let recipient = if recipient_raw.starts_with("npub1") {
|
||||
let recipient_hex = if recipient_raw.starts_with("npub1") {
|
||||
nostr_sdk::PublicKey::from_bech32(recipient_raw)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
||||
.to_hex()
|
||||
} else {
|
||||
recipient_raw.to_string()
|
||||
};
|
||||
let recipient = recipient.as_str();
|
||||
let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex)
|
||||
.ok()
|
||||
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok())
|
||||
.unwrap_or_default();
|
||||
let message = params.get("message").and_then(|v| v.as_str());
|
||||
let optional_name = params.get("name").and_then(|v| v.as_str());
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let our_onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?;
|
||||
let our_node_pubkey = &data.server_info.pubkey;
|
||||
let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey)
|
||||
.unwrap_or_default();
|
||||
let our_did =
|
||||
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
let our_version = &data.server_info.version;
|
||||
let our_name = data.server_info.name.as_deref();
|
||||
let our_name = optional_name.or(data.server_info.name.as_deref());
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
nostr_handshake::send_connect_request(
|
||||
nostr_handshake::send_peer_request(
|
||||
&identity_dir,
|
||||
recipient,
|
||||
our_onion,
|
||||
our_node_pubkey,
|
||||
&recipient_hex,
|
||||
&our_did,
|
||||
our_version,
|
||||
our_name,
|
||||
message,
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "sent_to": recipient }))
|
||||
// Record the outbound request so the user can see "Sent" status
|
||||
// and so the eventual NIP-44 PeerInvite reply can be matched.
|
||||
let row = pending::insert_outbound(
|
||||
&self.config.data_dir,
|
||||
recipient_hex.clone(),
|
||||
recipient_npub,
|
||||
String::new(), // remote DID unknown until they reply
|
||||
None,
|
||||
message.map(String::from),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"sent_to": recipient_hex,
|
||||
"id": row.id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Poll for incoming encrypted handshake messages (connect requests/responses).
|
||||
/// Auto-adds peers and auto-responds to requests.
|
||||
/// Poll relays for inbound NIP-44 handshake messages, then dispatch:
|
||||
/// - `PeerRequest` → queue in `federation::pending` for approval
|
||||
/// - `PeerInvite` → apply via federation invite flow (adds as Observer)
|
||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||
///
|
||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<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 handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
@@ -78,72 +217,159 @@ impl RpcHandler {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut added_peers = Vec::new();
|
||||
let mut new_requests: Vec<PendingPeerRequest> = 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 {
|
||||
let (onion, node_pubkey, name) = match &hs.message {
|
||||
nostr_handshake::HandshakeMessage::ConnectRequest {
|
||||
onion,
|
||||
node_pubkey,
|
||||
match &hs.message {
|
||||
HandshakeMessage::PeerRequest {
|
||||
from_did,
|
||||
version: _,
|
||||
name,
|
||||
..
|
||||
message,
|
||||
} => {
|
||||
// Auto-respond with our details
|
||||
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
|
||||
let our_did = crate::identity::did_key_from_pubkey_hex(
|
||||
&data.server_info.pubkey,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let _ = nostr_handshake::send_connect_response(
|
||||
&identity_dir,
|
||||
&hs.from_nostr_pubkey,
|
||||
our_onion,
|
||||
&data.server_info.pubkey,
|
||||
&our_did,
|
||||
&data.server_info.version,
|
||||
data.server_info.name.as_deref(),
|
||||
&self.config.nostr_relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await;
|
||||
match pending::insert_inbound(
|
||||
&self.config.data_dir,
|
||||
hs.from_nostr_pubkey.clone(),
|
||||
hs.from_nostr_npub.clone(),
|
||||
from_did.clone(),
|
||||
name.clone(),
|
||||
message.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => new_requests.push(row),
|
||||
Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
error = %e,
|
||||
"Dropped peer request (rate limit or storage error)"
|
||||
);
|
||||
skipped.push(hs.from_nostr_pubkey.clone());
|
||||
}
|
||||
}
|
||||
(onion.clone(), node_pubkey.clone(), name.clone())
|
||||
}
|
||||
nostr_handshake::HandshakeMessage::ConnectResponse {
|
||||
onion,
|
||||
node_pubkey,
|
||||
name,
|
||||
..
|
||||
} => (onion.clone(), node_pubkey.clone(), name.clone()),
|
||||
};
|
||||
HandshakeMessage::PeerInvite { invite_code } => {
|
||||
// Match against an outbound Sent request from this nostr
|
||||
// pubkey. If we never sent them anything, ignore — we
|
||||
// don't accept unsolicited invites over Nostr.
|
||||
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||
let matching = pendings.iter().find(|r| {
|
||||
r.outbound
|
||||
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||
&& matches!(r.state, PendingState::Sent)
|
||||
});
|
||||
let Some(row) = matching else {
|
||||
tracing::warn!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
"Ignoring unsolicited PeerInvite — no matching Sent request"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let row_id = row.id.clone();
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(
|
||||
&data.server_info.pubkey,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let local_onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
// Auto-add as peer
|
||||
let peer = peers::KnownPeer {
|
||||
onion,
|
||||
pubkey: node_pubkey.clone(),
|
||||
name,
|
||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
};
|
||||
let _ = peers::add_peer(&self.config.data_dir, peer).await;
|
||||
added_peers.push(node_pubkey);
|
||||
let identity_dir2 = self.config.data_dir.join("identity");
|
||||
let node_identity =
|
||||
crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?;
|
||||
match crate::federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
invite_code,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(node) => {
|
||||
// Approved-by-them: their box already has us as Observer
|
||||
// (their approval handler added us under that trust level
|
||||
// before sending the invite). Demote our local entry to
|
||||
// Observer too — accept_invite hardcodes Trusted, but the
|
||||
// discovery flow should never auto-trust.
|
||||
let _ = crate::federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
&node.did,
|
||||
crate::federation::TrustLevel::Observer,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Mirror into the mesh peer table immediately so the
|
||||
// chat UI can address the new peer without waiting
|
||||
// for the next mesh restart.
|
||||
let svc = self.mesh_service.read().await;
|
||||
if let Some(svc) = svc.as_ref() {
|
||||
crate::mesh::upsert_federation_peer(
|
||||
&svc.shared_state(),
|
||||
&node.pubkey,
|
||||
&node.did,
|
||||
node.name.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pending::set_state(
|
||||
&self.config.data_dir,
|
||||
&row_id,
|
||||
PendingState::Approved,
|
||||
)
|
||||
.await?;
|
||||
applied_invites.push(node.did);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
error = %e,
|
||||
"Failed to apply PeerInvite"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
HandshakeMessage::PeerReject { reason } => {
|
||||
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||
if let Some(row) = pendings.iter().find(|r| {
|
||||
r.outbound
|
||||
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||
&& matches!(r.state, PendingState::Sent)
|
||||
}) {
|
||||
let row_id = row.id.clone();
|
||||
pending::set_state(
|
||||
&self.config.data_dir,
|
||||
&row_id,
|
||||
PendingState::Rejected,
|
||||
)
|
||||
.await?;
|
||||
rejected_outbound.push(row_id);
|
||||
tracing::info!(
|
||||
from = %hs.from_nostr_pubkey,
|
||||
reason = ?reason,
|
||||
"Outbound peer request rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let serialized: Vec<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!({
|
||||
"handshakes": serialized,
|
||||
"added_peers": added_peers,
|
||||
"polled": handshakes.len(),
|
||||
"new_requests": new_requests,
|
||||
"applied_invites": applied_invites,
|
||||
"rejected_outbound": rejected_outbound,
|
||||
"skipped": skipped,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ impl RpcHandler {
|
||||
&req_msg.to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).await?;
|
||||
|
||||
// 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__repository__ENABLE_PUSH_CREATE_USER=true".to_string(),
|
||||
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(),
|
||||
"GITEA__security__X_FRAME_OPTIONS=".to_string(),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -261,6 +261,7 @@ impl RpcHandler {
|
||||
if !is_tailscale {
|
||||
run_args.push("--cap-drop=ALL");
|
||||
run_args.push("--security-opt=no-new-privileges:true");
|
||||
run_args.push("--pids-limit=4096");
|
||||
for cap in &security_caps {
|
||||
run_args.push(cap);
|
||||
}
|
||||
@@ -600,11 +601,11 @@ impl RpcHandler {
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
// Wrap the entire pull (stderr progress + wait) in a 60s timeout.
|
||||
// If the registry is unreachable, the pull hangs on DNS/TCP and the
|
||||
// stderr reader never returns — so the timeout must cover everything.
|
||||
// Wrap the entire pull (stderr progress + wait) in a 10-minute timeout.
|
||||
// Large image layers (Minio, Postgres, ffmpeg) can take several minutes
|
||||
// to pull. 60s was too short and caused premature retries on slow registries.
|
||||
let pull_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
std::time::Duration::from_secs(600),
|
||||
async {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
@@ -1097,15 +1098,21 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
// Set ROOT_URL in Gitea config
|
||||
// Set ROOT_URL in Gitea config — port 3000 is the nginx iframe proxy,
|
||||
// which is the public-facing port users and the UI iframe access.
|
||||
let host_ip = &self.config.host_ip;
|
||||
let root_url = format!("GITEA__server__ROOT_URL=http://{}:3001/", host_ip);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
|
||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3000/|' /data/gitea/conf/app.ini || true", host_ip)])
|
||||
.output()
|
||||
.await;
|
||||
info!("Gitea: ROOT_URL set to http://{}:3001/", host_ip);
|
||||
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
|
||||
.output()
|
||||
.await;
|
||||
info!("Gitea: ROOT_URL set to http://{}:3000/, X_FRAME_OPTIONS cleared", host_ip);
|
||||
}
|
||||
|
||||
if package_id == "nextcloud" {
|
||||
|
||||
@@ -138,6 +138,8 @@ impl RpcHandler {
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let db_pass = super::config::read_or_generate_secret("immich-db-password").await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
@@ -148,10 +150,24 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"--network-alias",
|
||||
"immich_postgres",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U postgres || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v",
|
||||
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=immichpass",
|
||||
&format!("POSTGRES_PASSWORD={}", db_pass),
|
||||
"-e",
|
||||
"POSTGRES_USER=postgres",
|
||||
"-e",
|
||||
@@ -172,6 +188,15 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"--network-alias",
|
||||
"immich_redis",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=128m",
|
||||
"--pids-limit=2048",
|
||||
"--health-cmd=valkey-cli ping || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"git.tx1138.com/lfg2025/valkey:7-alpine",
|
||||
])
|
||||
.output()
|
||||
@@ -188,6 +213,12 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"--network-alias",
|
||||
"immich_server",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=2g",
|
||||
"--pids-limit=4096",
|
||||
"-p",
|
||||
"2283:2283",
|
||||
"-v",
|
||||
@@ -197,7 +228,7 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"DB_USERNAME=postgres",
|
||||
"-e",
|
||||
"DB_PASSWORD=immichpass",
|
||||
&format!("DB_PASSWORD={}", db_pass),
|
||||
"-e",
|
||||
"DB_DATABASE_NAME=immich",
|
||||
"-e",
|
||||
@@ -276,6 +307,20 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-postgres",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U penpot || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
@@ -300,6 +345,15 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-valkey",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=192m",
|
||||
"--pids-limit=2048",
|
||||
"--health-cmd=valkey-cli ping || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-e",
|
||||
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||
"git.tx1138.com/lfg2025/valkey:8.1",
|
||||
@@ -318,6 +372,12 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-backend",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
@@ -354,6 +414,12 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-exporter",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=2048",
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
@@ -376,6 +442,12 @@ impl RpcHandler {
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"--network-alias",
|
||||
"penpot-frontend",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=2048",
|
||||
"-p",
|
||||
"9001:8080",
|
||||
"-v",
|
||||
@@ -473,7 +545,18 @@ impl RpcHandler {
|
||||
"--restart", "unless-stopped",
|
||||
"--network", "archy-net",
|
||||
"--network-alias", "archy-btcpay-db",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=pg_isready -U btcpay || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v", "/var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data",
|
||||
"-e", "POSTGRES_DB=btcpay",
|
||||
"-e", "POSTGRES_USER=btcpay",
|
||||
@@ -501,7 +584,10 @@ impl RpcHandler {
|
||||
"--restart", "unless-stopped",
|
||||
"--network", "archy-net",
|
||||
"--network-alias", "archy-nbxplorer",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"-p", "32838:32838",
|
||||
"-v", "/var/lib/archipelago/nbxplorer:/data",
|
||||
"-e", "NBXPLORER_DATADIR=/data",
|
||||
@@ -531,7 +617,10 @@ impl RpcHandler {
|
||||
"--restart", "unless-stopped",
|
||||
"--network", "archy-net",
|
||||
"--network-alias", "btcpay-server",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=1g",
|
||||
"--pids-limit=4096",
|
||||
"-p", "23000:49392",
|
||||
"-v", "/var/lib/archipelago/btcpay:/datadir",
|
||||
"-e", "ASPNETCORE_URLS=http://0.0.0.0:49392",
|
||||
@@ -632,7 +721,18 @@ impl RpcHandler {
|
||||
"--restart", "unless-stopped",
|
||||
"--network", "archy-net",
|
||||
"--network-alias", "archy-mempool-db",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=FOWNER",
|
||||
"--cap-add=SETGID",
|
||||
"--cap-add=SETUID",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"--health-cmd=mariadb-admin ping -u root --password=$MYSQL_ROOT_PASSWORD || exit 1",
|
||||
"--health-interval=30s",
|
||||
"--health-retries=3",
|
||||
"-v", "/var/lib/archipelago/mysql-mempool:/var/lib/mysql",
|
||||
"-e", "MYSQL_DATABASE=mempool",
|
||||
"-e", "MYSQL_USER=mempool",
|
||||
@@ -652,7 +752,10 @@ impl RpcHandler {
|
||||
"--restart", "unless-stopped",
|
||||
"--network", "archy-net",
|
||||
"--network-alias", "mempool-api",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=512m",
|
||||
"--pids-limit=4096",
|
||||
"-p", "8999:8999",
|
||||
"-v", "/var/lib/archipelago/mempool:/data",
|
||||
"-e", "MEMPOOL_BACKEND=electrum",
|
||||
@@ -682,7 +785,10 @@ impl RpcHandler {
|
||||
"--restart", "unless-stopped",
|
||||
"--network", "archy-net",
|
||||
"--network-alias", "mempool",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges:true",
|
||||
"--memory=256m",
|
||||
"--pids-limit=2048",
|
||||
"-p", "4080:8080",
|
||||
"-e", "FRONTEND_HTTP_PORT=8080",
|
||||
"-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
|
||||
@@ -718,7 +824,7 @@ impl RpcHandler {
|
||||
.into_iter()
|
||||
.find(|r| r.enabled)
|
||||
.map(|r| r.url)
|
||||
.unwrap_or_else(|| "23.182.128.160:3000/lfg2025".to_string());
|
||||
.unwrap_or_else(|| "git.tx1138.com/lfg2025".to_string());
|
||||
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
@@ -740,16 +846,13 @@ impl RpcHandler {
|
||||
format!("{}/indeedhub:1.0.0", registry),
|
||||
];
|
||||
|
||||
// Pull all images with retry; fail the install if any image can't be pulled.
|
||||
// Previously this just logged a warning and continued, leaving the stack
|
||||
// broken and the user seeing "failed" with no recovery path.
|
||||
for img in &images {
|
||||
info!("Pulling {}", img);
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["pull", img, "--tls-verify=false"])
|
||||
.env("TMPDIR", &user_tmp)
|
||||
.status()
|
||||
.await;
|
||||
if !status.map(|s| s.success()).unwrap_or(false) {
|
||||
tracing::warn!("Failed to pull {}", img);
|
||||
}
|
||||
pull_image_with_retry(img).await
|
||||
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
|
||||
}
|
||||
|
||||
// Create indeedhub-net
|
||||
|
||||
@@ -90,6 +90,15 @@ impl RpcHandler {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
// Skip sending to ourselves (prevents duplicate messages in group chat)
|
||||
if let Some(ref our_onion) = data.server_info.tor_address {
|
||||
let our = our_onion.trim_end_matches(".onion");
|
||||
let their = onion.trim_end_matches(".onion");
|
||||
if our == their {
|
||||
return Ok(serde_json::json!({ "ok": true, "sent_to": onion, "skipped": "self" }));
|
||||
}
|
||||
}
|
||||
|
||||
// Load signing key for E2E encryption
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
@@ -587,42 +587,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// NostrVPN mesh participants (from nvpn config)
|
||||
let our_npub = vpn::read_nvpn_config_value("nostr", "public_key").await;
|
||||
for path in vpn::NVPN_CONFIG_PATHS {
|
||||
if let Ok(content) = tokio::fs::read_to_string(path).await {
|
||||
if let Ok(table) = content.parse::<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
|
||||
}
|
||||
}
|
||||
|
||||
// NostrVPN peer loading removed — standalone WireGuard only
|
||||
Ok(serde_json::json!({ "peers": peers }))
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,12 @@ impl Default for Config {
|
||||
port_offset: 10000,
|
||||
bitcoin_simulation: BitcoinSimulation::Mock,
|
||||
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
||||
nostr_discovery_enabled: true,
|
||||
// Discoverability is opt-in. Until the user explicitly enables it
|
||||
// (Settings UI / `nostr_discovery_enabled = true` in config), no
|
||||
// presence event is ever published and `handshake.poll` never
|
||||
// contacts a relay. This is the sole knob that controls whether
|
||||
// we leak our DID + npub to the public Nostr relays.
|
||||
nostr_discovery_enabled: false,
|
||||
nostr_relays: vec![
|
||||
"wss://relay.damus.io".into(),
|
||||
"wss://relay.nostr.info".into(),
|
||||
@@ -223,7 +228,7 @@ mod tests {
|
||||
assert_eq!(config.host_ip, "127.0.0.1");
|
||||
assert!(!config.dev_mode);
|
||||
assert_eq!(config.port_offset, 10000);
|
||||
assert!(config.nostr_discovery_enabled);
|
||||
assert!(!config.nostr_discovery_enabled);
|
||||
assert_eq!(config.nostr_relays.len(), 2);
|
||||
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
|
||||
}
|
||||
@@ -333,9 +338,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_nostr_discovery_enabled_by_default() {
|
||||
fn test_config_nostr_discovery_disabled_by_default() {
|
||||
// Discoverability is opt-in: nothing is published to public relays
|
||||
// until the user explicitly turns it on. Flipping this back to
|
||||
// `true` would silently start leaking the local DID + npub on every
|
||||
// boot — guard rail.
|
||||
let config = Config::default();
|
||||
assert!(config.nostr_discovery_enabled);
|
||||
assert!(!config.nostr_discovery_enabled);
|
||||
assert!(config.nostr_tor_proxy.is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -44,16 +44,16 @@ impl Default for RegistryConfig {
|
||||
Self {
|
||||
registries: vec![
|
||||
Registry {
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
name: "Archipelago Primary".to_string(),
|
||||
tls_verify: false,
|
||||
tls_verify: true,
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
},
|
||||
Registry {
|
||||
url: "git.tx1138.com/lfg2025".to_string(),
|
||||
name: "Archipelago Legacy".to_string(),
|
||||
tls_verify: true,
|
||||
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||
name: "Archipelago Fallback".to_string(),
|
||||
tls_verify: false,
|
||||
enabled: true,
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::{add_node, load_invites, load_nodes, save_invites};
|
||||
use super::storage::{add_node, load_invites, load_nodes, save_invites, save_nodes};
|
||||
use super::types::{FederatedNode, FederationInvite, TrustLevel};
|
||||
|
||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||
@@ -94,10 +94,28 @@ pub async fn accept_invite(
|
||||
) -> Result<FederatedNode> {
|
||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||
|
||||
// Check not already federated
|
||||
let nodes = load_nodes(data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
anyhow::bail!("Already federated with node {}", did);
|
||||
// Make accept idempotent: drop any existing entry that conflicts with
|
||||
// this invite — same DID (same node, refreshing the link), same onion
|
||||
// (node rotated identity but kept its hidden service), or same pubkey
|
||||
// (DID and onion reformatted but the underlying key is the same).
|
||||
// Whatever is there gets replaced so re-accepting an invite is always
|
||||
// safe and the user never has to manually remove an entry first.
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let onion_norm = onion.trim_end_matches(".onion");
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| {
|
||||
n.did != did
|
||||
&& n.onion.trim_end_matches(".onion") != onion_norm
|
||||
&& n.pubkey != pubkey
|
||||
});
|
||||
if nodes.len() != before {
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
tracing::info!(
|
||||
removed = before - nodes.len(),
|
||||
new_did = %did,
|
||||
onion = %onion,
|
||||
"Replaced stale federation entry on re-accept"
|
||||
);
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
@@ -226,7 +244,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_rejects_duplicate() {
|
||||
async fn test_accept_invite_is_idempotent() {
|
||||
// Re-accepting the same invite is a no-op refresh — it must not
|
||||
// duplicate the entry and must not error. This is the contract the
|
||||
// UI relies on: clicking "Join" twice or refreshing after an
|
||||
// identity rotation always converges to one entry.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
@@ -244,8 +266,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accepting the same invite again should fail
|
||||
let result = accept_invite(
|
||||
accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
@@ -253,7 +274,10 @@ mod tests {
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1, "re-accept should not duplicate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! sync container status, health metrics, and availability.
|
||||
|
||||
mod invites;
|
||||
pub mod pending;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod types;
|
||||
|
||||
@@ -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,
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
nostr_npub: None,
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn build_local_state(
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
server_name: Option<String>,
|
||||
nostr_npub: Option<String>,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
@@ -86,6 +87,7 @@ pub fn build_local_state(
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
nostr_npub,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +182,7 @@ mod tests {
|
||||
3600,
|
||||
true,
|
||||
Some("Test Node".to_string()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
|
||||
@@ -59,6 +59,11 @@ pub struct NodeStateSnapshot {
|
||||
pub uptime_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
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.
|
||||
|
||||
@@ -201,15 +201,33 @@ impl MeshState {
|
||||
|
||||
pub async fn store_message(&self, msg: MeshMessage) {
|
||||
let mut messages = self.messages.write().await;
|
||||
// Deduplicate: skip if we already have a message with the same text,
|
||||
// peer, and timestamp within 30 seconds (prevents echo-back doubles)
|
||||
let dominated = messages.iter().rev().take(20).any(|m| {
|
||||
m.peer_contact_id == msg.peer_contact_id
|
||||
&& m.plaintext == msg.plaintext
|
||||
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
|
||||
});
|
||||
if dominated {
|
||||
return;
|
||||
// Deduplicate RECEIVED messages only — a Sent record is the user's
|
||||
// own action and must ALWAYS be shown, even when the display text
|
||||
// collides with an earlier one (e.g. two 👍 reactions to different
|
||||
// targets, or "ok" reply twice in a row).
|
||||
//
|
||||
// For received messages, prefer MessageKey (sender_pubkey, sender_seq)
|
||||
// as the dedup identity — it's exact and cross-transport-safe. Fall
|
||||
// back to (peer, plaintext, 30s window) only for legacy plain-text
|
||||
// frames that arrive without a sender_seq.
|
||||
if matches!(msg.direction, MessageDirection::Received) {
|
||||
let dominated = if msg.sender_pubkey.is_some() && msg.sender_seq.is_some() {
|
||||
messages.iter().rev().take(40).any(|m| {
|
||||
matches!(m.direction, MessageDirection::Received)
|
||||
&& m.sender_pubkey == msg.sender_pubkey
|
||||
&& m.sender_seq == msg.sender_seq
|
||||
})
|
||||
} else {
|
||||
messages.iter().rev().take(20).any(|m| {
|
||||
matches!(m.direction, MessageDirection::Received)
|
||||
&& m.peer_contact_id == msg.peer_contact_id
|
||||
&& m.plaintext == msg.plaintext
|
||||
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
|
||||
})
|
||||
};
|
||||
if dominated {
|
||||
return;
|
||||
}
|
||||
}
|
||||
messages.push_back(msg);
|
||||
if messages.len() > MAX_MESSAGES {
|
||||
|
||||
@@ -628,6 +628,12 @@ impl MeshService {
|
||||
|
||||
/// Send raw wire payload bytes to a peer (no Sent-record bookkeeping).
|
||||
/// Callers are responsible for storing the MeshMessage record afterwards.
|
||||
///
|
||||
/// Oversized payloads (>LoRa per-frame budget) are handled by the lower
|
||||
/// `send_dm_via_channel` layer, which base64-encodes + MC-frame-chunks
|
||||
/// the bytes into 80-char pieces and reassembles on the receiver. We
|
||||
/// must NOT chunk here as well — doing so double-chunks and produces
|
||||
/// bytes the receiver can't decode.
|
||||
async fn send_raw_payload(&self, contact_id: u32, payload: Vec<u8>) -> Result<()> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
@@ -635,14 +641,6 @@ impl MeshService {
|
||||
}
|
||||
drop(status);
|
||||
|
||||
if payload.len() > protocol::MAX_MESSAGE_LEN {
|
||||
anyhow::bail!(
|
||||
"Message too large for LoRa: {} bytes (max {})",
|
||||
payload.len(),
|
||||
protocol::MAX_MESSAGE_LEN
|
||||
);
|
||||
}
|
||||
|
||||
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||
|
||||
self.state.send_cmd(listener::MeshCommand::SendText {
|
||||
@@ -714,17 +712,11 @@ impl MeshService {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if exceeds_lora {
|
||||
// No federation path — fall back to send-side chunking. Receive
|
||||
// side already handles MC-framed base64 reassembly for up to 20
|
||||
// chunks (~3KB) per message, which is plenty for ContentRef or
|
||||
// long replies when the peer is LoRa-only.
|
||||
self.send_chunked_payload(contact_id, wire).await?;
|
||||
return Ok(self
|
||||
.record_sent_typed(contact_id, type_label, display_text, typed_payload, sender_seq)
|
||||
.await);
|
||||
}
|
||||
// Fall through: federation-synthetic case handled above, shouldn't reach here.
|
||||
// No federation path — fall through to send_raw_payload, which
|
||||
// hands the wire to the lower DM-via-channel layer. That layer
|
||||
// (`send_dm_via_channel` in listener/session.rs) handles both
|
||||
// single-frame and chunked transmission internally; we must NOT
|
||||
// pre-chunk here as well or the receiver sees garbage.
|
||||
}
|
||||
self.send_raw_payload(contact_id, wire).await?;
|
||||
Ok(self
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
//! Encrypted peer handshake via Nostr NIP-44.
|
||||
//! Encrypted peer-discovery handshake via Nostr NIP-44.
|
||||
//!
|
||||
//! Instead of publishing onion addresses publicly on relays, nodes exchange
|
||||
//! them privately via NIP-44 encrypted DMs:
|
||||
//! Goals:
|
||||
//! - A node can opt in to being *discoverable* on Nostr by publishing a
|
||||
//! minimal presence event (DID + nostr pubkey, NIP-33 kind 30078). The
|
||||
//! presence event NEVER contains the onion address, the federation list,
|
||||
//! the app inventory, or anything beyond a DID + nostr pubkey + version.
|
||||
//! - Another node that sees the presence event can send an encrypted
|
||||
//! `PeerRequest` (NIP-44 DM, kind 4) asking to peer. The request also
|
||||
//! does NOT contain the requester's onion — it carries only the DID
|
||||
//! the requester claims, an optional friendly name, and an optional
|
||||
//! one-line message.
|
||||
//! - The recipient does NOT auto-accept and does NOT auto-respond.
|
||||
//! Instead, the request is queued in `federation::pending` for the
|
||||
//! user to manually approve or reject in the Federation UI.
|
||||
//! - On approval, the recipient generates a one-shot federation invite
|
||||
//! code (which contains *their* onion + pubkey) and ships it back via
|
||||
//! NIP-44 encrypted to the requester's nostr pubkey. The requester's
|
||||
//! poll loop receives the invite, applies it via the existing
|
||||
//! `federation.join` flow, and the two boxes complete the trust
|
||||
//! exchange over Tor — never over a public relay.
|
||||
//!
|
||||
//! 1. Node publishes presence-only event (DID + Nostr pubkey, NO onion address)
|
||||
//! 2. To connect, Node A sends NIP-44 encrypted DM to Node B's Nostr pubkey
|
||||
//! containing A's onion address + Ed25519 node pubkey
|
||||
//! 3. Node B auto-responds with its own onion address + pubkey
|
||||
//! 4. Both nodes add each other as known peers
|
||||
//!
|
||||
//! Uses NIP-44 (ChaCha20-Poly1305) for encryption, kind 4 for DMs.
|
||||
//! Result: the only thing ever visible on a public Nostr relay is the
|
||||
//! presence event (a DID + a npub + a version). Everything actionable
|
||||
//! lives inside NIP-44 ciphertext addressed to a specific nostr pubkey.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -22,26 +35,39 @@ use tracing::warn;
|
||||
|
||||
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
||||
|
||||
/// Message types for the encrypted handshake protocol
|
||||
/// Message types exchanged inside NIP-44 encrypted DMs (kind 4).
|
||||
///
|
||||
/// Note: NONE of these variants carry an onion address. The onion is only
|
||||
/// transmitted as part of a `PeerInvite { invite_code }`, where the invite
|
||||
/// code is generated by the local approval flow and points at the local
|
||||
/// federation hidden service. The legacy `connect-request` / `connect-response`
|
||||
/// variants from earlier development are preserved on the deserialize side
|
||||
/// (`#[serde(other)]`-style fallback via untagged Unknown) so that an old
|
||||
/// peer that hasn't been upgraded yet can't crash the parser, but we no
|
||||
/// longer construct or act on them.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum HandshakeMessage {
|
||||
#[serde(rename = "connect-request")]
|
||||
ConnectRequest {
|
||||
onion: String,
|
||||
node_pubkey: String,
|
||||
did: String,
|
||||
version: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
#[serde(rename = "connect-response")]
|
||||
ConnectResponse {
|
||||
onion: String,
|
||||
node_pubkey: String,
|
||||
did: String,
|
||||
/// Inbound peer-discovery request. The sender proves they hold the
|
||||
/// nostr secret key by signing the kind-4 envelope (Nostr does this
|
||||
/// at the protocol layer). They claim a `from_did` here, but we do
|
||||
/// not trust it until the federation invite round-trip completes.
|
||||
#[serde(rename = "peer-request")]
|
||||
PeerRequest {
|
||||
from_did: String,
|
||||
version: String,
|
||||
name: Option<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
|
||||
@@ -219,16 +245,16 @@ pub async fn discover_nodes(
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Send an encrypted connection request to a peer's Nostr pubkey.
|
||||
/// Uses NIP-44 encrypted DM (kind 4) containing our onion address.
|
||||
pub async fn send_connect_request(
|
||||
/// Encrypt and publish a `HandshakeMessage` to a recipient's nostr pubkey.
|
||||
/// Used by both the request-side (PeerRequest) and the approver-side
|
||||
/// (PeerInvite / PeerReject) flows. The message is wrapped in a NIP-44 v2
|
||||
/// ciphertext addressed to `recipient_nostr_pubkey` and posted as a kind-4
|
||||
/// encrypted DM, so only the holder of that nostr secret key can decrypt
|
||||
/// the contents. Relays only ever see the ciphertext.
|
||||
async fn send_handshake_message(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
our_onion: &str,
|
||||
our_node_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
msg: &HandshakeMessage,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
@@ -239,20 +265,10 @@ pub async fn send_connect_request(
|
||||
let keys = load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
||||
let recipient_pk =
|
||||
PublicKey::from_hex(recipient_nostr_pubkey).context("Invalid recipient Nostr pubkey")?;
|
||||
|
||||
let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey)
|
||||
.context("Invalid recipient Nostr pubkey")?;
|
||||
|
||||
let msg = HandshakeMessage::ConnectRequest {
|
||||
onion: our_onion.to_string(),
|
||||
node_pubkey: our_node_pubkey.to_string(),
|
||||
did: our_did.to_string(),
|
||||
version: our_version.to_string(),
|
||||
name: our_name.map(String::from),
|
||||
};
|
||||
let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
||||
|
||||
// NIP-44 encrypt
|
||||
let plaintext = serde_json::to_string(msg).context("Failed to serialize handshake")?;
|
||||
let encrypted = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&recipient_pk,
|
||||
@@ -265,79 +281,86 @@ pub async fn send_connect_request(
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
}
|
||||
|
||||
// Kind 4 encrypted DM with p-tag for recipient
|
||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||
.tag(Tag::public_key(recipient_pk));
|
||||
let builder =
|
||||
EventBuilder::new(Kind::EncryptedDirectMessage, encrypted).tag(Tag::public_key(recipient_pk));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
|
||||
/// include an onion address — the recipient learns nothing that would let
|
||||
/// them dial us directly. They learn only our claimed DID, version,
|
||||
/// optional friendly name, and the optional message.
|
||||
pub async fn send_peer_request(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
message: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let msg = HandshakeMessage::PeerRequest {
|
||||
from_did: our_did.to_string(),
|
||||
version: our_version.to_string(),
|
||||
name: our_name.map(String::from),
|
||||
message: message.map(String::from),
|
||||
};
|
||||
send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?;
|
||||
tracing::info!(
|
||||
"🤝 Sent encrypted connect request to {}...{}",
|
||||
"🤝 Sent peer-request to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an encrypted connection response to a peer.
|
||||
pub async fn send_connect_response(
|
||||
/// Send a `PeerInvite` reply containing a one-shot federation invite code.
|
||||
/// The code embeds our onion + pubkey, but the entire envelope is NIP-44
|
||||
/// encrypted to the requester's nostr pubkey, so the onion is only ever
|
||||
/// readable by them.
|
||||
pub async fn send_peer_invite(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
our_onion: &str,
|
||||
our_node_pubkey: &str,
|
||||
our_did: &str,
|
||||
our_version: &str,
|
||||
our_name: Option<&str>,
|
||||
invite_code: &str,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if relays.is_empty() {
|
||||
anyhow::bail!("No relays configured");
|
||||
}
|
||||
|
||||
let keys = load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("No Nostr keys"))?;
|
||||
|
||||
let recipient_pk = PublicKey::from_hex(recipient_nostr_pubkey)
|
||||
.context("Invalid recipient Nostr pubkey")?;
|
||||
|
||||
let msg = HandshakeMessage::ConnectResponse {
|
||||
onion: our_onion.to_string(),
|
||||
node_pubkey: our_node_pubkey.to_string(),
|
||||
did: our_did.to_string(),
|
||||
version: our_version.to_string(),
|
||||
name: our_name.map(String::from),
|
||||
let msg = HandshakeMessage::PeerInvite {
|
||||
invite_code: invite_code.to_string(),
|
||||
};
|
||||
let plaintext = serde_json::to_string(&msg).context("Failed to serialize handshake")?;
|
||||
|
||||
let encrypted = nip44::encrypt(
|
||||
keys.secret_key(),
|
||||
&recipient_pk,
|
||||
&plaintext,
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("NIP-44 encrypt failed: {}", e))?;
|
||||
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
||||
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
}
|
||||
|
||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||
.tag(Tag::public_key(recipient_pk));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
|
||||
send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?;
|
||||
tracing::info!(
|
||||
"🤝 Sent encrypted connect response to {}...{}",
|
||||
"🤝 Sent peer-invite to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PeerReject` reply with an optional reason.
|
||||
pub async fn send_peer_reject(
|
||||
identity_dir: &Path,
|
||||
recipient_nostr_pubkey: &str,
|
||||
reason: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let msg = HandshakeMessage::PeerReject {
|
||||
reason: reason.map(String::from),
|
||||
};
|
||||
send_handshake_message(identity_dir, recipient_nostr_pubkey, &msg, relays, tor_proxy).await?;
|
||||
tracing::info!(
|
||||
"🚫 Sent peer-reject to {}...{}",
|
||||
&recipient_nostr_pubkey[..8.min(recipient_nostr_pubkey.len())],
|
||||
&recipient_nostr_pubkey[recipient_nostr_pubkey.len().saturating_sub(4)..]
|
||||
);
|
||||
|
||||
@@ -222,6 +222,7 @@ mod tests {
|
||||
disk_total_bytes: Some(1_800_000_000_000),
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
nostr_npub: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +254,7 @@ mod tests {
|
||||
disk_total_bytes: Some(1_800_000_000_000),
|
||||
uptime_secs: Some(86700), // Changed
|
||||
tor_active: Some(true),
|
||||
nostr_npub: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -281,17 +281,14 @@ pub fn generate_wireguard_conf(config: &WireGuardConfig) -> String {
|
||||
|
||||
/// Get the current VPN status by checking network interfaces.
|
||||
pub async fn get_status() -> VpnStatus {
|
||||
// Check for NostrVPN (native system service)
|
||||
if let Ok(nvpn) = get_nostr_vpn_status().await {
|
||||
return nvpn;
|
||||
}
|
||||
// NostrVPN disabled — standalone WireGuard only
|
||||
|
||||
// Check for Tailscale interface
|
||||
if let Ok(tailscale) = get_tailscale_status().await {
|
||||
return tailscale;
|
||||
}
|
||||
|
||||
// Check for WireGuard interface
|
||||
// Check for WireGuard interface (wg0)
|
||||
if let Ok(wg) = get_wireguard_status().await {
|
||||
return wg;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user