release(v1.7.16-alpha): bidirectional + transitive federation, no self-peering
Federation join flow now notifies the inviter with the joiner's name and immediately bumps state so the Federation UI reloads without a manual Sync click. Accepting an invite that points back at the local node is rejected up front (DID/pubkey/onion match). After a peer joins, we spawn a transitive sync that pulls the new peer's federated peer hints so all nodes in the federation learn about each other as Observer entries. Federation.vue polls every 5s while mounted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
749234b8b0
commit
f2360d570f
@@ -79,6 +79,7 @@ impl RpcHandler {
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
let local_name = data.server_info.name.clone();
|
||||
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
@@ -90,6 +91,7 @@ impl RpcHandler {
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|data| node_identity.sign(data),
|
||||
)
|
||||
.await?;
|
||||
@@ -447,6 +449,38 @@ impl RpcHandler {
|
||||
.get("fips_npub")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// Optional, unsigned: peer's display name. Display-only — identity
|
||||
// claims are anchored on the signed did/pubkey below.
|
||||
let incoming_name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Reject self-peering. If somehow our own did / onion / pubkey
|
||||
// comes back at us (misconfigured invite, gossip loop), adding
|
||||
// the entry causes sync loops where the node syncs with itself
|
||||
// forever. Drop it quietly — no useful recovery path.
|
||||
let (own_data, _) = self.state_manager.get_snapshot().await;
|
||||
let own_did_result =
|
||||
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||
let own_onion_trim = own_data
|
||||
.server_info
|
||||
.tor_address
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches(".onion")
|
||||
.to_string();
|
||||
let incoming_onion_trim = onion.trim_end_matches(".onion");
|
||||
if own_did_result.as_deref() == Some(did)
|
||||
|| pubkey == own_data.server_info.pubkey
|
||||
|| (!own_onion_trim.is_empty() && own_onion_trim == incoming_onion_trim)
|
||||
{
|
||||
tracing::warn!(
|
||||
peer_did = %did,
|
||||
"Rejected peer-joined: inbound identity matches this node"
|
||||
);
|
||||
anyhow::bail!("Refusing to peer with self");
|
||||
}
|
||||
|
||||
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||
let signature = params.get("signature").and_then(|v| v.as_str());
|
||||
@@ -471,11 +505,12 @@ impl RpcHandler {
|
||||
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
||||
// If already known but missing onion/pubkey/fips_npub, update them
|
||||
// If already known but missing onion/pubkey/fips_npub/name, update them
|
||||
let needs_onion = existing.onion.is_empty();
|
||||
let needs_pubkey = existing.pubkey.is_empty();
|
||||
let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some();
|
||||
if needs_onion || needs_pubkey || needs_fips {
|
||||
let needs_name = existing.name.is_none() && incoming_name.is_some();
|
||||
if needs_onion || needs_pubkey || needs_fips || needs_name {
|
||||
let mut updated = existing.clone();
|
||||
if needs_onion && !onion.is_empty() {
|
||||
updated.onion = onion.to_string();
|
||||
@@ -486,6 +521,9 @@ impl RpcHandler {
|
||||
if needs_fips {
|
||||
updated.fips_npub = fips_npub.clone();
|
||||
}
|
||||
if needs_name {
|
||||
updated.name = incoming_name.clone();
|
||||
}
|
||||
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||
federation::update_node(&self.config.data_dir, &updated).await?;
|
||||
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
|
||||
@@ -497,7 +535,7 @@ impl RpcHandler {
|
||||
did: did.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
name: incoming_name.clone(),
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
@@ -512,9 +550,38 @@ impl RpcHandler {
|
||||
|
||||
// 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)
|
||||
self.register_federation_peer_in_mesh(pubkey, did, incoming_name.as_deref())
|
||||
.await;
|
||||
|
||||
// Bump the data-model revision so any Federation view with an
|
||||
// open WebSocket reloads its node list without waiting for the
|
||||
// user to click Sync.
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
|
||||
// Transitive discovery: spawn a task that pulls the new peer's
|
||||
// state (its own federated peers end up as Observer entries on
|
||||
// our side) so after a join every existing peer in our list is
|
||||
// aware of the newcomer via the next pair of syncs, without the
|
||||
// user clicking anything. Best-effort; errors are logged only.
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let new_peer_did = did.to_string();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
if let Err(e) = crate::federation::sync_with_peer_by_did(
|
||||
&data_dir,
|
||||
&new_peer_did,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
peer_did = %new_peer_did,
|
||||
error = %e,
|
||||
"Transitive sync on peer-joined failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(serde_json::json!({ "accepted": true }))
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ impl RpcHandler {
|
||||
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let local_name = data.server_info.name.clone();
|
||||
match crate::federation::accept_invite(
|
||||
&self.config.data_dir,
|
||||
invite_code,
|
||||
@@ -289,6 +290,7 @@ impl RpcHandler {
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|bytes| node_identity.sign(bytes),
|
||||
)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user