use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use mcpmesh_local_api::{
BlobFetchResult, BlobPublishResult, BlobScopeList, InviteResult, PairResult, PeerAddParams,
PeerRemoveParams, PeerRenameParams, RegisterServiceParams, ScopeInfo, SetRelaysResult,
};
use mcpmesh_net::errors::{ERR_UNREACHABLE, synthesized};
use mcpmesh_net::framing::{FrameReader, write_frame};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::allowlist::{PeerEntry, PeerStore};
use crate::audit::{AuditRecord, now_ts};
use crate::config::Config;
use crate::control::DaemonState;
use crate::pairing::Invite;
use crate::util::{blocking, epoch_now_u64};
use super::accept::swap_services;
use super::config_write::{
append_allow_to_config, remove_allow_from_config, remove_principal_from_service,
remove_service_from_config, write_relays, write_service_to_config,
};
use super::{MeshState, dial_service, pipe_session};
const INVITE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
pub const RELAY_READY_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) async fn blob_publish(
state: &DaemonState,
scope: String,
path: String,
) -> Result<BlobPublishResult> {
let mesh = state.mesh_required()?;
let provider = mesh.app_blobs().await.context(
"app-blob provider not enabled (its store failed to build — check the daemon log)",
)?;
let (ticket, hash) = provider
.publish_scope(&scope, Path::new(&path))
.await
.context("publish blob into scope")?;
Ok(BlobPublishResult { ticket, hash })
}
pub(crate) async fn blob_grant(
state: &DaemonState,
scope: String,
principal: String,
) -> Result<()> {
let mesh = state.mesh_required()?;
let provider = mesh.app_blobs().await.context(
"app-blob provider not enabled (its store failed to build — check the daemon log)",
)?;
provider.grant(&scope, &principal)
}
pub(crate) async fn blob_revoke(
state: &DaemonState,
scope: String,
principals: Vec<String>,
) -> Result<()> {
let mesh = state.mesh_required()?;
let provider = mesh.app_blobs().await.context(
"app-blob provider not enabled (its store failed to build — check the daemon log)",
)?;
if !provider.has_scope(&scope) {
anyhow::bail!(NoSuchBlobScope(scope));
}
let changed = provider.revoke_from_scope(&scope, &principals)?;
tracing::info!(%scope, count = principals.len(), changed, "blob grants revoked");
Ok(())
}
pub(crate) async fn blob_unpublish(state: &DaemonState, scope: String, hash: String) -> Result<()> {
let mesh = state.mesh_required()?;
let provider = mesh.app_blobs().await.context(
"app-blob provider not enabled (its store failed to build — check the daemon log)",
)?;
let parsed = crate::blobs::parse_blob_hash(&hash)?;
let hash_hex = parsed.to_hex().to_string();
if !provider.has_scope(&scope) {
anyhow::bail!(NoSuchBlobScope(scope));
}
let changed = provider.unpublish(&scope, &hash_hex).await?;
tracing::info!(%scope, changed, "blob unpublished from scope");
Ok(())
}
#[derive(Debug)]
pub struct NoSuchBlobScope(pub String);
impl std::fmt::Display for NoSuchBlobScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"no blob scope named '{}' — 'blob_list' shows the scopes this daemon has",
self.0
)
}
}
impl std::error::Error for NoSuchBlobScope {}
#[derive(Debug)]
pub struct NoSuchBlob(pub String);
impl std::fmt::Display for NoSuchBlob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"blob '{}' is not held complete by this daemon — fetch it before republishing",
self.0
)
}
}
impl std::error::Error for NoSuchBlob {}
#[derive(Debug)]
pub struct BlobWithdrawn {
pub scope: String,
pub hash: String,
}
impl std::fmt::Display for BlobWithdrawn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"blob '{}' was withdrawn from scope '{}' — republishing will not restore it; \
'blob_publish' from the file if the re-share is intended",
self.hash, self.scope
)
}
}
impl std::error::Error for BlobWithdrawn {}
pub(crate) async fn blob_republish(
state: &DaemonState,
scope: String,
hash: String,
) -> Result<mcpmesh_local_api::BlobPublishResult> {
let mesh = state.mesh_required()?;
let provider = mesh.app_blobs().await.ok_or_else(|| {
anyhow::anyhow!(
"app-blob provider not enabled (its store failed to build — check the daemon log)"
)
})?;
let (ticket, hash) = provider.republish(&scope, &hash).await?;
tracing::info!(%scope, %hash, "blob republished");
Ok(mcpmesh_local_api::BlobPublishResult { ticket, hash })
}
pub(crate) async fn blob_list(
state: &DaemonState,
params: mcpmesh_local_api::BlobListParams,
) -> Result<BlobScopeList> {
let mesh = state.mesh_required()?;
let q = crate::blobs::scope::ListQuery {
scope: params.scope,
hash: params.hash,
limit: params.limit,
offset: params.offset,
counts_only: params.counts_only,
};
let Some(provider) = mesh.app_blobs().await else {
return Ok(BlobScopeList {
scopes: Vec::new(),
total: 0,
truncated: false,
});
};
let page = provider.list_page(&q)?;
Ok(BlobScopeList {
scopes: page
.rows
.into_iter()
.map(
|(name, hashes, grants, withdrawn, hash_count, grant_count, withdrawn_count)| {
ScopeInfo {
name,
hashes,
grants,
withdrawn,
hash_count,
grant_count,
withdrawn_count,
}
},
)
.collect(),
total: page.total,
truncated: page.truncated,
})
}
pub(crate) async fn blob_fetch(
state: &DaemonState,
ticket: String,
dest_path: String,
) -> Result<BlobFetchResult> {
let mesh = state.mesh_required()?;
let provider = mesh.app_blobs().await.context(
"app-blob provider not enabled (its store failed to build — check the daemon log)",
)?;
let hash = provider.fetch(&ticket).await.context("fetch blob")?;
let dest = PathBuf::from(dest_path);
let bytes_len = provider.export_to(hash, &dest).await?;
Ok(BlobFetchResult {
hash: hash.to_hex().to_string(),
bytes_len,
})
}
async fn reload_services_from_disk(mesh: &Arc<MeshState>, why: &str) -> Result<()> {
let cfg = Config::load(&mesh.config_path)
.map_err(|e| anyhow::anyhow!("reload config after {why}: {e}"))?;
let ephemeral = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned")
.clone();
swap_services(
mesh,
crate::daemon::build_services_with_ephemeral(
&cfg,
&mesh.audit(),
&mesh.limits(),
&ephemeral,
),
);
Ok(())
}
pub(crate) async fn register_service(
state: &DaemonState,
params: RegisterServiceParams,
) -> Result<()> {
let mesh = state.mesh_required()?;
let _reload = mesh.reload_lock.lock().await;
let RegisterServiceParams {
name,
backend,
allow,
ephemeral,
} = params;
if ephemeral {
let cfg = Config::load(&mesh.config_path)
.map_err(|e| anyhow::anyhow!("config error in {}: {e}", mesh.config_path.display()))?;
if cfg.services.contains_key(&name) {
anyhow::bail!(
"service '{name}' is already registered persistently in config; \
use a different name for an ephemeral registration"
);
}
mesh.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned")
.insert(
name.clone(),
crate::daemon::EphemeralService {
backend,
allow: allow.clone(),
},
);
reload_services_from_disk(mesh, "register-ephemeral").await?;
tracing::info!(service = %name, "registered ephemeral service");
return Ok(());
}
{
let map = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned");
if map.contains_key(&name) {
anyhow::bail!(
"service '{name}' is currently registered ephemerally; \
unregister it first, or use a different name for the persistent registration"
);
}
}
let config_path = mesh.config_path.clone();
let (name_w, backend_w, allow_w) = (name.clone(), backend.clone(), allow.clone());
blocking("join config write", move || {
write_service_to_config(&config_path, &name_w, &backend_w, &allow_w)
})
.await??;
reload_services_from_disk(mesh, "register").await?;
tracing::info!(service = %name, "registered/updated service");
Ok(())
}
#[doc(hidden)]
pub async fn unregister_ephemeral(mesh: &Arc<MeshState>, names: &[String]) {
if names.is_empty() {
return;
}
let _reload = mesh.reload_lock.lock().await;
{
let mut map = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned");
for name in names {
map.remove(name);
}
}
if let Err(e) = reload_services_from_disk(mesh, "unregister-ephemeral").await {
tracing::warn!(%e, "reload after ephemeral unregister failed");
}
}
pub(crate) async fn add_peer(state: &DaemonState, params: PeerAddParams) -> Result<()> {
let mesh = state.mesh_required()?;
let PeerAddParams {
nickname,
endpoint_id,
allow,
} = params;
let endpoint_id = endpoint_id
.parse::<iroh::EndpointId>()
.map_err(|e| anyhow::anyhow!("peer_add: endpoint_id is not a valid EndpointId: {e}"))?;
let entry = PeerEntry {
endpoint_id: *endpoint_id.as_bytes(),
nickname: nickname.clone(),
services: allow,
paired_at: None,
user_id: None,
last_addr: None,
};
let store = mesh.store.clone();
blocking("join peer add", move || store.add(entry)).await??;
tracing::info!(peer = %nickname, "added peer to allowlist");
Ok(())
}
pub async fn remove_peer(state: &DaemonState, params: PeerRemoveParams) -> Result<()> {
let mesh = state.mesh_required()?;
let nickname = params.nickname;
let revoked = revoke_service_access(mesh, &nickname).await?;
if let Some(provider) = mesh.app_blobs().await {
let store = mesh.store.clone();
let nick_r = nickname.clone();
let principals: Vec<String> = blocking("join blob-revoke principals", move || {
let (targets, others): (Vec<_>, Vec<_>) = store
.list()?
.into_iter()
.partition(|e| e.nickname == nick_r);
let mut principals = Vec::new();
for t in &targets {
principals.push(mcpmesh_net::EndpointId::from_bytes(t.endpoint_id).principal());
if let Some(uid) = &t.user_id
&& !others.iter().any(|o| o.user_id.as_deref() == Some(uid))
&& !principals.contains(uid)
{
principals.push(uid.clone());
}
}
anyhow::Ok(principals)
})
.await??;
if !principals.is_empty()
&& let Err(e) = provider.revoke_principals(&principals)
{
tracing::warn!(%e, "blob-scope revoke on unpair failed");
}
}
let store = mesh.store.clone();
let nickname_w = nickname.clone();
let removed = blocking("join peer remove", move || store.remove(&nickname_w)).await??;
if !revoked && !removed {
anyhow::bail!("no paired peer named '{nickname}' — 'mcpmesh status' lists your peers");
}
tracing::info!(peer = %nickname, "unpaired peer");
mesh.audit().record(AuditRecord::trust(
now_ts(),
"unpair".into(),
Some(nickname.clone()),
None,
));
Ok(())
}
struct RenamePlan {
targets: Vec<PeerEntry>,
}
fn rename_plan(
store: &PeerStore,
user_id: Option<&str>,
nickname: Option<&str>,
to: &str,
) -> Result<Option<RenamePlan>> {
let all = store.list()?;
let targets: Vec<PeerEntry> = all
.iter()
.filter(|e| match user_id {
Some(u) => e.user_id.as_deref() == Some(u),
None => Some(e.nickname.as_str()) == nickname,
})
.cloned()
.collect();
if targets.is_empty() {
anyhow::bail!("peer_rename: no matching contact");
}
if targets.iter().all(|e| e.nickname == to) {
return Ok(None); }
let target_ids: std::collections::BTreeSet<[u8; 32]> =
targets.iter().map(|e| e.endpoint_id).collect();
if all
.iter()
.any(|e| e.nickname == to && !target_ids.contains(&e.endpoint_id))
{
anyhow::bail!("the nickname \"{to}\" is already used by another contact");
}
Ok(Some(RenamePlan { targets }))
}
pub async fn rename_peer(state: &DaemonState, params: PeerRenameParams) -> Result<()> {
let mesh = state.mesh_required()?;
let to = params.to.trim().to_string();
if to.is_empty() {
anyhow::bail!("peer_rename: the new nickname is empty");
}
let PeerRenameParams {
user_id, nickname, ..
} = params;
if user_id.is_none() && nickname.is_none() {
anyhow::bail!("peer_rename: no contact identified");
}
let _reload = mesh.reload_lock.lock().await;
let store = mesh.store.clone();
let (uid_c, pn_c, to_c) = (user_id.clone(), nickname.clone(), to.clone());
let plan = blocking("join rename plan", move || {
rename_plan(&store, uid_c.as_deref(), pn_c.as_deref(), &to_c)
})
.await??;
let RenamePlan { targets } = match plan {
Some(p) => p,
None => return Ok(()), };
let store = mesh.store.clone();
let to_c = to.clone();
blocking("join rename mutate", move || {
for mut e in targets {
e.nickname = to_c.clone();
store.add(e)?;
}
anyhow::Ok(())
})
.await??;
tracing::info!(to = %to, "renamed contact");
Ok(())
}
fn unregistered_service_error(requested: &[String], served: &[String]) -> Option<String> {
let unknown: Vec<&String> = requested.iter().filter(|r| !served.contains(r)).collect();
let quoted: Vec<String> = unknown.iter().map(|n| format!("'{n}'")).collect();
let named = match quoted.as_slice() {
[] => return None,
[one] => format!("no service named {one}"),
many => format!("no services named {}", many.join(", ")),
};
Some(if served.is_empty() {
format!(
"{named} — nothing is served yet; register one with \
'mcpmesh serve <name> -- <command>'"
)
} else {
format!(
"{named} — you serve: {} (see 'mcpmesh status')",
served.join(", ")
)
})
}
pub(crate) async fn mint_invite(
services: Vec<String>,
app_label: Option<String>,
mesh: &MeshState,
) -> Result<InviteResult> {
use rand::RngCore;
if let Some(label) = &app_label
&& label.len() > crate::pairing::MAX_APP_LABEL_LEN
{
anyhow::bail!(
"app_label is {} bytes; the maximum is {}",
label.len(),
crate::pairing::MAX_APP_LABEL_LEN
);
}
if services.is_empty() {
anyhow::bail!(
"invite must name at least one registered service (an invite granting nothing is useless)"
);
}
let cfg = Config::load(&mesh.config_path)
.map_err(|e| anyhow::anyhow!("config error in {}: {e}", mesh.config_path.display()))?;
let ephemeral = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned")
.clone();
let served: Vec<String> = crate::daemon::known_service_names(&cfg, &ephemeral);
if let Some(msg) = unregistered_service_error(&services, &served) {
anyhow::bail!(msg);
}
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
let inviter_id = *mesh.endpoint.id().as_bytes();
let _ = tokio::time::timeout(RELAY_READY_TIMEOUT, mesh.endpoint.online()).await;
let inviter_addr_json = serde_json::to_string(&mesh.endpoint.addr())
.context("serialize our own endpoint address for the invite")?;
let now = epoch_now_u64();
let expires_at_epoch = now + INVITE_TTL.as_secs();
let invite = Invite {
secret,
inviter_id,
inviter_addr_json,
nickname: mesh.self_nickname(),
services: services.clone(),
expires_at_epoch,
app_label,
};
let invite_line = invite.encode();
mesh.invites.remove_expired(now).await;
mesh.invites.mint(invite).await.context(
"persist the outstanding invite (its advertised TTL depends on surviving a restart)",
)?;
tracing::info!(?services, "invite minted");
Ok(InviteResult {
invite_line,
expires_at_epoch,
})
}
pub(crate) async fn redeem(state: &DaemonState, invite_line: String) -> Result<PairResult> {
let mesh = state.mesh_required()?;
let grant_mesh = mesh.clone();
let grant_back: crate::pairing::rendezvous::GrantBackFn =
Box::new(move |principal, display| {
let mesh = grant_mesh.clone();
Box::pin(async move {
let served: Vec<String> = match Config::load(&mesh.config_path) {
Ok(cfg) => cfg.services.keys().cloned().collect(),
Err(e) => {
tracing::warn!(%e, "mutual grant-back skipped: config unreadable");
return Ok(());
}
};
if served.is_empty() {
return Ok(()); }
if let Err(e) = grant_service_access(&mesh, &principal, &display, &served).await {
tracing::warn!(%e, "mutual grant-back failed (pairing still succeeded)");
}
Ok(())
})
});
crate::pairing::rendezvous::redeem_invite(
mesh.endpoint.clone(),
mesh.self_nickname(),
invite_line,
mesh.store.clone(),
mesh.self_binding(),
Some(grant_back),
)
.await
}
pub(crate) async fn peer_services(
state: &DaemonState,
peer: String,
) -> Result<mcpmesh_local_api::PeerServicesResult> {
let mesh = state.mesh_required()?;
let endpoint_id = resolve_peer_endpoint(mesh, &peer).await?;
let entry = crate::daemon::reach::probe_peer_cached(mesh, endpoint_id).await;
anyhow::ensure!(
entry.reachable,
"peer '{peer}' is unreachable — cannot fetch its shared services"
);
Ok(mcpmesh_local_api::PeerServicesResult {
services: entry.services,
})
}
pub(crate) async fn peer_diagnostics(
state: &DaemonState,
peer: &str,
) -> Result<mcpmesh_local_api::PeerDiagnosticsResult> {
let mesh = state.mesh_required()?;
let endpoint_id = resolve_peer_endpoint(mesh, peer).await?;
let store = mesh.store.clone();
let entry = blocking("join peer-diagnostics store read", move || {
store.resolve(&endpoint_id)
})
.await??
.with_context(|| format!("peer '{peer}' is not in the allowlist"))?;
let id = iroh::EndpointId::from_bytes(&endpoint_id)
.map_err(|e| anyhow::anyhow!("stored endpoint id for '{peer}' is invalid: {e}"))?;
let dialed = crate::daemon::dial::stored_dial_addr(entry.last_addr.as_deref(), id);
let hint_addrs: Vec<String> = dialed
.addrs
.iter()
.map(|a| match a {
iroh::TransportAddr::Ip(s) => s.to_string(),
iroh::TransportAddr::Relay(u) => {
format!("relay {}", crate::daemon::sanitize_relay_url(u))
}
other => format!("{other:?}"),
})
.collect();
let hint_usable = entry.last_addr.is_some() && !dialed.addrs.is_empty();
let reachability = {
let cache = mesh
.reachability
.lock()
.expect("reachability lock not poisoned");
cache.get(&endpoint_id).map(|e| {
let age = (crate::util::epoch_now_i64() - e.probed_at).max(0);
crate::daemon::reach::reachability_row(
entry.nickname.clone(),
endpoint_id,
Some(e),
Some(age as u64),
)
})
};
Ok(mcpmesh_local_api::PeerDiagnosticsResult {
nickname: entry.nickname,
principal: mcpmesh_net::EndpointId::from_bytes(entry.endpoint_id).principal(),
user_id: entry.user_id,
paired_at: entry.paired_at,
last_addr: entry.last_addr,
hint_addrs,
hint_usable,
reachability,
})
}
async fn resolve_peer_endpoint(mesh: &Arc<MeshState>, peer: &str) -> Result<[u8; 32]> {
if let Some(hex) = peer.strip_prefix("eid:") {
let bytes = data_encoding::HEXLOWER
.decode(hex.as_bytes())
.map_err(|_| anyhow::anyhow!("invalid eid principal: not lowercase hex"))?;
return bytes
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("invalid eid principal: expected 32 bytes"));
}
let store = mesh.store.clone();
let peer_owned = peer.to_string();
let eid = tokio::task::spawn_blocking(move || -> Result<Option<[u8; 32]>> {
if let Some(e) = store.entry_for(&peer_owned)? {
return Ok(Some(e.endpoint_id));
}
Ok(store
.entries_for_user(&peer_owned)?
.first()
.map(|e| e.endpoint_id))
})
.await
.context("join peer resolve for peer_services")??;
eid.with_context(|| format!("no paired peer '{peer}' — 'mcpmesh status' lists your peers"))
}
pub(crate) async fn unregister_service(state: &DaemonState, name: String) -> Result<()> {
let mesh = state.mesh_required()?;
let _reload = mesh.reload_lock.lock().await;
let dropped_ephemeral = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned")
.remove(&name)
.is_some();
let config_path = mesh.config_path.clone();
let name_w = name.clone();
let removed_config = blocking("join unregister config write", move || {
remove_service_from_config(&config_path, &name_w)
})
.await??;
if dropped_ephemeral || removed_config {
reload_services_from_disk(mesh, "unregister").await?;
}
tracing::info!(service = %name, dropped_ephemeral, removed_config, "unregistered service");
Ok(())
}
pub(crate) async fn set_relays(
state: &DaemonState,
relay_urls: Vec<String>,
) -> Result<SetRelaysResult> {
let mesh = state.mesh_required()?;
anyhow::ensure!(
!relay_urls.is_empty(),
"set_relays: relay_urls is empty (custom mode requires at least one relay; \
disable relays via a relay_mode=\"disabled\" restart)"
);
let parsed: Vec<iroh::RelayUrl> = relay_urls
.iter()
.map(|u| {
u.parse::<iroh::RelayUrl>()
.map_err(|e| anyhow::anyhow!("set_relays: relay url {u:?}: {e}"))
})
.collect::<Result<_>>()?;
let _reload = mesh.reload_lock.lock().await;
let posture = mesh.applied_relays();
let restart_required = posture.mode != "custom";
let desired_norm: Vec<String> = parsed.iter().map(|r| r.to_string()).collect();
let current_norm: Vec<String> = posture
.urls
.iter()
.filter_map(|u| u.parse::<iroh::RelayUrl>().ok().map(|r| r.to_string()))
.collect();
let desired_set: BTreeSet<&str> = desired_norm.iter().map(String::as_str).collect();
let current_set: BTreeSet<&str> = current_norm.iter().map(String::as_str).collect();
if current_set == desired_set {
return Ok(SetRelaysResult {
changed: false,
restart_required,
});
}
let config_path = mesh.config_path.clone();
let persisted = desired_norm.clone();
blocking("set_relays config write", move || {
write_relays(&config_path, &persisted)
})
.await??;
if !restart_required {
for (ru, norm) in parsed.iter().zip(desired_norm.iter()) {
if !current_set.contains(norm.as_str()) {
mesh.endpoint
.insert_relay(ru.clone(), Arc::new(iroh::RelayConfig::from(ru.clone())))
.await;
}
}
for norm in ¤t_norm {
if !desired_set.contains(norm.as_str())
&& let Ok(ru) = norm.parse::<iroh::RelayUrl>()
{
mesh.endpoint.remove_relay(&ru).await;
}
}
}
let new_mode = if restart_required {
&posture.mode
} else {
"custom"
};
mesh.set_applied_relays(new_mode, &desired_norm);
tracing::info!(
count = desired_norm.len(),
restart_required,
"set custom relay set"
);
Ok(SetRelaysResult {
changed: true,
restart_required,
})
}
pub async fn grant_service_access(
mesh: &Arc<MeshState>,
principal: &str,
display_nickname: &str,
services: &[String],
) -> Result<()> {
let _reload = mesh.reload_lock.lock().await;
let config_path = mesh.config_path.clone();
let principal_w = principal.to_string();
let config_services = services.to_vec();
let known_ephemeral: HashSet<String> = {
let map = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned");
services
.iter()
.filter(|s| map.contains_key(*s))
.cloned()
.collect()
};
let changed = blocking("join grant config write", move || {
append_allow_to_config(
&config_path,
&principal_w,
&config_services,
&known_ephemeral,
)
})
.await??;
let mut changed = changed;
for svc in services {
if let Some(moved) = mesh.grant_ephemeral(svc, principal) {
changed |= moved;
}
}
if changed {
reload_services_from_disk(mesh, "grant").await?;
}
tracing::info!(peer = %display_nickname, ?services, changed, "granted service access");
mesh.audit().record(AuditRecord::trust(
now_ts(),
"pair".into(),
Some(display_nickname.to_string()),
Some(principal.to_string()),
));
Ok(())
}
async fn apply_ephemeral_allow(mesh: &Arc<MeshState>, service: &str, why: &str) -> Result<()> {
let allow = {
let map = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned");
map.get(service).map(|e| e.allow.clone())
};
let updated = allow.and_then(|allow| mesh.services.get().with_allow_replaced(service, allow));
match updated {
Some(services) => {
swap_services(mesh, services);
Ok(())
}
None => reload_services_from_disk(mesh, why).await,
}
}
async fn service_servable_in_config(mesh: &Arc<MeshState>, service: &str) -> Result<bool> {
let config_path = mesh.config_path.clone();
let service = service.to_string();
blocking("join service-exists config read", move || {
let cfg = Config::load(&config_path)
.map_err(|e| anyhow::anyhow!("config error in {}: {e}", config_path.display()))?;
Ok(cfg
.services
.get(&service)
.is_some_and(|svc| svc.backend_result().is_ok()))
})
.await?
}
#[derive(Debug)]
pub struct NoSuchService(pub String);
impl std::fmt::Display for NoSuchService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"no service named '{}' that this daemon can serve — register it first \
('mcpmesh serve', or register_service), or check the config entry names exactly one \
of `run` / `socket`",
self.0
)
}
}
impl std::error::Error for NoSuchService {}
pub(crate) async fn service_allow_grant(
state: &DaemonState,
service: String,
principal: String,
) -> Result<()> {
grant_service_allow(state.mesh_required()?, service, principal).await
}
pub async fn grant_service_allow(
mesh: &Arc<MeshState>,
service: String,
principal: String,
) -> Result<()> {
let _reload = mesh.reload_lock.lock().await;
let is_ephemeral = {
let map = mesh
.ephemeral_services
.lock()
.expect("ephemeral_services lock not poisoned");
map.contains_key(&service)
};
if !is_ephemeral && !service_servable_in_config(mesh, &service).await? {
anyhow::bail!(NoSuchService(service));
}
let config_path = mesh.config_path.clone();
let (principal_w, services_w) = (principal.clone(), vec![service.clone()]);
let known_ephemeral: HashSet<String> = if is_ephemeral {
std::iter::once(service.clone()).collect()
} else {
HashSet::new()
};
let config_moved = blocking("join service-allow grant config write", move || {
append_allow_to_config(&config_path, &principal_w, &services_w, &known_ephemeral)
})
.await??;
let ephemeral_moved = mesh.grant_ephemeral(&service, &principal).unwrap_or(false);
if config_moved {
reload_services_from_disk(mesh, "service-allow-grant").await?;
} else if ephemeral_moved {
apply_ephemeral_allow(mesh, &service, "service-allow-grant").await?;
}
let changed = config_moved || ephemeral_moved;
tracing::info!(%service, %principal, changed, "service allow granted");
Ok(())
}
pub(crate) async fn service_allow_revoke(
state: &DaemonState,
service: String,
principal: String,
) -> Result<()> {
revoke_service_allow(state.mesh_required()?, service, principal).await
}
pub async fn revoke_service_allow(
mesh: &Arc<MeshState>,
service: String,
principal: String,
) -> Result<()> {
let _reload = mesh.reload_lock.lock().await;
let ephemeral_moved = mesh.revoke_ephemeral(&service, &principal);
let config_path = mesh.config_path.clone();
let (svc_w, principal_w) = (service.clone(), principal.clone());
let config_moved = blocking("join service-allow revoke config write", move || {
remove_principal_from_service(&config_path, &svc_w, &principal_w)
})
.await??;
if ephemeral_moved.is_none()
&& !config_moved
&& !service_servable_in_config(mesh, &service).await?
{
anyhow::bail!(NoSuchService(service));
}
let changed = config_moved || ephemeral_moved.unwrap_or(false);
let severed = if changed {
if config_moved {
reload_services_from_disk(mesh, "service-allow-revoke").await?;
} else {
apply_ephemeral_allow(mesh, &service, "service-allow-revoke").await?;
}
sever_principal(mesh, &principal).await?
} else {
0
};
tracing::info!(%service, %principal, changed, severed, "service allow revoked");
Ok(())
}
async fn sever_principal(mesh: &Arc<MeshState>, principal: &str) -> Result<usize> {
sever_principals(mesh, std::slice::from_ref(&principal.to_string())).await
}
async fn sever_principals(mesh: &Arc<MeshState>, principals: &[String]) -> Result<usize> {
let observer = mesh
.sever_observer
.lock()
.expect("sever observer lock not poisoned")
.clone();
if let Some(observe) = observer {
observe(&mesh.services.get());
}
let store = mesh.store.clone();
let roster = mesh.roster.view();
let principals_w = principals.to_vec();
let targets = blocking("join sever principal resolution", move || {
let mut all = std::collections::HashSet::new();
for principal in &principals_w {
all.extend(crate::daemon::sever::endpoints_for_principal(
&store,
roster.as_deref(),
principal,
)?);
}
anyhow::Ok(all)
})
.await??;
if targets.is_empty() {
return Ok(0);
}
Ok(mesh.conn_registry.sever_matching(
mcpmesh_net::CLOSE_UNAUTHORIZED, b"access revoked",
|eid, _| targets.contains(eid),
))
}
pub async fn revoke_service_access(mesh: &Arc<MeshState>, nickname: &str) -> Result<bool> {
let _reload = mesh.reload_lock.lock().await;
let store = mesh.store.clone();
let nick_r = nickname.to_string();
let principals: Vec<String> = blocking("join revoke principal resolution", move || {
let (targets, others): (Vec<_>, Vec<_>) = store
.list()?
.into_iter()
.partition(|e| e.nickname == nick_r);
let mut principals = Vec::new();
for target in &targets {
principals.push(mcpmesh_net::EndpointId::from_bytes(target.endpoint_id).principal());
if let Some(user_id) = &target.user_id {
let shared_elsewhere = others.iter().any(|o| o.user_id.as_deref() == Some(user_id));
if !shared_elsewhere && !principals.contains(user_id) {
principals.push(user_id.clone());
}
}
}
anyhow::Ok(principals)
})
.await??;
if principals.is_empty() {
tracing::info!(peer = %nickname, changed = false, "revoked service access");
return Ok(false);
}
let config_path = mesh.config_path.clone();
let principals_w = principals.clone(); let changed = blocking("join revoke config write", move || {
remove_allow_from_config(&config_path, &principals_w)
})
.await??;
if changed {
reload_services_from_disk(mesh, "revoke").await?;
}
let severed = sever_principals(mesh, &principals).await?;
tracing::info!(peer = %nickname, changed, severed, "revoked service access");
Ok(changed)
}
pub(crate) async fn open_session<CR, CW>(
state: &DaemonState,
peer: &str,
service: &str,
control_reader: FrameReader<CR>,
mut control_writer: CW,
) -> Result<()>
where
CR: AsyncRead + Unpin + Send,
CW: AsyncWrite + Unpin + Send,
{
let Some(mesh) = state.mesh() else {
let _ = write_frame(
&mut control_writer,
&synthesized(Value::Null, ERR_UNREACHABLE, "daemon has no mesh"),
)
.await;
return Ok(());
};
let transport = match dial_service(mesh, peer, service).await {
Ok(t) => t,
Err(e) => {
mesh.audit().record(
AuditRecord::session_open(
now_ts(),
Some(peer.to_string()),
service.to_string(),
None,
)
.with_status("error"),
);
tracing::warn!(peer, service, %e, "open_session dial failed; answering -32055");
let _ = write_frame(
&mut control_writer,
&synthesized(Value::Null, ERR_UNREACHABLE, "peer unreachable"),
)
.await;
return Ok(());
}
};
pipe_session(transport, service, control_reader, control_writer).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::testutil::hermetic_mesh;
#[tokio::test(flavor = "multi_thread")]
async fn unregister_service_removes_the_entry_idempotently() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"eid:beef\"]\n [services.notes]\nsocket = \"/run/notes.sock\"\nallow = []\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path.clone()).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let has = |name: &str| {
crate::config::Config::load(&config_path)
.unwrap()
.services
.contains_key(name)
};
assert!(has("kb") && has("notes"));
unregister_service(&state, "kb".into()).await.unwrap();
assert!(!has("kb"), "kb removed");
assert!(has("notes"), "other services untouched");
unregister_service(&state, "kb".into()).await.unwrap();
unregister_service(&state, "ghost".into()).await.unwrap();
assert!(has("notes"));
}
#[tokio::test(flavor = "multi_thread")]
async fn peer_diagnostics_reports_the_hint_the_dial_would_actually_use() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "").unwrap();
let mesh = hermetic_mesh(config_path).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let key = iroh::SecretKey::from_bytes(&[3u8; 32]);
let eid = *key.public().as_bytes();
let other = *iroh::SecretKey::from_bytes(&[4u8; 32]).public().as_bytes();
let seed = |last_addr: Option<String>| {
mesh.store
.add(crate::allowlist::PeerEntry {
endpoint_id: eid,
nickname: "jetson".into(),
services: vec![],
paired_at: Some("1753000000".into()),
user_id: None,
last_addr,
})
.unwrap();
};
seed(None);
let d = peer_diagnostics(&state, "jetson").await.unwrap();
assert_eq!(d.nickname, "jetson");
assert!(d.principal.starts_with("eid:"), "{}", d.principal);
assert_eq!(d.last_addr, None);
assert!(!d.hint_usable, "no hint cannot be a usable hint");
assert!(d.hint_addrs.is_empty());
assert_eq!(d.paired_at.as_deref(), Some("1753000000"));
let good = serde_json::to_string(&iroh::EndpointAddr::from_parts(
key.public(),
[iroh::TransportAddr::Ip(
"192.168.1.50:4433".parse().unwrap(),
)],
))
.unwrap();
seed(Some(good.clone()));
let d = peer_diagnostics(&state, "jetson").await.unwrap();
assert_eq!(d.last_addr.as_deref(), Some(good.as_str()));
assert!(d.hint_usable, "a well-formed hint for THIS peer is usable");
assert_eq!(d.hint_addrs, vec!["192.168.1.50:4433".to_string()]);
let mismatched = serde_json::to_string(&iroh::EndpointAddr::from_parts(
iroh::EndpointId::from_bytes(&other).unwrap(),
[iroh::TransportAddr::Ip(
"192.168.1.99:4433".parse().unwrap(),
)],
))
.unwrap();
seed(Some(mismatched.clone()));
let d = peer_diagnostics(&state, "jetson").await.unwrap();
assert_eq!(
d.last_addr.as_deref(),
Some(mismatched.as_str()),
"the stored value is reported verbatim — that is the evidence"
);
assert!(
!d.hint_usable,
"a hint for a different endpoint is discarded at every dial; saying otherwise sends \
the reader after an address the node never uses"
);
assert!(
d.hint_addrs.is_empty(),
"and its addresses are not this peer's"
);
seed(Some("not json at all".into()));
let d = peer_diagnostics(&state, "jetson").await.unwrap();
assert!(!d.hint_usable);
assert_eq!(d.last_addr.as_deref(), Some("not json at all"));
let relay_only = serde_json::to_string(&iroh::EndpointAddr::from_parts(
key.public(),
[iroh::TransportAddr::Relay(
"https://user:token@relay.example/".parse().unwrap(),
)],
))
.unwrap();
seed(Some(relay_only));
let d = peer_diagnostics(&state, "jetson").await.unwrap();
assert_eq!(
d.hint_addrs.len(),
1,
"the relay hint must be REPORTED: {d:?}"
);
assert!(d.hint_addrs[0].starts_with("relay "), "{:?}", d.hint_addrs);
assert!(
!d.hint_addrs[0].contains("token"),
"a relay URL's userinfo must be SANITIZED — this output is meant to be pasted into an \
issue, and every other surface sanitizes it: {:?}",
d.hint_addrs
);
}
#[tokio::test(flavor = "multi_thread")]
async fn minting_an_invite_writes_it_to_the_configured_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.notes]
socket = \"/run/notes.sock\"
allow = []
",
)
.unwrap();
let invites_path = dir.path().join("invites.json");
let mesh = crate::daemon::testutil::hermetic_mesh_with_invites(
config_path,
Arc::new(crate::pairing::LiveInvites::load(
invites_path.clone(),
crate::util::epoch_now_u64(),
)),
)
.await;
let res = mint_invite(vec!["notes".into()], None, &mesh)
.await
.expect("mint");
assert!(res.invite_line.starts_with("mcpmesh-invite:"));
let on_disk = crate::pairing::persist::InviteFile::new(&invites_path).load(0);
assert_eq!(
on_disk.len(),
1,
"the `invite` verb must leave the invite ON DISK — otherwise the 24h TTL it just \
advertised is a promise the next restart breaks (#87b)"
);
assert_eq!(
on_disk[0].expires_at_epoch, res.expires_at_epoch,
"and it must be THE invite that was handed out"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn peer_diagnostics_never_dials() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "").unwrap();
let mesh = hermetic_mesh(config_path).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
for (i, nick) in [(21u8, "jetson"), (22u8, "studio")] {
mesh.store
.add(crate::allowlist::PeerEntry {
endpoint_id: *iroh::SecretKey::from_bytes(&[i; 32]).public().as_bytes(),
nickname: nick.into(),
services: vec![],
paired_at: None,
user_id: None,
last_addr: None,
})
.unwrap();
}
let before = mesh.probe_seq_for_test();
let d = peer_diagnostics(&state, "jetson").await.unwrap();
assert_eq!(
mesh.probe_seq_for_test(),
before,
"peer_diagnostics took a probe ticket — it is dialing, and the peer it is meant to be \
observing is now being perturbed by the act of observing it"
);
assert_eq!(
d.reachability, None,
"never probed is NOT unreachable — reporting a fabricated `reachable: false` row on a \
fresh daemon would read as a real verdict in a capture"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn peer_diagnostics_joins_the_live_row_by_id_not_nickname() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "").unwrap();
let mesh = hermetic_mesh(config_path).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let a = *iroh::SecretKey::from_bytes(&[31u8; 32]).public().as_bytes();
let b = *iroh::SecretKey::from_bytes(&[32u8; 32]).public().as_bytes();
for eid in [a, b] {
mesh.store
.add(crate::allowlist::PeerEntry {
endpoint_id: eid,
nickname: "jetson".into(), services: vec![],
paired_at: None,
user_id: None,
last_addr: None,
})
.unwrap();
}
mesh.reachability.lock().unwrap().insert(
b,
crate::daemon::ReachEntry {
reachable: true,
rtt_ms: Some(9),
probed_at: crate::util::epoch_now_i64(),
meta: String::new(),
services: Vec::new(),
seq: 1,
path: mcpmesh_local_api::PeerPath::Direct,
},
);
let d = peer_diagnostics(&state, &mcpmesh_net::EndpointId::from_bytes(a).principal())
.await
.unwrap();
assert_eq!(
d.reachability, None,
"peer A has no live row of its OWN; borrowing its namesake's would report a direct, \
9ms link for a peer that has never been probed"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_exact_literal_revokes_a_bare_allow_entry() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"legacy-nickname\", \"eid:beef\", \"ops-team\"]\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path.clone()).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let allow = || {
crate::config::Config::load(&config_path)
.unwrap()
.services
.get("kb")
.unwrap()
.allow
.clone()
};
service_allow_revoke(&state, "kb".into(), "legacy-nickname".into())
.await
.expect("a bare entry is a valid revoke target");
assert_eq!(
allow(),
vec!["eid:beef".to_string(), "ops-team".to_string()],
"the exact literal is stripped and nothing else is"
);
service_allow_revoke(&state, "kb".into(), "legacy-nickname".into())
.await
.expect("revoking an absent entry is a no-op");
assert_eq!(
allow(),
vec!["eid:beef".to_string(), "ops-team".to_string()]
);
mesh.register_ephemeral(
"tmp".to_string(),
crate::daemon::EphemeralService {
backend: mcpmesh_local_api::BackendSpec::Socket {
path: "/run/tmp.sock".into(),
},
allow: vec!["legacy-nickname".to_string(), "eid:beef".to_string()],
},
);
service_allow_revoke(&state, "tmp".into(), "legacy-nickname".into())
.await
.expect("a bare entry in an ephemeral allow is a valid target");
assert_eq!(
mesh.ephemeral_services
.lock()
.unwrap()
.get("tmp")
.unwrap()
.allow,
vec!["eid:beef".to_string()],
"the ephemeral allow lost the bare entry and kept the principal"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_exact_b64u_revoke_has_no_multi_device_protection() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"b64u:alice\"]\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path.clone()).await;
for (i, nick) in [(7u8, "alice-laptop"), (8u8, "alice-phone")] {
mesh.store
.add(crate::allowlist::PeerEntry {
endpoint_id: [i; 32],
nickname: nick.into(),
services: vec![],
paired_at: None,
user_id: Some("b64u:alice".into()),
last_addr: None,
})
.unwrap();
}
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let allow = || {
crate::config::Config::load(&config_path)
.unwrap()
.services
.get("kb")
.unwrap()
.allow
.clone()
};
revoke_service_access(&mesh, "alice-laptop").await.unwrap();
assert_eq!(
allow(),
vec!["b64u:alice".to_string()],
"unpairing one device must never revoke the PERSON — the guard this verb lacks"
);
service_allow_revoke(&state, "kb".into(), "b64u:alice".into())
.await
.unwrap();
assert!(
allow().is_empty(),
"an exact literal revoke is exactly as literal as it sounds, shared or not"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn service_allow_grant_and_revoke_toggle_one_principal() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = []\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path.clone()).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let allow = || {
crate::config::Config::load(&config_path)
.unwrap()
.services
.get("kb")
.unwrap()
.allow
.clone()
};
service_allow_grant(&state, "kb".into(), "eid:beef".into())
.await
.unwrap();
assert_eq!(allow(), vec!["eid:beef".to_string()]);
service_allow_grant(&state, "kb".into(), "eid:beef".into())
.await
.unwrap();
assert_eq!(allow(), vec!["eid:beef".to_string()]);
service_allow_revoke(&state, "kb".into(), "eid:beef".into())
.await
.unwrap();
assert!(allow().is_empty());
service_allow_revoke(&state, "kb".into(), "eid:beef".into())
.await
.unwrap();
assert!(allow().is_empty());
let grant_err = service_allow_grant(&state, "ghost".into(), "eid:beef".into())
.await
.expect_err("an unknown service must not report success");
assert!(
grant_err.downcast_ref::<NoSuchService>().is_some(),
"the grant error must be branchable as NoSuchService, got: {grant_err}"
);
let revoke_err = service_allow_revoke(&state, "ghost".into(), "eid:beef".into())
.await
.expect_err("an unknown service must not report success");
assert!(
revoke_err.downcast_ref::<NoSuchService>().is_some(),
"the revoke error must be branchable as NoSuchService, got: {revoke_err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_unknown_service_answers_the_no_such_service_code_on_the_wire() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = []\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path).await;
let state = crate::control::DaemonState::with_mesh("test", mesh);
let req = |method: &str| {
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": method,
"params": {"service": "ghost", "principal": "eid:beef"}
})
};
for method in ["service_allow_grant", "service_allow_revoke"] {
let r = crate::control::handle_request(&req(method), &state).await;
assert_eq!(
r["error"]["code"],
mcpmesh_local_api::ERR_NO_SUCH_SERVICE,
"{method} must answer -32040 for an unknown service, got: {r}"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn revoking_a_shadowed_name_strips_the_config_allow_too() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.room]\nsocket = \"/run/room.sock\"\nallow = [\"eid:beef\"]\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path.clone()).await;
mesh.register_ephemeral(
"room".to_string(),
crate::daemon::EphemeralService {
backend: mcpmesh_local_api::BackendSpec::Socket {
path: "/run/room.sock".into(),
},
allow: vec!["eid:beef".to_string()],
},
);
revoke_service_allow(&mesh, "room".into(), "eid:beef".into())
.await
.unwrap();
assert!(
mesh.ephemeral_services
.lock()
.unwrap()
.get("room")
.unwrap()
.allow
.is_empty(),
"the ephemeral allow is stripped"
);
assert!(
crate::config::Config::load(&config_path)
.unwrap()
.services
.get("room")
.unwrap()
.allow
.is_empty(),
"the SHADOWED config allow must be stripped too — otherwise it goes live with a \
revoked principal the moment the ephemeral registration is dropped"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_config_write_leaves_the_ephemeral_allow_untouched() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = \"not-an-array\"\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path).await;
mesh.register_ephemeral(
"room".to_string(),
crate::daemon::EphemeralService {
backend: mcpmesh_local_api::BackendSpec::Socket {
path: "/run/room.sock".into(),
},
allow: vec![],
},
);
let r = grant_service_access(
&mesh,
"eid:beef",
"eid:beef",
&["room".to_string(), "kb".to_string()],
)
.await;
assert!(r.is_err(), "the malformed config must fail the grant");
assert!(
mesh.ephemeral_services
.lock()
.unwrap()
.get("room")
.unwrap()
.allow
.is_empty(),
"a FAILED grant must not have applied the in-memory half"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn allow_verbs_mutate_an_ephemeral_registration() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = []\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path).await;
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
mesh.register_ephemeral(
"room".to_string(),
crate::daemon::EphemeralService {
backend: mcpmesh_local_api::BackendSpec::Socket {
path: "/run/room.sock".into(),
},
allow: vec![],
},
);
let allow = || {
mesh.ephemeral_services
.lock()
.unwrap()
.get("room")
.unwrap()
.allow
.clone()
};
service_allow_grant(&state, "room".into(), "eid:beef".into())
.await
.unwrap();
assert_eq!(allow(), vec!["eid:beef".to_string()], "granted in memory");
service_allow_grant(&state, "room".into(), "eid:beef".into())
.await
.unwrap();
assert_eq!(allow(), vec!["eid:beef".to_string()], "grant is idempotent");
service_allow_revoke(&state, "room".into(), "eid:beef".into())
.await
.unwrap();
assert!(allow().is_empty(), "revoked in memory");
service_allow_revoke(&state, "room".into(), "eid:beef".into())
.await
.unwrap();
assert!(allow().is_empty(), "revoke is idempotent");
assert!(
crate::config::Config::load(&mesh.config_path)
.unwrap()
.services
.get("kb")
.unwrap()
.allow
.is_empty(),
"an ephemeral grant must not write the config"
);
}
#[test]
fn unregistered_service_error_message_shapes() {
let s = |names: &[&str]| -> Vec<String> { names.iter().map(|n| n.to_string()).collect() };
assert_eq!(
unregistered_service_error(&s(&["notes"]), &s(&["notes", "kb"])),
None
);
assert_eq!(
unregistered_service_error(&s(&["nosuchsvc"]), &s(&["notes", "code"])).unwrap(),
"no service named 'nosuchsvc' — you serve: notes, code (see 'mcpmesh status')"
);
assert_eq!(
unregistered_service_error(&s(&["a", "notes", "b"]), &s(&["notes"])).unwrap(),
"no services named 'a', 'b' — you serve: notes (see 'mcpmesh status')"
);
assert_eq!(
unregistered_service_error(&s(&["nosuchsvc"]), &[]).unwrap(),
"no service named 'nosuchsvc' — nothing is served yet; register one with \
'mcpmesh serve <name> -- <command>'"
);
}
#[tokio::test]
async fn blob_ops_error_without_a_mesh() {
let st = DaemonState::new("test");
assert!(blob_list(&st, Default::default()).await.is_err());
assert!(
blob_publish(&st, "scope".into(), "/tmp/x".into())
.await
.is_err()
);
assert!(blob_grant(&st, "scope".into(), "bob".into()).await.is_err());
assert!(
blob_fetch(&st, "ticket".into(), "/tmp/dst".into())
.await
.is_err()
);
}
fn rename_params(user_id: Option<&str>, to: &str) -> PeerRenameParams {
PeerRenameParams {
user_id: user_id.map(str::to_string),
nickname: None,
to: to.into(),
}
}
#[tokio::test]
async fn rename_peer_renames_all_devices_and_leaves_grants_untouched() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = [\"b64u:BOB\"]\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path.clone()).await;
mesh.store
.add(rename_entry(1, "bob-old", Some("b64u:BOB")))
.unwrap();
mesh.store
.add(rename_entry(2, "bob-old", Some("b64u:BOB")))
.unwrap();
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
let config_before = std::fs::read_to_string(&config_path).unwrap();
rename_peer(&state, rename_params(Some("b64u:BOB"), "Bobby"))
.await
.unwrap();
let names: Vec<String> = mesh
.store
.list()
.unwrap()
.into_iter()
.map(|e| e.nickname)
.collect();
assert!(
names.iter().all(|n| n == "Bobby"),
"all devices renamed, got {names:?}"
);
let config_after = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(
config_before, config_after,
"rename must not rewrite the config"
);
let doc: toml::Table = toml::from_str(&config_after).unwrap();
let allow = doc["services"]["kb"]["allow"].as_array().unwrap();
assert_eq!(allow.len(), 1);
assert_eq!(allow[0].as_str(), Some("b64u:BOB"));
}
#[tokio::test]
async fn rename_peer_guards_bad_requests_and_collisions() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[services.kb]\nsocket = \"/run/kb.sock\"\nallow = []\n",
)
.unwrap();
let mesh = hermetic_mesh(config_path).await;
mesh.store
.add(rename_entry(1, "alice", Some("b64u:ALICE")))
.unwrap();
mesh.store
.add(rename_entry(2, "bob", Some("b64u:BOB")))
.unwrap();
let state = crate::control::DaemonState::with_mesh("test", mesh.clone());
assert!(
rename_peer(&state, rename_params(Some("b64u:ALICE"), " "))
.await
.is_err()
);
assert!(rename_peer(&state, rename_params(None, "X")).await.is_err());
assert!(
rename_peer(&state, rename_params(Some("b64u:NOBODY"), "X"))
.await
.is_err()
);
assert!(
rename_peer(&state, rename_params(Some("b64u:ALICE"), "bob"))
.await
.is_err()
);
let names: std::collections::BTreeSet<String> = mesh
.store
.list()
.unwrap()
.into_iter()
.map(|e| e.nickname)
.collect();
assert!(
names.contains("alice") && names.contains("bob"),
"no rename should have occurred: {names:?}"
);
}
fn rename_entry(id: u8, nickname: &str, user_id: Option<&str>) -> PeerEntry {
PeerEntry {
endpoint_id: [id; 32],
nickname: nickname.into(),
services: Vec::new(),
paired_at: None,
user_id: user_id.map(str::to_string),
last_addr: None,
}
}
#[test]
fn rename_plan_groups_by_user_id_and_guards_collisions() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("s.redb")).unwrap();
store
.add(rename_entry(1, "bob-phone", Some("b64u:BOB")))
.unwrap();
store
.add(rename_entry(2, "bob-laptop", Some("b64u:BOB")))
.unwrap();
store
.add(rename_entry(3, "carol", Some("b64u:CAROL")))
.unwrap();
let plan = rename_plan(&store, Some("b64u:BOB"), None, "Bobby")
.unwrap()
.unwrap();
assert_eq!(plan.targets.len(), 2);
assert!(rename_plan(&store, Some("b64u:BOB"), None, "carol").is_err());
store.add(rename_entry(4, "dave", None)).unwrap();
assert_eq!(
rename_plan(&store, None, Some("dave"), "Dave")
.unwrap()
.unwrap()
.targets
.len(),
1
);
assert!(
rename_plan(&store, Some("b64u:CAROL"), None, "carol")
.unwrap()
.is_none()
);
assert!(rename_plan(&store, Some("b64u:NOBODY"), None, "x").is_err());
}
}