use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use net_sdk::capabilities::{
CapabilityAnnouncement, CapabilityGroupId as GroupId, CapabilitySet,
CapabilitySubnetId as SubnetId, CapabilityTagError, Tag, MAX_ALLOW_LIST_LEN, RESERVED_PREFIXES,
};
use serde::Serialize;
use crate::context::{load_identity_keypair, resolve_profile, CliContext};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
#[derive(Subcommand, Debug)]
pub enum CapCommand {
Show(ShowArgs),
Query(QueryArgs),
Nodes(NodesArgs),
Announce(AnnounceArgs),
}
#[derive(Args, Debug)]
pub struct ShowArgs {
#[arg(long, value_name = "PEER_NODE")]
pub peer: Option<u64>,
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
}
#[derive(Args, Debug)]
pub struct QueryArgs {
#[arg(long = "tag", required = true, num_args = 1.., value_name = "TAG")]
pub tags: Vec<String>,
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
}
#[derive(Args, Debug)]
pub struct NodesArgs {
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
}
#[derive(Args, Debug)]
pub struct AnnounceArgs {
#[arg(long = "tag", required = true, num_args = 1.., value_name = "TAG")]
pub tags: Vec<String>,
#[arg(long = "allow-node", num_args = 0.., value_name = "NODE_ID")]
pub allow_nodes: Vec<String>,
#[arg(long = "allow-subnet", num_args = 0.., value_name = "SUBNET")]
pub allow_subnets: Vec<String>,
#[arg(long = "allow-group", num_args = 0.., value_name = "GROUP")]
pub allow_groups: Vec<String>,
#[arg(long, value_name = "PATH")]
pub key: PathBuf,
#[arg(long, default_value_t = 1)]
pub version: u64,
#[arg(long = "ttl-secs", default_value_t = 300)]
pub ttl_secs: u32,
#[arg(long = "node-id", value_name = "NODE_ID")]
pub node_id: Option<String>,
#[arg(long, value_name = "PATH")]
pub out: Option<PathBuf>,
}
pub async fn run(
cmd: CapCommand,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
match cmd {
CapCommand::Show(args) => run_show(args, output, config_path, profile_name).await,
CapCommand::Query(args) => run_query(args, output, config_path, profile_name).await,
CapCommand::Nodes(args) => run_nodes(args, output, config_path, profile_name).await,
CapCommand::Announce(args) => run_announce(args).await,
}
}
async fn run_show(
args: ShowArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
let profile = resolve_profile(config_path, profile_name).await?;
let ctx = CliContext::build(&profile, args.identity.as_deref(), args.node, false).await?;
let snapshot = ctx.deck().status();
let target = args.peer.unwrap_or(args.node);
let caps = snapshot
.peers
.get(&target)
.map(|p| p.capability_set.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let info = CapShow {
node: target,
capabilities: caps,
};
emit_value(OutputFormat::resolve_oneshot(output), &info)
.map_err(|e| generic(format!("write cap show: {e}")))?;
Ok(())
}
async fn run_query(
args: QueryArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
let profile = resolve_profile(config_path, profile_name).await?;
let ctx = CliContext::build(&profile, args.identity.as_deref(), args.node, false).await?;
let snapshot = ctx.deck().status();
let required: BTreeSet<String> = args.tags.into_iter().collect();
let matches: Vec<u64> = snapshot
.peers
.iter()
.filter(|(_, p)| required.iter().all(|t| p.capability_set.contains(t)))
.map(|(id, _)| *id)
.collect();
let info = CapQuery {
required: required.into_iter().collect(),
matched_nodes: matches,
};
emit_value(OutputFormat::resolve_oneshot(output), &info)
.map_err(|e| generic(format!("write cap query: {e}")))?;
Ok(())
}
async fn run_nodes(
args: NodesArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
let profile = resolve_profile(config_path, profile_name).await?;
let ctx = CliContext::build(&profile, args.identity.as_deref(), args.node, false).await?;
let snapshot = ctx.deck().status();
let rows: Vec<CapNodesRow> = snapshot
.peers
.iter()
.map(|(id, p)| CapNodesRow {
node: *id,
capabilities: p.capability_set.iter().cloned().collect(),
})
.collect();
emit_value(OutputFormat::resolve_oneshot(output), &rows)
.map_err(|e| generic(format!("write cap nodes: {e}")))?;
Ok(())
}
const ADVISORY_ALLOW_LIST_WARNING: &str = "\
warning: --allow-subnet / --allow-group do NOT admit anyone; they only narrow
routing. Membership is self-declared and this announcement publishes
the admitted values mesh-wide. A capability restricted by these axes
alone denies every caller. Use --allow-node (or org admission) for
access control.";
fn tag_rejected_message(tag: &str, err: &CapabilityTagError) -> String {
match err {
CapabilityTagError::ReservedPrefix { .. } => {
let reserved = RESERVED_PREFIXES.join("` / `");
format!(
"tag {tag:?} rejected: {err}. Reserved prefixes \
(`{reserved}`) cannot be set here; scope needs the SDK \
builders (with_tenant_scope / with_region_scope / \
with_subnet_local_scope)."
)
}
CapabilityTagError::Empty => {
format!("tag {tag:?} rejected: {err}.")
}
}
}
fn capability_set_from_tags(tags: &[String]) -> Result<CapabilitySet, CliError> {
let mut caps = CapabilitySet::new();
for tag in tags {
if let Err(e) = Tag::parse_user(tag) {
return Err(invalid_args(tag_rejected_message(tag, &e)));
}
caps = caps.add_tag(tag.clone());
}
Ok(caps)
}
async fn run_announce(args: AnnounceArgs) -> Result<(), CliError> {
let keypair = load_identity_keypair(&args.key).await?;
if args.allow_nodes.len() > MAX_ALLOW_LIST_LEN
|| args.allow_subnets.len() > MAX_ALLOW_LIST_LEN
|| args.allow_groups.len() > MAX_ALLOW_LIST_LEN
{
return Err(invalid_args(format!(
"allow-list axes are capped at {MAX_ALLOW_LIST_LEN} entries each; \
operators above that limit should use a group instead of an \
inline node enumeration (see CAPABILITY_AUTH_PLAN.md §\"What ships\")"
)));
}
let allowed_nodes = parse_node_ids(&args.allow_nodes)?;
let allowed_subnets = parse_subnets(&args.allow_subnets)?;
let allowed_groups = parse_groups(&args.allow_groups)?;
if !allowed_subnets.is_empty() || !allowed_groups.is_empty() {
eprintln!("{ADVISORY_ALLOW_LIST_WARNING}");
}
let derived = keypair.node_id();
let node_id = match args.node_id.as_deref() {
Some(s) => {
let supplied = parse_node_id(s)?;
if supplied != derived {
return Err(invalid_args(format!(
"--node-id {supplied:#x} does not match the signing key's \
derived node id {derived:#x}; receivers re-derive the \
expected NodeId from the signed entity_id and reject \
announcements with mismatched bindings. Drop the flag \
to use the derived value, or sign with the keypair that \
produces {supplied:#x}."
)));
}
supplied
}
None => derived,
};
let caps = capability_set_from_tags(&args.tags)?;
let mut ann =
CapabilityAnnouncement::new(node_id, keypair.entity_id().clone(), args.version, caps)
.with_ttl(args.ttl_secs);
ann.allowed_nodes = allowed_nodes;
ann.allowed_subnets = allowed_subnets;
ann.allowed_groups = allowed_groups;
ann.sign(&keypair);
let bytes = ann.to_bytes();
write_announcement_output(args.out.as_deref(), &bytes).await?;
Ok(())
}
fn parse_node_ids(values: &[String]) -> Result<Vec<u64>, CliError> {
values.iter().map(|v| parse_node_id(v)).collect()
}
fn parse_node_id(value: &str) -> Result<u64, CliError> {
let trimmed = value.trim();
let parsed = if let Some(hex) = trimmed
.strip_prefix("0x")
.or_else(|| trimmed.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16)
} else {
trimmed.parse::<u64>()
};
parsed.map_err(|_| {
invalid_args(format!(
"node id {value:?} must be decimal or `0x`-prefixed hex (u64)"
))
})
}
fn parse_subnets(values: &[String]) -> Result<Vec<SubnetId>, CliError> {
values
.iter()
.map(|v| {
let trimmed = v.trim();
let tag_form = if trimmed.starts_with("subnet:") {
trimmed.to_string()
} else {
format!("subnet:{trimmed}")
};
SubnetId::from_tag(&tag_form).ok_or_else(|| {
invalid_args(format!(
"subnet id {v:?} must be 32 hex characters (16 bytes), \
optionally prefixed with `subnet:`"
))
})
})
.collect()
}
fn parse_groups(values: &[String]) -> Result<Vec<GroupId>, CliError> {
values
.iter()
.map(|v| {
let trimmed = v.trim();
let tag_form = if trimmed.starts_with("group:") {
trimmed.to_string()
} else {
format!("group:{trimmed}")
};
GroupId::from_tag(&tag_form).ok_or_else(|| {
invalid_args(format!(
"group id {v:?} must be 64 hex characters (32 bytes), \
optionally prefixed with `group:`"
))
})
})
.collect()
}
async fn write_announcement_output(out: Option<&Path>, bytes: &[u8]) -> Result<(), CliError> {
match out {
Some(path) => tokio::fs::write(path, bytes)
.await
.map_err(|e| generic(format!("write {}: {e}", path.display()))),
None => {
use std::io::Write;
let mut stdout = std::io::stdout().lock();
stdout
.write_all(bytes)
.map_err(|e| generic(format!("write stdout: {e}")))?;
stdout
.write_all(b"\n")
.map_err(|e| generic(format!("write stdout: {e}")))?;
Ok(())
}
}
}
#[derive(Serialize)]
struct CapShow {
node: u64,
capabilities: Vec<String>,
}
#[derive(Serialize)]
struct CapQuery {
required: Vec<String>,
matched_nodes: Vec<u64>,
}
#[derive(Serialize)]
struct CapNodesRow {
node: u64,
capabilities: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn announce_long_flags() -> BTreeSet<String> {
let cmd = AnnounceArgs::augment_args(clap::Command::new("announce"));
cmd.get_arguments()
.filter_map(|a| a.get_long().map(str::to_string))
.collect()
}
#[test]
fn advisory_warning_names_only_real_flags() {
let real = announce_long_flags();
assert!(
real.contains("allow-node"),
"expected --allow-node on AnnounceArgs; got {real:?}"
);
let mentioned: BTreeSet<String> = ADVISORY_ALLOW_LIST_WARNING
.split_whitespace()
.filter_map(|w| w.strip_prefix("--"))
.map(|w| w.trim_end_matches(['.', ',', ';', ':']).to_string())
.filter(|w| !w.is_empty())
.collect();
assert!(
!mentioned.is_empty(),
"the warning names no flags at all — did the text change shape?"
);
for flag in &mentioned {
assert!(
real.contains(flag),
"the advisory warning tells the operator to use `--{flag}`, \
which is not a flag on `cap announce`. Real flags: {real:?}"
);
}
}
#[test]
fn advisory_warning_points_at_allow_node() {
assert!(
ADVISORY_ALLOW_LIST_WARNING.contains("--allow-node "),
"the warning must direct the operator to --allow-node"
);
}
#[test]
fn reserved_prefix_tags_are_rejected_not_dropped() {
for prefix in RESERVED_PREFIXES {
let tag = format!("{prefix}whatever");
let err = capability_set_from_tags(std::slice::from_ref(&tag))
.err()
.unwrap_or_else(|| {
panic!("`{tag}` must fail the announce build, not be dropped from it")
});
let msg = err.to_string();
assert!(
msg.contains(&tag),
"the rejection must name the offending tag; got {msg}"
);
}
}
#[test]
fn a_reserved_tag_never_reaches_the_announcement() {
let tags = vec![
"nrpc:echo".to_string(),
"scope:tenant:acme".to_string(),
"gpu".to_string(),
];
assert!(
capability_set_from_tags(&tags).is_err(),
"one reserved tag must fail the whole announcement rather than \
signing the other two without it"
);
}
#[test]
fn ordinary_and_duplicate_tags_still_build() {
let caps = capability_set_from_tags(&[
"nrpc:echo".to_string(),
"nrpc:echo".to_string(),
"gpu".to_string(),
])
.expect("legal tags must build");
let rendered: Vec<String> = caps.tags.iter().map(|t| t.to_string()).collect();
assert_eq!(
rendered.len(),
2,
"duplicates dedupe through HashSet<Tag> rather than erroring; got {rendered:?}"
);
}
#[test]
fn tag_rejection_message_lists_every_reserved_prefix() {
let err = Tag::parse_user("scope:tenant:acme").expect_err("reserved");
let msg = tag_rejected_message("scope:tenant:acme", &err);
for prefix in RESERVED_PREFIXES {
assert!(
msg.contains(prefix),
"rejection message omits the reserved prefix `{prefix}`: {msg}"
);
}
}
#[test]
fn an_empty_tag_is_not_diagnosed_as_a_reserved_prefix() {
let err = Tag::parse_user("").expect_err("empty tag must be rejected");
let msg = tag_rejected_message("", &err);
assert!(
!msg.contains("Reserved prefixes"),
"an empty tag must not be blamed on reserved prefixes: {msg}"
);
assert!(
!msg.contains("with_tenant_scope"),
"and must not point at the scope builders: {msg}"
);
assert!(
msg.contains("non-empty"),
"it should say what is actually wrong; got {msg}"
);
assert!(capability_set_from_tags(&[String::new()]).is_err());
}
}