release(v1.7.16-alpha): bidirectional + transitive federation, no self-peering
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 23m32s
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 23m32s
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:
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.15-alpha"
|
||||
version = "1.7.16-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.15-alpha"
|
||||
version = "1.7.16-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,7 @@ pub async fn accept_invite(
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
local_fips_npub: Option<&str>,
|
||||
local_name: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<FederatedNode> {
|
||||
let ParsedInvite {
|
||||
@@ -131,6 +132,20 @@ pub async fn accept_invite(
|
||||
fips_npub,
|
||||
} = parse_invite(code)?;
|
||||
|
||||
// Refuse self-peering. If the invite's did / onion / pubkey matches
|
||||
// our own, adding it pollutes the federation list with a node that
|
||||
// sees itself as its own peer and causes sync loops. The user
|
||||
// almost certainly pasted the wrong invite.
|
||||
if did == local_did || pubkey == local_pubkey || {
|
||||
let a = onion.trim_end_matches(".onion");
|
||||
let b = local_onion.trim_end_matches(".onion");
|
||||
!a.is_empty() && a == b
|
||||
} {
|
||||
anyhow::bail!(
|
||||
"Refusing to federate with self — invite points at this node's own did / onion / pubkey"
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -190,6 +205,7 @@ pub async fn accept_invite(
|
||||
local_onion,
|
||||
local_pubkey,
|
||||
local_fips_npub,
|
||||
local_name,
|
||||
sign_fn,
|
||||
)
|
||||
.await;
|
||||
@@ -201,20 +217,22 @@ pub async fn accept_invite(
|
||||
/// Prefers FIPS (if the remote advertised an npub in their invite) and
|
||||
/// falls back to Tor. Signs the message with our ed25519 key so the
|
||||
/// remote peer can verify authenticity regardless of transport.
|
||||
async fn notify_join(
|
||||
pub(crate) async fn notify_join(
|
||||
remote_onion: &str,
|
||||
remote_fips_npub: Option<&str>,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
local_fips_npub: Option<&str>,
|
||||
local_name: Option<&str>,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<()> {
|
||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||
// Signature domain intentionally unchanged — fips_npub is carried
|
||||
// as an unsigned informational field. The FIPS daemon's own Noise
|
||||
// handshake authenticates the actual transport session, so a
|
||||
// stripped/substituted npub here merely downgrades the path to Tor.
|
||||
// Signature domain intentionally unchanged — fips_npub + name are
|
||||
// carried as unsigned informational fields. Name is display-only
|
||||
// (any identity claim is anchored on the signed did/pubkey); the
|
||||
// FIPS daemon's own Noise handshake authenticates the transport
|
||||
// session regardless of the advertised npub.
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||
let signature = sign_fn(sign_data.as_bytes());
|
||||
|
||||
@@ -227,6 +245,9 @@ async fn notify_join(
|
||||
if let Some(npub) = local_fips_npub {
|
||||
params["fips_npub"] = serde_json::Value::String(npub.to_string());
|
||||
}
|
||||
if let Some(name) = local_name {
|
||||
params["name"] = serde_json::Value::String(name.to_string());
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
|
||||
@@ -17,5 +17,5 @@ pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
|
||||
set_trust_level, update_node,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
@@ -82,6 +82,30 @@ pub async fn sync_with_peer(
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Convenience wrapper: look up a federated peer by DID, derive our
|
||||
/// own local_did / signing context from the node identity on disk, and
|
||||
/// call sync_with_peer. Used by transitive-discovery code paths where
|
||||
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
|
||||
/// follow-up task).
|
||||
pub async fn sync_with_peer_by_did(
|
||||
data_dir: &Path,
|
||||
peer_did: &str,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let nodes = super::storage::load_nodes(data_dir).await?;
|
||||
let peer = nodes
|
||||
.into_iter()
|
||||
.find(|n| n.did == peer_did)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
|
||||
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
let local_pubkey_hex = node_identity.pubkey_hex();
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
|
||||
|
||||
sync_with_peer(data_dir, &peer, &local_did, |data| node_identity.sign(data)).await
|
||||
}
|
||||
|
||||
/// Merge peers advertised by a Trusted federated node into our own
|
||||
/// federation list. New peers are added at `Observer` trust (not
|
||||
/// Trusted — that requires a direct invite). Existing peers get their
|
||||
|
||||
Reference in New Issue
Block a user