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:
Dorian
2026-04-18 11:07:08 -04:00
co-authored by Claude Opus 4.6
parent e210376e05
commit 9dd802998c
38 changed files with 3773 additions and 697 deletions
+121 -98
View File
@@ -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)..]
);