fix(fips,federation,ui): mesh content browse, removed-node tombstones, modal sizing
FIPS peer content browse over the mesh was failing with "Peer returned error: 404 Not Found" and never falling back to Tor. `is_peer_allowed_path` only allowed `/content/<id>` (item fetches) — the catalog endpoint is exactly `/content` (no trailing slash), so it 404'd over the FIPS peer listener. A FIPS 404 was also treated as a successful response, so the dial never retried Tor. Fixes: allow `/content` over the mesh; add `fips_should_fall_back()` so a FIPS 404/5xx in Auto mode falls back to Tor (handles version-skew peers reaching a different route). Also correct the reconnect hint text — the public anchor is TCP/8443, not UDP/8668. Federation: deleted nodes reappeared because transitive discovery (`merge` of a peer's advertised trusted peers) re-added any unknown DID. Add a tombstone store (`removed-nodes.json`): remove_node tombstones the DID, transitive merge skips tombstoned DIDs, and a remote-triggered peer-joined is ignored for a removed DID. Explicit local re-add (add_node) clears the tombstone. UI: the app credentials modal panel stretched edge-to-edge (height:100%, max-width:none, items-stretch overlay). Constrain it to a centered card (max-width 34rem, rounded, dimmed full-screen backdrop) matching the AppIconGrid / wallet-receive modal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7bd22f1f80
commit
e056c2477b
@@ -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};
|
||||
|
||||
@@ -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<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct RemovedFile {
|
||||
pub(crate) removed: Vec<RemovedNode>,
|
||||
}
|
||||
|
||||
#[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<FederationInvite>,
|
||||
@@ -114,6 +128,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
// Explicitly (re-)adding a node clears any prior tombstone so the
|
||||
// operator can intentionally bring back a previously removed peer.
|
||||
let _ = untombstone_did(data_dir, &node.did).await;
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
@@ -127,9 +144,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
// Tombstone the DID so transitive federation discovery (a still-federated
|
||||
// peer advertising this DID as one of *its* trusted peers) can't silently
|
||||
// re-add it. Best-effort: a failed tombstone write must not fail the
|
||||
// remove the operator asked for.
|
||||
let _ = tombstone_did(data_dir, did).await;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Load the set of tombstoned (operator-removed) DIDs.
|
||||
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
|
||||
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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user