diff --git a/CHANGELOG.md b/CHANGELOG.md index 03374b86..71544142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.7.95-alpha (2026-06-15) + +- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up. +- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself. +- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen. + ## v1.7.94-alpha (2026-06-15) - Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.) diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index 01e5604a..90d38102 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -533,6 +533,19 @@ impl RpcHandler { return Ok(serde_json::json!({ "accepted": true, "already_known": true })); } + // Respect operator removal: a peer the operator deleted must not + // silently re-join via a stale invite. The tombstone is only cleared + // by an explicit local action (manually adding the node or accepting + // an incoming invite) — not by a remote-triggered join. + if federation::load_removed_dids(&self.config.data_dir) + .await + .unwrap_or_default() + .contains(did) + { + info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID"); + return Ok(serde_json::json!({ "accepted": false, "removed": true })); + } + let node = FederatedNode { did: did.to_string(), pubkey: pubkey.to_string(), diff --git a/core/archipelago/src/api/rpc/fips.rs b/core/archipelago/src/api/rpc/fips.rs index 74f63fde..b70d99c8 100644 --- a/core/archipelago/src/api/rpc/fips.rs +++ b/core/archipelago/src/api/rpc/fips.rs @@ -115,10 +115,12 @@ impl RpcHandler { } else if !after.key_present { "no_seed_key" } else if after.authenticated_peer_count == 0 { - // Daemon is up with a key but hasn't authenticated any - // peers — almost always outbound UDP/8668 dropped by the - // local firewall/router, or the anchor itself being down. - "no_outbound_udp_or_anchor_down" + // Daemon is up with a key but hasn't authenticated any peers — + // almost always the outbound connection to the anchor being + // dropped by the local firewall/router, or the anchor itself + // being down. The public anchor is reached over TCP/8443 (not + // UDP/8668 — that endpoint is dead). + "no_outbound_or_anchor_down" } else { "peers_but_no_anchor" }; @@ -126,8 +128,8 @@ impl RpcHandler { "connected" => "An anchor is reachable.", "daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.", "no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.", - "no_outbound_udp_or_anchor_down" => - "Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.", + "no_outbound_or_anchor_down" => + "Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.", "peers_but_no_anchor" => "Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.", _ => "", diff --git a/core/archipelago/src/federation/mod.rs b/core/archipelago/src/federation/mod.rs index 4f37f345..11da5736 100644 --- a/core/archipelago/src/federation/mod.rs +++ b/core/archipelago/src/federation/mod.rs @@ -14,8 +14,8 @@ mod types; pub use invites::{accept_invite, create_invite}; #[allow(unused_imports)] pub use storage::{ - add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes, - set_trust_level, update_node, + add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport, + remove_node, save_nodes, set_trust_level, update_node, }; pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did}; pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel}; diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index ad5b9c9f..6860b3c1 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve pub(crate) const FEDERATION_DIR: &str = "federation"; pub(crate) const NODES_FILE: &str = "nodes.json"; pub(crate) const INVITES_FILE: &str = "invites.json"; +/// Tombstones: DIDs the operator explicitly removed. Kept so transitive +/// federation discovery can't silently re-add a peer they deleted. +pub(crate) const REMOVED_FILE: &str = "removed-nodes.json"; /// Top-level file structures. #[derive(Debug, Default, Serialize, Deserialize)] @@ -17,6 +20,17 @@ pub(crate) struct NodesFile { pub(crate) nodes: Vec, } +#[derive(Debug, Default, Serialize, Deserialize)] +pub(crate) struct RemovedFile { + pub(crate) removed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RemovedNode { + pub(crate) did: String, + pub(crate) removed_at: String, +} + #[derive(Debug, Default, Serialize, Deserialize)] pub(crate) struct InvitesFile { pub(crate) outgoing: Vec, @@ -114,6 +128,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result Result Result> { + let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE); + if !path.exists() { + return Ok(std::collections::HashSet::new()); + } + let content = fs::read_to_string(&path) + .await + .context("Failed to read removed nodes")?; + let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default(); + Ok(file.removed.into_iter().map(|r| r.did).collect()) +} + +/// Record a DID as removed. Idempotent. +pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> { + let dir = ensure_dir(data_dir).await?; + let path = dir.join(REMOVED_FILE); + let mut file: RemovedFile = if path.exists() { + serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default()) + .unwrap_or_default() + } else { + RemovedFile::default() + }; + if !file.removed.iter().any(|r| r.did == did) { + file.removed.push(RemovedNode { + did: did.to_string(), + removed_at: chrono::Utc::now().to_rfc3339(), + }); + let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?; + fs::write(&path, content) + .await + .context("Failed to write removed nodes")?; + } + Ok(()) +} + +/// Clear a DID's tombstone (operator explicitly re-added it). +pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> { + let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE); + if !path.exists() { + return Ok(()); + } + let mut file: RemovedFile = + serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default()) + .unwrap_or_default(); + let before = file.removed.len(); + file.removed.retain(|r| r.did != did); + if file.removed.len() != before { + let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?; + fs::write(&path, content) + .await + .context("Failed to write removed nodes")?; + } + Ok(()) +} + pub async fn set_trust_level( data_dir: &Path, did: &str, @@ -287,6 +365,36 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn test_remove_tombstones_and_readd_clears_it() { + let dir = tempfile::tempdir().unwrap(); + add_node(dir.path(), make_node("did:key:z1", "a.onion")) + .await + .unwrap(); + // No tombstones yet. + assert!(load_removed_dids(dir.path()).await.unwrap().is_empty()); + + // Removing tombstones the DID so transitive discovery won't re-add it. + remove_node(dir.path(), "did:key:z1").await.unwrap(); + let removed = load_removed_dids(dir.path()).await.unwrap(); + assert!( + removed.contains("did:key:z1"), + "removed DID must be tombstoned" + ); + + // Explicitly re-adding clears the tombstone (intentional re-federate). + add_node(dir.path(), make_node("did:key:z1", "a.onion")) + .await + .unwrap(); + assert!( + !load_removed_dids(dir.path()) + .await + .unwrap() + .contains("did:key:z1"), + "explicit re-add must clear the tombstone" + ); + } + #[tokio::test] async fn test_set_trust_level() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/archipelago/src/federation/sync.rs b/core/archipelago/src/federation/sync.rs index 38427c95..d25cf768 100644 --- a/core/archipelago/src/federation/sync.rs +++ b/core/archipelago/src/federation/sync.rs @@ -118,6 +118,12 @@ async fn merge_transitive_peers( return Ok(()); } let mut nodes = super::storage::load_nodes(data_dir).await?; + // Tombstoned DIDs: peers the operator explicitly removed. Never re-add + // them via transitive discovery, or deleted (e.g. stale test) nodes + // reappear on the next sync with any peer that still lists them. + let removed = super::storage::load_removed_dids(data_dir) + .await + .unwrap_or_default(); let mut added = 0u32; let mut refreshed = 0u32; @@ -127,6 +133,10 @@ async fn merge_transitive_peers( if hint.did == source_did || hint.did == local_did { continue; } + // Skip anything the operator deliberately removed. + if removed.contains(&hint.did) { + continue; + } if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) { // Already known — just refresh fips_npub if we didn't have one. if existing.fips_npub.is_none() && hint.fips_npub.is_some() { diff --git a/core/archipelago/src/fips/dial.rs b/core/archipelago/src/fips/dial.rs index 51e26b8e..d48503d8 100644 --- a/core/archipelago/src/fips/dial.rs +++ b/core/archipelago/src/fips/dial.rs @@ -34,6 +34,17 @@ use tokio::net::UdpSocket; /// path filter can restrict the exposed surface. pub const PEER_PORT: u16 = 5679; +/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in +/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener +/// doesn't expose that path (e.g. a peer on an older build with a stricter +/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are +/// worth retrying over Tor, which reaches a different (less-filtered) route. +/// Success, redirects, and other 4xx (auth / bad request) are authoritative +/// and are returned as-is so we neither mask real errors nor double latency. +fn fips_should_fall_back(status: reqwest::StatusCode) -> bool { + status == reqwest::StatusCode::NOT_FOUND || status.is_server_error() +} + /// DNS suffix appended to a peer's bech32 npub. pub const FIPS_DNS_SUFFIX: &str = "fips"; @@ -294,13 +305,22 @@ impl<'a> PeerRequest<'a> { let pref = self.preference().await; // FIPS-only or Auto: try FIPS first. if matches!(pref, TransportPref::Auto | TransportPref::Fips) { - if let Some(resp) = self.try_fips_post_json(body).await? { - return Ok((resp, crate::transport::TransportKind::Fips)); - } - if pref == TransportPref::Fips { - anyhow::bail!( - "User set transport preference to FIPS only, but peer is unreachable over FIPS" - ); + match self.try_fips_post_json(body).await? { + Some(resp) => { + // Use the FIPS reply unless it's one a Tor retry could + // fix (404 path-not-served / 5xx) and we're allowed to + // fall back. FIPS-only never falls back. + if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { + return Ok((resp, crate::transport::TransportKind::Fips)); + } + } + None => { + if pref == TransportPref::Fips { + anyhow::bail!( + "User set transport preference to FIPS only, but peer is unreachable over FIPS" + ); + } + } } } let resp = self.send_tor_post_json(body).await?; @@ -312,13 +332,19 @@ impl<'a> PeerRequest<'a> { use crate::settings::transport::TransportPref; let pref = self.preference().await; if matches!(pref, TransportPref::Auto | TransportPref::Fips) { - if let Some(resp) = self.try_fips_get().await? { - return Ok((resp, crate::transport::TransportKind::Fips)); - } - if pref == TransportPref::Fips { - anyhow::bail!( - "User set transport preference to FIPS only, but peer is unreachable over FIPS" - ); + match self.try_fips_get().await? { + Some(resp) => { + if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) { + return Ok((resp, crate::transport::TransportKind::Fips)); + } + } + None => { + if pref == TransportPref::Fips { + anyhow::bail!( + "User set transport preference to FIPS only, but peer is unreachable over FIPS" + ); + } + } } } let resp = self.send_tor_get().await?; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index d7500357..113eb891 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -769,6 +769,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool { | "/archipelago/mesh-typed" | "/dwn" | "/transport/inbox" + // Content *catalog* — the peer-browse entry point. This is the + // exact path `/content` (no trailing slash); the prefix match + // below only covers `/content/` item fetches, so without + // this the catalog 404s over the mesh and `content.browse-peer` + // fails with "Peer returned error: 404 Not Found" (and never + // falls back to Tor, since a 404 is a successful HTTP exchange). + | "/content" ) // Prefix-matched content endpoints (peer file browse + fetch) || path.starts_with("/content/") @@ -1378,6 +1385,25 @@ mod merge_tests { } } + #[test] + fn peer_path_filter_allows_content_catalog_and_items() { + // Regression: the content *catalog* is exactly "/content" (no trailing + // slash). It must be reachable over the peer (FIPS) listener, else + // `content.browse-peer` 404s over the mesh. Item fetches are + // "/content/". + assert!(is_peer_allowed_path("/content"), "catalog must be allowed"); + assert!( + is_peer_allowed_path("/content/abc123"), + "items must be allowed" + ); + assert!(is_peer_allowed_path("/rpc/v1")); + assert!(is_peer_allowed_path("/health")); + // Not on the allow-list → rejected (no broad surface over the mesh). + assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak"); + assert!(!is_peer_allowed_path("/")); + assert!(!is_peer_allowed_path("/rpc/v2")); + } + #[test] fn preserves_transitional_state_on_merge() { // existing: user initiated a stop, spawn_transitional set Stopping. diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index b8897e01..621e3d2e 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -244,7 +244,7 @@
@@ -806,17 +806,22 @@ async function submitSideload() { display: flex; flex-direction: column; width: 100%; - height: 100%; + max-width: 34rem; + /* Centered card that never exceeds the visible viewport (minus safe areas), + matching the wallet receive modal / AppIconGrid credential modal. The body + scrolls if content overflows rather than the panel stretching edge-to-edge. */ + max-height: calc( + 100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem + ); min-height: 0; - max-width: none; - max-height: none; overflow: hidden; - border: 0; - border-radius: 0; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 1.5rem; background: rgba(8, 10, 18, 0.98); padding: 1.25rem; padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px))); - box-shadow: none; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); } .credential-modal-body { flex: 1 1 auto; diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 68be8d58..16eade29 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -188,6 +188,18 @@ init()
+ +
+
+ v1.7.95-alpha + June 15, 2026 +
+
+

Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.

+

Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.

+

The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.

+
+