From f756365935584eace6b718a63cd14e2c29303dc7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 19 Apr 2026 04:34:37 -0400 Subject: [PATCH] feat(peers): bidirectional /network peer requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: Alice sent /network.send-request to Bob, Bob accepted via /network.accept-request and gained Alice in his peers list, but Alice was never notified — her pending row sat there and she had to manually add Bob separately. User complaint: "it's strange you have to do it both ways." Fix — the accept now fires a best-effort connection_accepted message back to the requester: - handle_network_accept_request: after writing the local peer record, assembles a `{type: "connection_accepted", request_id, from_did, from_onion, from_pubkey}` JSON, signs + encrypts + POSTs it to the requester via node_message::send_to_peer. Uses PeerRequest internally so it prefers FIPS and falls back to Tor. - handle_node_message: parses incoming plaintext as JSON; on a match for type=connection_accepted, auto-adds the sender to peers.json (the existing self-pubkey guard in add_peer still applies) and short-circuits the normal store_received path so the acceptance doesn't also land as a chat message in Alice's inbox. Offline handling: if Alice is offline when Bob accepts, the notify warns and the local accept still succeeds. Alice will receive any subsequent message from Bob normally; future iteration could retry on reconnect. Federation-invite flow (federation.accept-invite → notify_join) was already bidirectional; this closes the gap for the peer flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/api/handler/node_message.rs | 41 ++++++++++++++++ core/archipelago/src/api/rpc/network.rs | 48 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/api/handler/node_message.rs b/core/archipelago/src/api/handler/node_message.rs index 5dba2ee0..a1879ef1 100644 --- a/core/archipelago/src/api/handler/node_message.rs +++ b/core/archipelago/src/api/handler/node_message.rs @@ -89,6 +89,47 @@ impl ApiHandler { msg.clone() }; + // Detect a `connection_accepted` reply: the remote peer just + // approved an outbound request we sent, so mirror their add on + // our side (bidirectional peering without a manual second + // click). JSON-shape only — any non-matching payload stays in + // the normal received-messages store below. + if let Ok(val) = serde_json::from_str::(&plaintext) { + if val.get("type").and_then(|v| v.as_str()) == Some("connection_accepted") { + if let (Some(their_onion), Some(their_pubkey)) = ( + val.get("from_onion").and_then(|v| v.as_str()), + val.get("from_pubkey").and_then(|v| v.as_str()), + ) { + let data_dir = std::path::Path::new("/var/lib/archipelago"); + let peer = crate::peers::KnownPeer { + onion: their_onion.to_string(), + pubkey: their_pubkey.to_string(), + name: val + .get("from_name") + .and_then(|v| v.as_str()) + .map(String::from), + added_at: Some(chrono::Utc::now().to_rfc3339()), + }; + match crate::peers::add_peer(data_dir, peer).await { + Ok(_) => tracing::info!( + from = %sanitize_log_string(from), + "Auto-added peer after connection_accepted" + ), + Err(e) => tracing::warn!( + from = %sanitize_log_string(from), + error = %e, + "Failed to auto-add peer on connection_accepted" + ), + } + } + return Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#), + )); + } + } + let safe_from = sanitize_log_string(from); let safe_msg = sanitize_log_string(&plaintext); tracing::info!("Received message from {}: {}", safe_from, safe_msg); diff --git a/core/archipelago/src/api/rpc/network.rs b/core/archipelago/src/api/rpc/network.rs index c07cbc85..9c2576f0 100644 --- a/core/archipelago/src/api/rpc/network.rs +++ b/core/archipelago/src/api/rpc/network.rs @@ -160,7 +160,9 @@ impl RpcHandler { Ok(serde_json::json!({ "requests": requests })) } - /// Accept a connection request — add peer to trusted list. + /// Accept a connection request — add peer to trusted list AND send + /// a `connection_accepted` notification back to the requester so + /// their side auto-adds us without a second manual round-trip. pub(super) async fn handle_network_accept_request( &self, params: Option, @@ -175,7 +177,8 @@ impl RpcHandler { let req = requests .iter() .find(|r| r.id == request_id) - .ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?; + .ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))? + .clone(); // Add to known peers let peer = peers::KnownPeer { @@ -189,6 +192,47 @@ impl RpcHandler { // Remove the request self.delete_request(request_id).await?; + // Notify the requester we've accepted so their UI auto-adds us and + // clears its outbound pending row. Best-effort — if the peer is + // offline we don't fail the accept; the next connection_request + // retry on their side will resolve eventually. + let (data, _) = self.state_manager.get_snapshot().await; + let my_pubkey = data.server_info.pubkey.clone(); + let my_did = crate::identity::did_key_from_pubkey_hex(&my_pubkey).ok(); + let my_onion = crate::container::docker_packages::read_tor_address("archipelago") + .await + .unwrap_or_default(); + let accept_msg = serde_json::json!({ + "type": "connection_accepted", + "request_id": request_id, + "from_did": my_did, + "from_onion": my_onion, + "from_pubkey": my_pubkey, + }); + let to_fips_npub = + crate::federation::fips_npub_for_onion(&self.config.data_dir, &req.from_onion).await; + let identity_dir = self.config.data_dir.join("identity"); + let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir) + .await + .ok(); + if let Err(e) = crate::node_message::send_to_peer( + &req.from_onion, + to_fips_npub.as_deref(), + &my_pubkey, + &accept_msg.to_string(), + signing_key.as_ref().map(|i| i.signing_key()), + Some(&req.from_pubkey), + data.server_info.name.as_deref(), + ) + .await + { + tracing::warn!( + to = %req.from_did, + error = %e, + "connection_accepted notify failed (requester will still be able to see us on their next retry)" + ); + } + tracing::info!("Accepted connection from {}", req.from_did); Ok(serde_json::json!({ "ok": true })) }