use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use net_sdk::subnets::SubnetId;
use serde::Serialize;
use crate::context::{resolve_profile, CliContext};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
#[derive(Subcommand, Debug)]
pub enum SubnetCommand {
Show(ShowArgs),
Ls(LsArgs),
Tree(TreeArgs),
Keygen(SubnetKeygenArgs),
IssueDirect(IssueDirectArgs),
IssueIssuer(IssueIssuerArgs),
IssueDelegated(IssueDelegatedArgs),
IssueControlFact(IssueControlFactArgs),
Inspect(InspectArgs),
}
#[derive(Args, Debug)]
pub struct ShowArgs {
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
}
#[derive(Args, Debug)]
pub struct LsArgs {
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
}
#[derive(Args, Debug)]
pub struct TreeArgs {
#[arg(long)]
pub identity: Option<PathBuf>,
#[arg(long, default_value_t = crate::prelude::DEFAULT_SUPERVISOR_NODE)]
pub node: u64,
}
pub async fn run(
cmd: SubnetCommand,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
match cmd {
SubnetCommand::Show(args) => run_show(args, output, config_path, profile_name).await,
SubnetCommand::Ls(args) => run_ls(args, output, config_path, profile_name).await,
SubnetCommand::Tree(args) => run_tree(args, output, config_path, profile_name).await,
SubnetCommand::Keygen(args) => run_subnet_keygen(args, output).await,
SubnetCommand::IssueDirect(args) => run_issue_direct(args, output).await,
SubnetCommand::IssueIssuer(args) => run_issue_issuer(args, output).await,
SubnetCommand::IssueDelegated(args) => run_issue_delegated(args, output).await,
SubnetCommand::IssueControlFact(args) => run_issue_control_fact(args, output).await,
SubnetCommand::Inspect(args) => run_inspect(args, output).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 deck = ctx.deck();
let view = ShowView {
local_subnet: deck.local_subnet().map(format_subnet),
depth: deck.local_subnet().map(|s| s.depth()),
known_peer_count: deck.known_subnets().len() as u64,
};
emit_value(OutputFormat::resolve_oneshot(output), &view)
.map_err(|e| generic(format!("write subnet show: {e}")))?;
Ok(())
}
async fn run_ls(
args: LsArgs,
output: Option<OutputFormat>,
config_path: Option<&std::path::Path>,
profile_name: &str,
) -> Result<(), CliError> {
let profile = resolve_profile(config_path, profile_name).await?;
let local_node_id = args.node;
let ctx = CliContext::build(&profile, args.identity.as_deref(), local_node_id, false).await?;
let deck = ctx.deck();
let rows: Vec<SubnetRow> = deck
.subnets_with_members(Some(local_node_id))
.into_iter()
.map(|r| SubnetRow {
subnet: format_subnet(r.subnet),
depth: r.subnet.depth(),
member_count: r.members.len() as u64,
members: r.members,
})
.collect();
emit_value(OutputFormat::resolve_oneshot(output), &rows)
.map_err(|e| generic(format!("write subnet ls: {e}")))?;
Ok(())
}
async fn run_tree(
args: TreeArgs,
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 deck = ctx.deck();
let mut all_subnets: BTreeSet<u32> = BTreeSet::new();
if let Some(local) = deck.local_subnet() {
all_subnets.insert(local.raw());
}
for (_node_id, subnet) in deck.known_subnets() {
all_subnets.insert(subnet.raw());
}
let mut closure: BTreeSet<u32> = BTreeSet::new();
for &raw in &all_subnets {
let mut cursor = SubnetId::from_raw(raw);
loop {
closure.insert(cursor.raw());
if cursor.is_global() {
break;
}
cursor = cursor.parent();
}
}
let mut nodes: Vec<SubnetId> = closure.into_iter().map(SubnetId::from_raw).collect();
nodes.sort_by_key(|s| (s.depth(), s.raw()));
let rows: Vec<TreeRow> = nodes
.into_iter()
.map(|s| TreeRow {
subnet: format_subnet(s),
depth: s.depth(),
parent: if s.is_global() {
None
} else {
Some(format_subnet(s.parent()))
},
is_local: deck.local_subnet() == Some(s),
})
.collect();
emit_value(OutputFormat::resolve_oneshot(output), &rows)
.map_err(|e| generic(format!("write subnet tree: {e}")))?;
Ok(())
}
fn format_subnet(subnet: SubnetId) -> String {
subnet.to_string()
}
#[derive(Serialize)]
struct ShowView {
local_subnet: Option<String>,
depth: Option<u8>,
known_peer_count: u64,
}
#[derive(Serialize)]
struct SubnetRow {
subnet: String,
depth: u8,
member_count: u64,
members: Vec<u64>,
}
#[derive(Serialize)]
struct TreeRow {
subnet: String,
depth: u8,
parent: Option<String>,
is_local: bool,
}
use crate::commands::identity::{
check_strict_permissions, enforce_strict_permissions, now_iso8601, parse_entity_hex,
};
use crate::commands::org::{
publish_staged, publish_staged_replace, refuse_aliased_paths, refuse_existing,
refuse_replacing_foreign_seed, stage_beside, warn_secret_permissions, SeedArtifact,
};
use crate::secret::{zeroize_slice, zeroize_string, ScrubbedBytes, ScrubbedString};
use net::adapter::net::identity::EntityKeypair;
use net::adapter::net::subnet::{
GatewayAdvertisement, SubnetAuthError, SubnetControlFact, SubnetCredentialSet,
SubnetDescriptor, SubnetExportPolicy, SubnetGrant, SubnetIssuerGrant, SubnetRef,
SubnetRevocationFloor, SubnetRights, TopologySubnetId,
};
const SUBNET_TTL_SECS_DEFAULT: u64 = 7 * 24 * 60 * 60;
const NOT_BEFORE_HEADROOM_SECS: u64 = 60;
#[derive(Args, Debug)]
pub struct SubnetKeygenArgs {
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub note: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long = "accept-windows-dacl")]
pub accept_windows_dacl: bool,
}
#[derive(Args, Debug)]
pub struct IssueDirectArgs {
#[arg(long = "root-key", value_name = "PATH")]
pub root_key: PathBuf,
#[arg(long)]
pub authority: String,
#[arg(long)]
pub subject: String,
#[arg(long)]
pub scope: String,
#[arg(long)]
pub rights: String,
#[arg(long = "topology-epoch", default_value_t = 0)]
pub topology_epoch: u32,
#[arg(long, default_value_t = 1)]
pub generation: u32,
#[arg(long = "not-before")]
pub not_before: Option<u64>,
#[arg(long = "ttl-secs", default_value_t = SUBNET_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct IssueIssuerArgs {
#[arg(long = "root-key", value_name = "PATH")]
pub root_key: PathBuf,
#[arg(long)]
pub authority: String,
#[arg(long)]
pub issuer: String,
#[arg(long)]
pub scope: String,
#[arg(long = "max-rights")]
pub max_rights: String,
#[arg(long = "topology-epoch", default_value_t = 0)]
pub topology_epoch: u32,
#[arg(long, default_value_t = 1)]
pub generation: u32,
#[arg(long = "not-before")]
pub not_before: Option<u64>,
#[arg(long = "ttl-secs", default_value_t = SUBNET_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct IssueDelegatedArgs {
#[arg(long = "issuer-grant", value_name = "PATH")]
pub issuer_grant: PathBuf,
#[arg(long = "issuer-key", value_name = "PATH")]
pub issuer_key: PathBuf,
#[arg(long)]
pub subject: String,
#[arg(long)]
pub scope: String,
#[arg(long)]
pub rights: String,
#[arg(long, default_value_t = 1)]
pub generation: u32,
#[arg(long = "not-before")]
pub not_before: Option<u64>,
#[arg(long = "ttl-secs", default_value_t = SUBNET_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct IssueControlFactArgs {
#[command(subcommand)]
pub kind: ControlFactKindCommand,
}
#[derive(Subcommand, Debug)]
pub enum ControlFactKindCommand {
Descriptor(FactDescriptorArgs),
GatewayAdvertisement(FactGatewayArgs),
ExportPolicy(FactExportPolicyArgs),
RevocationFloor(FactFloorArgs),
}
#[derive(Args, Debug)]
pub struct FactCommonArgs {
#[arg(long = "root-key", value_name = "PATH")]
pub root_key: PathBuf,
#[arg(long)]
pub authority: String,
#[arg(long)]
pub scope: String,
#[arg(long = "topology-epoch")]
pub topology_epoch: u32,
#[arg(long)]
pub revision: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct FactDescriptorArgs {
#[command(flatten)]
pub common: FactCommonArgs,
}
#[derive(Args, Debug)]
pub struct FactGatewayArgs {
#[command(flatten)]
pub common: FactCommonArgs,
#[arg(long)]
pub gateway: String,
#[arg(long = "gateway-node")]
pub gateway_node: String,
#[arg(long = "not-before")]
pub not_before: Option<u64>,
#[arg(long = "ttl-secs", default_value_t = SUBNET_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
}
#[derive(Args, Debug)]
pub struct FactExportPolicyArgs {
#[command(flatten)]
pub common: FactCommonArgs,
#[arg(long = "channel", required = true)]
pub channels: Vec<String>,
#[arg(long = "not-before")]
pub not_before: Option<u64>,
#[arg(long = "ttl-secs", default_value_t = SUBNET_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
}
#[derive(Args, Debug)]
pub struct FactFloorArgs {
#[command(flatten)]
pub common: FactCommonArgs,
#[arg(long = "minimum-generation")]
pub minimum_generation: u32,
}
#[derive(Args, Debug)]
pub struct InspectArgs {
pub file: PathBuf,
}
async fn run_subnet_keygen(
args: SubnetKeygenArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let keypair = EntityKeypair::generate();
let entity_id_hex = hex::encode(keypair.entity_id().as_bytes());
let path = match args.out {
Some(explicit) => explicit,
None => default_subnet_key_path(&entity_id_hex).ok_or_else(|| {
crate::error::invalid_args(
"cannot resolve the platform config directory, and refusing to fall back to \
the working directory — this file holds the SUBNET AUTHORITY SEED. Pass an \
explicit --out."
.to_string(),
)
})?,
};
refuse_existing(&path, args.force).await?;
if args.force {
refuse_replacing_foreign_seed(&path, SeedArtifact::SubnetKey).await?;
}
let mut seed = *keypair.secret_bytes();
let file = SubnetKeyFile {
kind: SUBNET_KEY_KIND.to_string(),
entity_id_hex: entity_id_hex.clone(),
seed_hex: hex::encode(seed),
created_at: now_iso8601(),
note: args.note.clone(),
};
zeroize_slice(&mut seed);
let toml_text = ScrubbedString::new(
toml::to_string_pretty(&file)
.map_err(|e| generic(format!("failed to serialize subnet key TOML: {e}")))?,
);
let tmp = stage_beside(&path, toml_text.as_bytes(), true).await?;
if args.force {
publish_staged_replace(&tmp, &path).await?;
} else {
publish_staged(&tmp, &path).await?;
}
enforce_strict_permissions(&path).await?;
warn_secret_permissions(&path, args.accept_windows_dacl);
let summary = SubnetKeySummary {
path: path.display().to_string(),
entity_id_hex,
created_at: file.created_at.clone(),
note: file.note.clone(),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_direct(
args: IssueDirectArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let keypair = load_subnet_key(&args.root_key, args.insecure_permissions).await?;
let authority = parse_entity_hex(&args.authority)?;
let subject = parse_entity_hex(&args.subject)?;
let scope = parse_subnet_path(&args.scope)?;
let rights = parse_subnet_rights(&args.rights)?;
let not_before = args
.not_before
.unwrap_or_else(|| unix_now().saturating_sub(NOT_BEFORE_HEADROOM_SECS));
let grant = SubnetGrant::try_issue(
&keypair,
authority.clone(),
scope,
args.topology_epoch,
subject.clone(),
rights,
args.generation,
not_before,
args.ttl_secs,
)
.map_err(|e| invalid_args(format!("issue-direct: subnet:{e}")))?;
let set = SubnetCredentialSet::Direct(grant);
publish_wire_artifact(
&set.to_bytes(),
&args.out,
args.force,
&[("--root-key", &args.root_key)],
)
.await?;
let summary = IssueCredentialOutput {
path: args.out.display().to_string(),
artifact: "credential-set-direct".to_string(),
authority_hex: hex::encode(authority.as_bytes()),
subject_hex: hex::encode(subject.as_bytes()),
scope: format_subnet(scope),
rights: format_subnet_rights(rights),
topology_epoch: args.topology_epoch,
generation: args.generation,
not_before,
not_after: not_before.saturating_add(args.ttl_secs),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_issuer(
args: IssueIssuerArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let keypair = load_subnet_key(&args.root_key, args.insecure_permissions).await?;
let authority = parse_entity_hex(&args.authority)?;
let issuer = parse_entity_hex(&args.issuer)?;
let scope = parse_subnet_path(&args.scope)?;
let max_rights = parse_subnet_rights(&args.max_rights)?;
let not_before = args
.not_before
.unwrap_or_else(|| unix_now().saturating_sub(NOT_BEFORE_HEADROOM_SECS));
let grant = SubnetIssuerGrant::try_issue(
&keypair,
authority.clone(),
scope,
args.topology_epoch,
issuer.clone(),
max_rights,
args.generation,
not_before,
args.ttl_secs,
)
.map_err(|e| invalid_args(format!("issue-issuer: subnet:{e}")))?;
publish_wire_artifact(
&grant.to_bytes(),
&args.out,
args.force,
&[("--root-key", &args.root_key)],
)
.await?;
let summary = IssueCredentialOutput {
path: args.out.display().to_string(),
artifact: "issuer-grant".to_string(),
authority_hex: hex::encode(authority.as_bytes()),
subject_hex: hex::encode(issuer.as_bytes()),
scope: format_subnet(scope),
rights: format_subnet_rights(max_rights),
topology_epoch: args.topology_epoch,
generation: args.generation,
not_before,
not_after: not_before.saturating_add(args.ttl_secs),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_delegated(
args: IssueDelegatedArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let issuer_grant_bytes = tokio::fs::read(&args.issuer_grant).await.map_err(|e| {
generic(format!(
"failed to read issuer grant {}: {e}",
args.issuer_grant.display()
))
})?;
let issuer_grant = SubnetIssuerGrant::from_bytes(&issuer_grant_bytes)
.map_err(|e| invalid_args(format!("issuer grant does not decode: subnet:{e}")))?;
let issuer_kp = load_subnet_key(&args.issuer_key, args.insecure_permissions).await?;
if issuer_kp.entity_id() != &issuer_grant.issuer {
return Err(invalid_args(
"the --issuer-key does not match the issuer named by the --issuer-grant".to_string(),
));
}
let subject = parse_entity_hex(&args.subject)?;
let scope = parse_subnet_path(&args.scope)?;
let rights = parse_subnet_rights(&args.rights)?;
if !issuer_grant.scope.is_ancestor_or_self_of(scope) {
return Err(invalid_args(format!(
"leaf scope {} escapes the issuer scope {} (subnet:scope_not_ancestor)",
format_subnet(scope),
format_subnet(issuer_grant.scope),
)));
}
if !issuer_grant.maximum_rights.contains(rights) {
return Err(invalid_args(format!(
"leaf rights {} exceed the issuer maximum {} (subnet:issuer_attenuation_broadened)",
format_subnet_rights(rights),
format_subnet_rights(issuer_grant.maximum_rights),
)));
}
let not_before = args
.not_before
.unwrap_or_else(|| unix_now().saturating_sub(NOT_BEFORE_HEADROOM_SECS));
let leaf = SubnetGrant::try_issue(
&issuer_kp,
issuer_grant.authority.clone(),
scope,
issuer_grant.topology_epoch,
subject.clone(),
rights,
args.generation,
not_before,
args.ttl_secs,
)
.map_err(|e| invalid_args(format!("issue-delegated: subnet:{e}")))?;
let authority_hex = hex::encode(issuer_grant.authority.as_bytes());
let topology_epoch = issuer_grant.topology_epoch;
let set = SubnetCredentialSet::OneHop { issuer_grant, leaf };
publish_wire_artifact(
&set.to_bytes(),
&args.out,
args.force,
&[
("--issuer-grant", &args.issuer_grant),
("--issuer-key", &args.issuer_key),
],
)
.await?;
let summary = IssueCredentialOutput {
path: args.out.display().to_string(),
artifact: "credential-set-delegated".to_string(),
authority_hex,
subject_hex: hex::encode(subject.as_bytes()),
scope: format_subnet(scope),
rights: format_subnet_rights(rights),
topology_epoch,
generation: args.generation,
not_before,
not_after: not_before.saturating_add(args.ttl_secs),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_control_fact(
args: IssueControlFactArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let (common, fact) = match args.kind {
ControlFactKindCommand::Descriptor(a) => {
let (kp, scope) = fact_prelude(&a.common).await?;
let fact = SubnetDescriptor::try_issue(
&kp,
scope,
a.common.topology_epoch,
a.common.revision,
unix_now(),
)
.map_err(|e| invalid_args(format!("descriptor: subnet:{e}")))?;
(a.common, SubnetControlFact::Descriptor(fact))
}
ControlFactKindCommand::GatewayAdvertisement(a) => {
let (kp, scope) = fact_prelude(&a.common).await?;
let gateway = parse_entity_hex(&a.gateway)?;
let gateway_node = parse_u64_arg("--gateway-node", &a.gateway_node)?;
let not_before = a
.not_before
.unwrap_or_else(|| unix_now().saturating_sub(NOT_BEFORE_HEADROOM_SECS));
let fact = GatewayAdvertisement::try_issue(
&kp,
scope,
a.common.topology_epoch,
gateway,
gateway_node,
a.common.revision,
not_before,
not_before.saturating_add(a.ttl_secs),
)
.map_err(|e| invalid_args(format!("gateway-advertisement: subnet:{e}")))?;
(a.common, SubnetControlFact::GatewayAdvertisement(fact))
}
ControlFactKindCommand::ExportPolicy(a) => {
let (kp, scope) = fact_prelude(&a.common).await?;
let mut channels = Vec::with_capacity(a.channels.len());
for raw in &a.channels {
channels.push(crate::commands::gateway::parse_channel_hash(raw)?);
}
let not_before = a
.not_before
.unwrap_or_else(|| unix_now().saturating_sub(NOT_BEFORE_HEADROOM_SECS));
let fact = SubnetExportPolicy::try_issue(
&kp,
scope,
a.common.topology_epoch,
channels,
a.common.revision,
not_before,
not_before.saturating_add(a.ttl_secs),
)
.map_err(|e| invalid_args(format!("export-policy: subnet:{e}")))?;
(a.common, SubnetControlFact::ExportPolicy(fact))
}
ControlFactKindCommand::RevocationFloor(a) => {
let (kp, scope) = fact_prelude(&a.common).await?;
let fact = SubnetRevocationFloor::try_issue(
&kp,
scope,
a.common.topology_epoch,
a.minimum_generation,
a.common.revision,
unix_now(),
)
.map_err(|e| invalid_args(format!("revocation-floor: subnet:{e}")))?;
(a.common, SubnetControlFact::RevocationFloor(fact))
}
};
publish_wire_artifact(
&fact.to_bytes(),
&common.out,
common.force,
&[("--root-key", &common.root_key)],
)
.await?;
let summary = IssueFactOutput {
path: common.out.display().to_string(),
artifact: "control-fact".to_string(),
kind: net_sdk::subnet::fact_kind_wire(fact.kind()).to_string(),
authority_hex: hex::encode(fact.scope().authority.as_bytes()),
scope: format_subnet(fact.scope().path),
topology_epoch: common.topology_epoch,
revision: common.revision,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn fact_prelude(common: &FactCommonArgs) -> Result<(EntityKeypair, SubnetRef), CliError> {
let kp = load_subnet_key(&common.root_key, common.insecure_permissions).await?;
let authority = parse_entity_hex(&common.authority)?;
let path = parse_subnet_path(&common.scope)?;
Ok((kp, SubnetRef { authority, path }))
}
async fn run_inspect(args: InspectArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let bytes = tokio::fs::read(&args.file)
.await
.map_err(|e| generic(format!("failed to read {}: {e}", args.file.display())))?;
let view = if let Ok(fact) = SubnetControlFact::from_bytes(&bytes) {
serde_json::json!({
"artifact": "control-fact",
"kind": net_sdk::subnet::fact_kind_wire(fact.kind()),
"authority_hex": hex::encode(fact.scope().authority.as_bytes()),
"scope": format_subnet(fact.scope().path),
})
} else if let Ok(set) = SubnetCredentialSet::from_bytes(&bytes) {
let leaf = set.leaf();
let mut v = serde_json::json!({
"artifact": match &set {
SubnetCredentialSet::Direct(_) => "credential-set-direct",
SubnetCredentialSet::OneHop { .. } => "credential-set-delegated",
},
"authority_hex": hex::encode(leaf.authority.as_bytes()),
"subject_hex": hex::encode(leaf.subject.as_bytes()),
"scope": format_subnet(leaf.scope),
"rights": format_subnet_rights(leaf.rights),
"topology_epoch": leaf.topology_epoch,
"generation": leaf.generation,
"not_before": leaf.not_before,
"not_after": leaf.not_after,
});
if let SubnetCredentialSet::OneHop { issuer_grant, .. } = &set {
v["issuer_hex"] = serde_json::json!(hex::encode(issuer_grant.issuer.as_bytes()));
v["issuer_scope"] = serde_json::json!(format_subnet(issuer_grant.scope));
v["issuer_max_rights"] =
serde_json::json!(format_subnet_rights(issuer_grant.maximum_rights));
}
v
} else if let Ok(grant) = SubnetIssuerGrant::from_bytes(&bytes) {
serde_json::json!({
"artifact": "issuer-grant",
"authority_hex": hex::encode(grant.authority.as_bytes()),
"issuer_hex": hex::encode(grant.issuer.as_bytes()),
"scope": format_subnet(grant.scope),
"max_rights": format_subnet_rights(grant.maximum_rights),
"topology_epoch": grant.topology_epoch,
"generation": grant.generation,
"not_before": grant.not_before,
"not_after": grant.not_after,
})
} else {
return Err(invalid_args(format!(
"{} is not a recognized subnet artifact (subnet:{})",
args.file.display(),
SubnetAuthError::InvalidFormat,
)));
};
emit_value(OutputFormat::resolve_oneshot(output), &view)
.map_err(|e| generic(format!("write inspect view: {e}")))?;
Ok(())
}
const SUBNET_KEY_KIND: &str = "subnet-authority-key";
#[derive(Serialize, serde::Deserialize)]
struct SubnetKeyFile {
kind: String,
entity_id_hex: String,
seed_hex: String,
created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl Drop for SubnetKeyFile {
fn drop(&mut self) {
zeroize_string(&mut self.seed_hex);
}
}
#[derive(Serialize)]
struct SubnetKeySummary {
path: String,
entity_id_hex: String,
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Serialize)]
struct IssueCredentialOutput {
path: String,
artifact: String,
authority_hex: String,
subject_hex: String,
scope: String,
rights: String,
topology_epoch: u32,
generation: u32,
not_before: u64,
not_after: u64,
}
#[derive(Serialize)]
struct IssueFactOutput {
path: String,
artifact: String,
kind: String,
authority_hex: String,
scope: String,
topology_epoch: u32,
revision: u64,
}
fn default_subnet_key_path(entity_id_hex: &str) -> Option<PathBuf> {
let short = &entity_id_hex[..entity_id_hex.len().min(16)];
Some(
dirs::config_dir()?
.join("net-mesh")
.join("subnets")
.join(format!("subnet-{short}.toml")),
)
}
async fn load_subnet_key(
path: &Path,
insecure_permissions: bool,
) -> Result<EntityKeypair, CliError> {
if !insecure_permissions {
check_strict_permissions(path).await?;
}
let mut text = tokio::fs::read_to_string(path).await.map_err(|e| {
generic(format!(
"failed to read subnet key file {}: {e}",
path.display()
))
})?;
let outcome = load_subnet_key_from_text(&text, path);
zeroize_string(&mut text);
outcome
}
fn load_subnet_key_from_text(text: &str, path: &Path) -> Result<EntityKeypair, CliError> {
let parsed: SubnetKeyFile = toml::from_str(text).map_err(|_| {
invalid_args(format!(
"subnet key file {} is not valid TOML (kind: parse_error)",
path.display()
))
})?;
if parsed.kind != SUBNET_KEY_KIND {
return Err(invalid_args(format!(
"{} is not a subnet authority key file (kind: wrong_kind)",
path.display()
)));
}
let seed_bytes = ScrubbedBytes::new(hex::decode(parsed.seed_hex.as_bytes()).map_err(|_| {
invalid_args(format!(
"subnet key file {} seed_hex is not valid hex (kind: bad_seed_encoding)",
path.display()
))
})?);
if seed_bytes.as_slice().len() != 32 {
return Err(invalid_args(format!(
"subnet key file {} seed must be 32 bytes (64 hex chars), got {} (kind: bad_seed_length)",
path.display(),
seed_bytes.as_slice().len()
)));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(seed_bytes.as_slice());
let keypair = EntityKeypair::from_bytes(seed);
zeroize_slice(&mut seed);
let derived = hex::encode(keypair.entity_id().as_bytes());
if !parsed.entity_id_hex.eq_ignore_ascii_case(&derived) {
return Err(invalid_args(format!(
"subnet key file {}: entity_id_hex does not match the key derived from seed_hex",
path.display()
)));
}
Ok(keypair)
}
async fn publish_wire_artifact(
bytes: &[u8],
out: &Path,
force: bool,
inputs: &[(&str, &Path)],
) -> Result<(), CliError> {
let mut paths: Vec<(&str, &Path)> = inputs.to_vec();
paths.push(("--out", out));
refuse_aliased_paths(&paths)?;
refuse_existing(out, force).await?;
if force {
refuse_replacing_foreign_seed(out, SeedArtifact::None).await?;
}
let tmp = stage_beside(out, bytes, false).await?;
if force {
publish_staged_replace(&tmp, out).await
} else {
publish_staged(&tmp, out).await
}
}
fn parse_subnet_path(raw: &str) -> Result<TopologySubnetId, CliError> {
if raw.eq_ignore_ascii_case("global") {
return Ok(TopologySubnetId::GLOBAL);
}
let mut levels = Vec::new();
for part in raw.split('.') {
let level: u8 = part.parse().map_err(|_| {
invalid_args(format!(
"subnet path `{raw}`: `{part}` is not a level in 0..=255; expected a dotted \
path like `3.9` or `global`"
))
})?;
levels.push(level);
}
TopologySubnetId::try_new(&levels)
.map_err(|_| invalid_args(format!("subnet path `{raw}`: more than four levels")))
}
fn parse_subnet_rights(raw: &str) -> Result<SubnetRights, CliError> {
let mut bits: u8 = 0;
for part in raw.split(',') {
let part = part.trim();
bits |= match part.to_ascii_lowercase().as_str() {
"attach" => SubnetRights::ATTACH.bits(),
"route" => SubnetRights::ROUTE.bits(),
"export" => SubnetRights::EXPORT.bits(),
other => {
return Err(invalid_args(format!(
"unknown right `{other}`; expected a comma-separated subset of \
attach, route, export"
)))
}
};
}
SubnetRights::try_from_bits(bits)
.map_err(|e| invalid_args(format!("rights `{raw}`: subnet:{e}")))
}
fn format_subnet_rights(rights: SubnetRights) -> String {
let mut parts = Vec::new();
if rights.contains(SubnetRights::ATTACH) {
parts.push("attach");
}
if rights.contains(SubnetRights::ROUTE) {
parts.push("route");
}
if rights.contains(SubnetRights::EXPORT) {
parts.push("export");
}
parts.join(",")
}
fn parse_u64_arg(flag: &str, raw: &str) -> Result<u64, CliError> {
let parsed = if let Some(hex) = raw.strip_prefix("0x") {
u64::from_str_radix(hex, 16)
} else {
raw.parse()
};
parsed.map_err(|_| invalid_args(format!("{flag} `{raw}` is not a u64")))
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}