Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions mesh-llm/src/mesh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use prost::Message;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use thiserror::Error;

use tokio::sync::{watch, Mutex};

Expand All @@ -23,6 +24,14 @@ use crate::crypto::{
use crate::inference::moe;
use crate::protocol::*;

#[derive(Debug, Error)]
pub enum InviteTokenError {
#[error("invalid invite token encoding: {0}")]
Decode(base64::DecodeError),
#[error("invalid invite token JSON: {0}")]
Json(serde_json::Error),
}

/// Demand signal for a model — tracks interest via API requests and --model declarations.
/// Gossiped across the mesh and merged via max(). Decays naturally when last_active gets old.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -1611,6 +1620,15 @@ impl Node {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&json)
}

pub fn decode_invite_token(
invite_token: &str,
) -> std::result::Result<EndpointAddr, InviteTokenError> {
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(invite_token)
.map_err(InviteTokenError::Decode)?;
serde_json::from_slice(&json).map_err(InviteTokenError::Json)
}

#[cfg(test)]
pub async fn sync_from_peer_for_tests(&self, remote: &Self) {
let remote_id = remote.endpoint.id();
Expand Down Expand Up @@ -1673,8 +1691,7 @@ impl Node {
}

pub async fn join(&self, invite_token: &str) -> Result<()> {
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(invite_token)?;
let addr: EndpointAddr = serde_json::from_slice(&json)?;
let addr = Self::decode_invite_token(invite_token)?;
// Clear dead status — explicit join should always attempt connection
self.state.lock().await.dead_peers.remove(&addr.id);
self.connect_to_peer(addr).await
Expand Down
23 changes: 23 additions & 0 deletions mesh-llm/src/mesh/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4487,6 +4487,29 @@ async fn config_subscribe_matching_owner_receives_snapshot() -> Result<()> {
Ok(())
}

#[tokio::test]
async fn invite_token_round_trips_to_endpoint_addr() -> Result<()> {
let node = make_test_node(super::NodeRole::Client).await?;
let token = node.invite_token();
let decoded = Node::decode_invite_token(&token).expect("invite token should decode");

assert_eq!(decoded.id, node.id());
assert_eq!(decoded.addrs, node.endpoint.addr().addrs);

Ok(())
}

#[test]
fn invalid_invite_token_reports_json_error() {
let bad_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not-json");
let err = Node::decode_invite_token(&bad_payload).expect_err("token must be rejected");

match err {
InviteTokenError::Json(_) => {}
other => panic!("expected JSON invite token error, got {other:?}"),
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_subscribe_wrong_owner_returns_error() -> Result<()> {
let server_owner = test_owner_keypair(0x22, 0x23);
Expand Down
120 changes: 112 additions & 8 deletions mesh-llm/src/network/nostr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,61 @@ pub struct DiscoveredMesh {
pub expires_at: Option<u64>,
}

fn validate_listing(listing: &MeshListing) -> std::result::Result<(), String> {
const MAX_INVITE_TOKEN_LEN: usize = 16 * 1024;
const MAX_NAME_LEN: usize = 128;
const MAX_REGION_LEN: usize = 64;
const MAX_MODEL_ENTRIES: usize = 256;

if listing.invite_token.is_empty() {
return Err("missing invite token".into());
}
if listing.invite_token.len() > MAX_INVITE_TOKEN_LEN {
return Err(format!(
"invite token too large ({} bytes)",
listing.invite_token.len()
));
}
crate::mesh::Node::decode_invite_token(&listing.invite_token)
.map_err(|err| format!("invalid invite token ({err})"))?;

if let Some(name) = &listing.name {
if name.len() > MAX_NAME_LEN {
return Err(format!("mesh name too long ({} bytes)", name.len()));
}
}
if let Some(region) = &listing.region {
if region.len() > MAX_REGION_LEN {
return Err(format!("region too long ({} bytes)", region.len()));
}
}

if listing.serving.len() > MAX_MODEL_ENTRIES {
return Err(format!(
"too many serving models ({})",
listing.serving.len()
));
}
if listing.wanted.len() > MAX_MODEL_ENTRIES {
return Err(format!("too many wanted models ({})", listing.wanted.len()));
}
if listing.on_disk.len() > MAX_MODEL_ENTRIES {
return Err(format!(
"too many on-disk models ({})",
listing.on_disk.len()
));
}

if listing.max_clients > 0 && listing.client_count > listing.max_clients.saturating_mul(8) {
return Err(format!(
"client count {} is implausible for max_clients {}",
listing.client_count, listing.max_clients
));
}

Ok(())
}

impl std::fmt::Display for DiscoveredMesh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let vram_gb = self.listing.total_vram_bytes as f64 / 1e9;
Expand Down Expand Up @@ -765,9 +820,24 @@ pub async fn discover(

let listing: MeshListing = match serde_json::from_str(&event.content) {
Ok(l) => l,
Err(_) => continue,
Err(err) => {
tracing::warn!(
"Skipping Nostr mesh listing from {}: invalid listing JSON: {err}",
event.pubkey.to_bech32().unwrap_or_default()
);
continue;
}
};

if let Err(reason) = validate_listing(&listing) {
tracing::warn!(
"Skipping Nostr mesh listing from {}: {}",
event.pubkey.to_bech32().unwrap_or_default(),
reason
);
continue;
}

let publisher_npub = event.pubkey.to_bech32().unwrap_or_default();
let discovered = DiscoveredMesh {
listing,
Expand Down Expand Up @@ -1149,6 +1219,17 @@ mod auto_pack_tests {
#[cfg(test)]
mod scoring_tests {
use super::*;
use base64::Engine;
use iroh::{EndpointAddr, EndpointId, SecretKey};

pub(super) fn valid_invite_token(_label: &str) -> String {
let addr = EndpointAddr {
id: EndpointId::from(SecretKey::generate(&mut ::rand::rng()).public()),
addrs: Default::default(),
};
let json = serde_json::to_vec(&addr).expect("endpoint addr should serialize");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
}

fn make_mesh(
name: Option<&str>,
Expand All @@ -1161,7 +1242,7 @@ mod scoring_tests {
) -> DiscoveredMesh {
DiscoveredMesh {
listing: MeshListing {
invite_token: format!("invite-{}", mesh_id.unwrap_or("test")),
invite_token: valid_invite_token(mesh_id.unwrap_or("test")),
serving: serving.iter().map(|s| s.to_string()).collect(),
wanted: vec![],
on_disk: vec![],
Expand Down Expand Up @@ -1195,6 +1276,29 @@ mod scoring_tests {
assert!(score > 400, "community mesh should score high, got {score}");
}

#[test]
fn validate_listing_rejects_bad_invite_token() {
let listing = MeshListing {
invite_token: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not-json"),
serving: vec![],
wanted: vec![],
on_disk: vec![],
total_vram_bytes: 0,
node_count: 1,
client_count: 0,
max_clients: 0,
name: None,
region: None,
mesh_id: None,
};

let err = validate_listing(&listing).expect_err("listing must be rejected");
assert!(
err.contains("invalid invite token"),
"unexpected error: {err}"
);
}

#[test]
fn score_private_mesh_penalty() {
let mesh = make_mesh(
Expand Down Expand Up @@ -1778,7 +1882,7 @@ mod integration_tests {
.await
.expect("pub_a");
let listing_a = MeshListing {
invite_token: "invite-a".into(),
invite_token: scoring_tests::valid_invite_token("publish-a"),
serving: vec!["Qwen3-8B-Q4_K_M".into()],
wanted: vec![],
on_disk: vec![],
Expand All @@ -1798,7 +1902,7 @@ mod integration_tests {
.await
.expect("pub_b");
let mut listing_b = listing_a.clone();
listing_b.invite_token = "invite-b".into();
listing_b.invite_token = scoring_tests::valid_invite_token("publish-b");
pub_b.publish(&listing_b, 120).await.expect("publish B");

tokio::time::sleep(Duration::from_secs(3)).await;
Expand Down Expand Up @@ -1832,12 +1936,12 @@ mod integration_tests {
.map(|m| m.listing.invite_token.as_str())
.collect();
assert!(
tokens.contains(&"invite-a"),
"missing invite-a in {tokens:?}"
tokens.contains(&listing_a.invite_token.as_str()),
"missing listing_a token in {tokens:?}"
);
assert!(
tokens.contains(&"invite-b"),
"missing invite-b in {tokens:?}"
tokens.contains(&listing_b.invite_token.as_str()),
"missing listing_b token in {tokens:?}"
);

// Second discover with same client still works
Expand Down
6 changes: 5 additions & 1 deletion mesh-llm/src/runtime/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ pub(super) async fn nostr_rediscovery(
rejoined = true;
}
Err(e) => {
eprintln!("⚠️ Re-join failed: {e}");
if let Some(invite_err) = e.downcast_ref::<mesh::InviteTokenError>() {
eprintln!("⚠️ Re-join skipped: invalid Nostr invite token ({invite_err})");
} else {
eprintln!("⚠️ Re-join failed: {e}");
}
}
}
if rejoined {
Expand Down
Loading