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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user