bug fixing and deploy and build diagnostics
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
//! Federation invite creation, parsing, and acceptance.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::{add_node, load_invites, load_nodes, save_invites};
|
||||
use super::types::{FederatedNode, FederationInvite, TrustLevel};
|
||||
|
||||
/// Generate an invite code. Format: `fed1:<base64(json{did, onion, pubkey, token})>`
|
||||
pub async fn create_invite(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
onion: &str,
|
||||
pubkey: &str,
|
||||
) -> Result<String> {
|
||||
use base64::Engine;
|
||||
use rand::Rng;
|
||||
|
||||
let mut token_bytes = [0u8; 16];
|
||||
rand::thread_rng().fill(&mut token_bytes);
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"did": did,
|
||||
"onion": onion,
|
||||
"pubkey": pubkey,
|
||||
"token": token,
|
||||
});
|
||||
let json = serde_json::to_string(&payload).context("Failed to serialize invite")?;
|
||||
let code = format!(
|
||||
"fed1:{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json.as_bytes())
|
||||
);
|
||||
|
||||
let invite = FederationInvite {
|
||||
code: code.clone(),
|
||||
did: did.to_string(),
|
||||
onion: onion.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: false,
|
||||
};
|
||||
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.outgoing.push(invite);
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Parse an invite code into its components.
|
||||
pub fn parse_invite(code: &str) -> Result<(String, String, String, String)> {
|
||||
use base64::Engine;
|
||||
|
||||
let encoded = code
|
||||
.strip_prefix("fed1:")
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid invite format: must start with fed1:"))?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.context("Invalid base64 in invite code")?;
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).context("Invalid JSON in invite")?;
|
||||
|
||||
let did = payload["did"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing did in invite"))?
|
||||
.to_string();
|
||||
let onion = payload["onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing onion in invite"))?
|
||||
.to_string();
|
||||
let pubkey = payload["pubkey"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey in invite"))?
|
||||
.to_string();
|
||||
let token = payload["token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token in invite"))?
|
||||
.to_string();
|
||||
|
||||
Ok((did, onion, pubkey, token))
|
||||
}
|
||||
|
||||
/// Accept an invite: parse code, verify the remote node, add to federation.
|
||||
pub async fn accept_invite(
|
||||
data_dir: &Path,
|
||||
code: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<FederatedNode> {
|
||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||
|
||||
// Check not already federated
|
||||
let nodes = load_nodes(data_dir).await?;
|
||||
if nodes.iter().any(|n| n.did == did) {
|
||||
anyhow::bail!("Already federated with node {}", did);
|
||||
}
|
||||
|
||||
let node = FederatedNode {
|
||||
did: did.clone(),
|
||||
pubkey,
|
||||
onion,
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: chrono::Utc::now().to_rfc3339(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
|
||||
add_node(data_dir, node.clone()).await?;
|
||||
|
||||
// Record as incoming accepted invite
|
||||
let mut invites = load_invites(data_dir).await?;
|
||||
invites.incoming.push(FederationInvite {
|
||||
code: code.to_string(),
|
||||
did: did.clone(),
|
||||
onion: node.onion.clone(),
|
||||
pubkey: node.pubkey.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
accepted: true,
|
||||
});
|
||||
save_invites(data_dir, &invites).await?;
|
||||
|
||||
// Notify remote node (best-effort over Tor)
|
||||
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Best-effort notification to the remote node that we joined their federation.
|
||||
/// Signs the message with our ed25519 key so the remote peer can verify authenticity.
|
||||
async fn notify_join(
|
||||
remote_onion: &str,
|
||||
local_did: &str,
|
||||
local_onion: &str,
|
||||
local_pubkey: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<()> {
|
||||
let host = if remote_onion.ends_with(".onion") {
|
||||
remote_onion.to_string()
|
||||
} else {
|
||||
format!("{}.onion", remote_onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||
let signature = sign_fn(sign_data.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.peer-joined",
|
||||
"params": {
|
||||
"did": local_did,
|
||||
"onion": local_onion,
|
||||
"pubkey": local_pubkey,
|
||||
"signature": signature,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let _ = client.post(&url).json(&body).send().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::storage::load_nodes;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_and_parse_invite() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:z1", "test.onion", "aabbcc")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code.starts_with("fed1:"));
|
||||
|
||||
let (did, onion, pubkey, token) = parse_invite(&code).unwrap();
|
||||
assert_eq!(did, "did:key:z1");
|
||||
assert_eq!(onion, "test.onion");
|
||||
assert_eq!(pubkey, "aabbcc");
|
||||
assert_eq!(token.len(), 32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_invite() {
|
||||
assert!(parse_invite("invalid").is_err());
|
||||
assert!(parse_invite("fed1:not-valid-base64!!!").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_creates_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accept from a different "local" perspective
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
let node = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(node.did, "did:key:zRemote");
|
||||
assert_eq!(node.trust_level, TrustLevel::Trusted);
|
||||
|
||||
let nodes = load_nodes(dir2.path()).await.unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_invite_rejects_duplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = create_invite(dir.path(), "did:key:zRemote", "remote.onion", "remotepub")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dir2 = tempfile::tempdir().unwrap();
|
||||
accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Accepting the same invite again should fail
|
||||
let result = accept_invite(
|
||||
dir2.path(),
|
||||
&code,
|
||||
"did:key:zLocal",
|
||||
"local.onion",
|
||||
"localpub",
|
||||
|_| "test-sig".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Node federation: trusted multi-node clusters with state sync.
|
||||
//!
|
||||
//! Nodes federate by exchanging invite codes containing DID + onion address.
|
||||
//! Trust is bilateral — both sides must agree. Federated nodes periodically
|
||||
//! sync container status, health metrics, and availability.
|
||||
|
||||
mod invites;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod types;
|
||||
|
||||
// Re-export all public items so `crate::federation::*` continues to work.
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
pub use storage::{
|
||||
add_node, load_nodes, remove_node, save_nodes, set_trust_level,
|
||||
};
|
||||
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer};
|
||||
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Federation persistent storage: node list and invite management on disk.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
pub(crate) const INVITES_FILE: &str = "invites.json";
|
||||
|
||||
/// Top-level file structures.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct NodesFile {
|
||||
pub(crate) nodes: Vec<FederatedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub(crate) struct InvitesFile {
|
||||
pub(crate) outgoing: Vec<FederationInvite>,
|
||||
pub(crate) incoming: Vec<FederationInvite>,
|
||||
}
|
||||
|
||||
/// Ensure federation directory exists.
|
||||
pub(crate) async fn ensure_dir(data_dir: &Path) -> Result<std::path::PathBuf> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create federation directory")?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
// ──────────────────────────── Node Management ────────────────────────────
|
||||
|
||||
pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(NODES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read federation nodes")?;
|
||||
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file.nodes)
|
||||
}
|
||||
|
||||
pub async fn save_nodes(data_dir: &Path, nodes: &[FederatedNode]) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let file = NodesFile {
|
||||
nodes: nodes.to_vec(),
|
||||
};
|
||||
let content = serde_json::to_string_pretty(&file).context("Failed to serialize nodes")?;
|
||||
fs::write(dir.join(NODES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write federation nodes")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let exists = nodes.iter().any(|n| n.did == node.did);
|
||||
if exists {
|
||||
anyhow::bail!("Node with DID {} is already federated", node.did);
|
||||
}
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| n.did != did);
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
let node = nodes
|
||||
.iter_mut()
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
pub async fn update_node_state(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
state: NodeStateSnapshot,
|
||||
) -> Result<()> {
|
||||
let mut nodes = load_nodes(data_dir).await?;
|
||||
if let Some(node) = nodes.iter_mut().find(|n| n.did == did) {
|
||||
node.last_seen = Some(state.timestamp.clone());
|
||||
// Update node name from sync if provided (peer announced their name)
|
||||
if let Some(ref name) = state.node_name {
|
||||
if !name.is_empty() {
|
||||
node.name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
node.last_state = Some(state);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ──────────────────────────── Invite Storage ────────────────────────────
|
||||
|
||||
pub(crate) async fn load_invites(data_dir: &Path) -> Result<InvitesFile> {
|
||||
let dir = data_dir.join(FEDERATION_DIR);
|
||||
let path = dir.join(INVITES_FILE);
|
||||
if !path.exists() {
|
||||
return Ok(InvitesFile::default());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read invites")?;
|
||||
let file: InvitesFile = serde_json::from_str(&content).unwrap_or_default();
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_invites(data_dir: &Path, invites: &InvitesFile) -> Result<()> {
|
||||
let dir = ensure_dir(data_dir).await?;
|
||||
let content = serde_json::to_string_pretty(invites).context("Failed to serialize invites")?;
|
||||
fs::write(dir.join(INVITES_FILE), content)
|
||||
.await
|
||||
.context("Failed to write invites")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::federation::types::AppStatus;
|
||||
|
||||
fn make_node(did: &str, onion: &str) -> FederatedNode {
|
||||
FederatedNode {
|
||||
did: did.to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: onion.to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nodes_empty_when_no_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_nodes_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nodes = vec![
|
||||
make_node("did:key:z1", "a.onion"),
|
||||
make_node("did:key:z2", "b.onion"),
|
||||
];
|
||||
save_nodes(dir.path(), &nodes).await.unwrap();
|
||||
let loaded = load_nodes(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded[0].did, "did:key:z1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_node_deduplicates_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = add_node(dir.path(), make_node("did:key:z1", "b.onion")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_node_by_did() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z2", "b.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:z1").await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].did, "did:key:z2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_remove_nonexistent_node_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = remove_node(dir.path(), "did:key:nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_node_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = NodeStateSnapshot {
|
||||
timestamp: "2026-03-10T12:00:00Z".to_string(),
|
||||
node_name: None,
|
||||
apps: vec![AppStatus {
|
||||
id: "bitcoin".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("27.0".to_string()),
|
||||
}],
|
||||
cpu_usage_percent: Some(45.2),
|
||||
mem_used_bytes: Some(4_000_000_000),
|
||||
mem_total_bytes: Some(8_000_000_000),
|
||||
disk_used_bytes: None,
|
||||
disk_total_bytes: None,
|
||||
uptime_secs: Some(86400),
|
||||
tor_active: Some(true),
|
||||
};
|
||||
|
||||
update_node_state(dir.path(), "did:key:z1", state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let nodes = load_nodes(dir.path()).await.unwrap();
|
||||
assert!(nodes[0].last_seen.is_some());
|
||||
let ls = nodes[0].last_state.as_ref().unwrap();
|
||||
assert_eq!(ls.apps.len(), 1);
|
||||
assert_eq!(ls.cpu_usage_percent, Some(45.2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Federation state sync and remote deployment.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::storage::update_node_state;
|
||||
use super::types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
|
||||
|
||||
/// Sync state with a single federated peer over Tor.
|
||||
pub async fn sync_with_peer(
|
||||
data_dir: &Path,
|
||||
peer: &FederatedNode,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<NodeStateSnapshot> {
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
// Sign current timestamp for authentication
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "federation.get-state",
|
||||
"params": {}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Peer returned {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("No result in peer response"))?;
|
||||
|
||||
let state: NodeStateSnapshot =
|
||||
serde_json::from_value(state_val.clone()).context("Failed to parse peer state")?;
|
||||
|
||||
update_node_state(data_dir, &peer.did, state.clone()).await?;
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Build the local node's state snapshot for sharing with peers.
|
||||
pub fn build_local_state(
|
||||
apps: Vec<AppStatus>,
|
||||
cpu: f64,
|
||||
mem_used: u64,
|
||||
mem_total: u64,
|
||||
disk_used: u64,
|
||||
disk_total: u64,
|
||||
uptime: u64,
|
||||
tor_active: bool,
|
||||
server_name: Option<String>,
|
||||
) -> NodeStateSnapshot {
|
||||
NodeStateSnapshot {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
node_name: server_name,
|
||||
apps,
|
||||
cpu_usage_percent: Some(cpu),
|
||||
mem_used_bytes: Some(mem_used),
|
||||
mem_total_bytes: Some(mem_total),
|
||||
disk_used_bytes: Some(disk_used),
|
||||
disk_total_bytes: Some(disk_total),
|
||||
uptime_secs: Some(uptime),
|
||||
tor_active: Some(tor_active),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy an app to a remote federated peer over Tor.
|
||||
/// Only works if the peer is trusted and the app exists in our marketplace.
|
||||
pub async fn deploy_to_peer(
|
||||
peer: &FederatedNode,
|
||||
app_id: &str,
|
||||
version: &str,
|
||||
marketplace_url: &str,
|
||||
local_did: &str,
|
||||
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||
) -> Result<serde_json::Value> {
|
||||
if peer.trust_level != TrustLevel::Trusted {
|
||||
anyhow::bail!("Can only deploy to trusted peers (current: {})", peer.trust_level);
|
||||
}
|
||||
|
||||
let host = if peer.onion.ends_with(".onion") {
|
||||
peer.onion.clone()
|
||||
} else {
|
||||
format!("{}.onion", peer.onion)
|
||||
};
|
||||
let url = format!("http://{}/rpc/v1", host);
|
||||
|
||||
let timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let signature = sign_fn(timestamp.as_bytes());
|
||||
|
||||
let body = serde_json::json!({
|
||||
"method": "package.install",
|
||||
"params": {
|
||||
"id": app_id,
|
||||
"version": version,
|
||||
"marketplace-url": marketplace_url,
|
||||
}
|
||||
});
|
||||
|
||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY).context("Invalid Tor proxy")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")?;
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.header("X-Federation-Sig", &signature)
|
||||
.header("X-Federation-Timestamp", ×tamp)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to reach federated peer for deploy")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Remote node returned HTTP {}", resp.status());
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
|
||||
if let Some(err) = result.get("error") {
|
||||
if !err.is_null() {
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("Unknown remote error");
|
||||
anyhow::bail!("Remote node refused deploy: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"deployed": true,
|
||||
"app_id": app_id,
|
||||
"peer_did": peer.did,
|
||||
"peer_onion": peer.onion,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_local_state() {
|
||||
let state = build_local_state(
|
||||
vec![AppStatus {
|
||||
id: "lnd".to_string(),
|
||||
status: "running".to_string(),
|
||||
version: Some("0.18".to_string()),
|
||||
}],
|
||||
25.5,
|
||||
2_000_000_000,
|
||||
8_000_000_000,
|
||||
100_000_000_000,
|
||||
500_000_000_000,
|
||||
3600,
|
||||
true,
|
||||
Some("Test Node".to_string()),
|
||||
);
|
||||
assert_eq!(state.apps.len(), 1);
|
||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||
assert_eq!(state.tor_active, Some(true));
|
||||
assert_eq!(state.node_name, Some("Test Node".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Federation type definitions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Trust level for a federated node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrustLevel {
|
||||
Trusted,
|
||||
Observer,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TrustLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TrustLevel::Trusted => write!(f, "trusted"),
|
||||
TrustLevel::Observer => write!(f, "observer"),
|
||||
TrustLevel::Untrusted => write!(f, "untrusted"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A federated node in our cluster.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederatedNode {
|
||||
pub did: String,
|
||||
pub pubkey: String,
|
||||
pub onion: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
pub trust_level: TrustLevel,
|
||||
pub added_at: String,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_state: Option<NodeStateSnapshot>,
|
||||
}
|
||||
|
||||
/// State snapshot received from a federated peer during sync.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeStateSnapshot {
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub node_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub apps: Vec<AppStatus>,
|
||||
#[serde(default)]
|
||||
pub cpu_usage_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub mem_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub mem_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_used_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub disk_total_bytes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub uptime_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tor_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Status of a single app/container on a remote node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppStatus {
|
||||
pub id: String,
|
||||
pub status: String, // "running", "stopped", "installed"
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// A pending invite (outgoing or incoming).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FederationInvite {
|
||||
pub code: String,
|
||||
pub did: String,
|
||||
pub onion: String,
|
||||
pub pubkey: String,
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trust_level_serialization() {
|
||||
let json = serde_json::to_string(&TrustLevel::Trusted).unwrap();
|
||||
assert_eq!(json, "\"trusted\"");
|
||||
|
||||
let parsed: TrustLevel = serde_json::from_str("\"observer\"").unwrap();
|
||||
assert_eq!(parsed, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_federated_node_serialization_roundtrip() {
|
||||
let node = FederatedNode {
|
||||
did: "did:key:zABC".to_string(),
|
||||
pubkey: "aabbccdd".to_string(),
|
||||
onion: "test.onion".to_string(),
|
||||
name: None,
|
||||
trust_level: TrustLevel::Trusted,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
last_seen: None,
|
||||
last_state: None,
|
||||
};
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let parsed: FederatedNode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.did, "did:key:zABC");
|
||||
assert_eq!(parsed.trust_level, TrustLevel::Trusted);
|
||||
assert!(parsed.last_state.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_state_snapshot_defaults() {
|
||||
let json = r#"{"timestamp": "2026-01-01T00:00:00Z"}"#;
|
||||
let state: NodeStateSnapshot = serde_json::from_str(json).unwrap();
|
||||
assert!(state.apps.is_empty());
|
||||
assert!(state.cpu_usage_percent.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user