use std::collections::BTreeSet;
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::status::service_infos;
use super::{MeshState, dial_service, pipe_session};
const INVITE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const RELAY_READY_TIMEOUT: Duration = Duration::from_secs(3);
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 (roster mode only)")?;
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 (roster mode only)")?;
provider.grant(&scope, &principal)
}
pub(crate) async fn blob_list(state: &DaemonState) -> Result<BlobScopeList> {
let mesh = state.mesh_required()?;
let scopes = match mesh.app_blobs().await {
Some(provider) => provider
.list()
.into_iter()
.map(|(name, hashes, grants)| ScopeInfo {
name,
hashes,
grants,
})
.collect(),
None => Vec::new(),
};
Ok(BlobScopeList { scopes })
}
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 (roster mode only)")?;
let hash = provider.fetch(&ticket).await.context("fetch blob")?;
let bytes = provider
.read_bytes(hash)
.await
.context("read fetched blob")?;
let bytes_len = bytes.len() as u64;
let dest = PathBuf::from(dest_path);
tokio::fs::write(&dest, &bytes)
.await
.with_context(|| format!("write fetched blob to {}", dest.display()))?;
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(())
}
pub(crate) 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()),
));
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> = service_infos(&cfg, &ephemeral, &[])
.into_iter()
.map(|s| s.name)
.collect();
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);
mesh.invites.mint(invite);
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::probe_peer(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,
})
}
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 changed = blocking("join grant config write", move || {
append_allow_to_config(&config_path, &principal_w, &config_services)
})
.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()),
));
Ok(())
}
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 mut changed = blocking("join service-allow grant config write", move || {
append_allow_to_config(&config_path, &principal_w, &services_w)
})
.await??;
if let Some(moved) = mesh.grant_ephemeral(&service, &principal) {
changed |= moved;
}
if changed {
reload_services_from_disk(mesh, "service-allow-grant").await?;
}
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 {
reload_services_from_disk(mesh, "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 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())
.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 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).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());
}
}