use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use net_mcp::forward::{AllowList, ForwardingStore, ProviderScope, StoreError};
use serde::Serialize;
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
#[derive(Subcommand, Debug)]
pub enum ForwardingCommand {
Enable(StoreArgs),
Disable(StoreArgs),
Allow(AllowArgs),
Rm(RmArgs),
Audit(StoreArgs),
SetValue(SetValueArgs),
}
#[derive(Args, Debug)]
pub struct StoreArgs {
#[arg(long = "store", value_name = "PATH")]
pub store: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct AllowArgs {
pub ref_name: String,
#[arg(long)]
pub header: String,
#[arg(long = "provider", value_name = "ID")]
pub provider: Vec<String>,
#[arg(long = "any-provider", conflicts_with = "provider")]
pub any_provider: bool,
#[arg(long = "capability", value_name = "GLOB")]
pub capability: Vec<String>,
#[arg(long)]
pub purpose: Option<String>,
#[arg(long)]
pub force: bool,
#[command(flatten)]
pub store: StoreArgs,
}
#[derive(Args, Debug)]
pub struct RmArgs {
pub ref_name: String,
#[command(flatten)]
pub store: StoreArgs,
}
#[derive(Args, Debug)]
pub struct SetValueArgs {
pub ref_name: String,
}
pub async fn run(
cmd: ForwardingCommand,
output: Option<OutputFormat>,
_config_path: Option<&Path>,
_profile_name: &str,
) -> Result<(), CliError> {
match cmd {
ForwardingCommand::Enable(args) => set_enabled(args, output, true).await,
ForwardingCommand::Disable(args) => set_enabled(args, output, false).await,
ForwardingCommand::Allow(args) => allow(args, output).await,
ForwardingCommand::Rm(args) => rm(args, output).await,
ForwardingCommand::Audit(args) => audit(args, output).await,
ForwardingCommand::SetValue(args) => set_value(args, output).await,
}
}
#[cfg(feature = "keychain")]
async fn set_value(args: SetValueArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
use net_mcp::forward::{
validate_ref_name, zeroize_secret, KeychainSecretBackend, DEFAULT_KEYCHAIN_SERVICE,
};
use tokio::io::AsyncReadExt;
validate_ref_name(&args.ref_name).map_err(|e| invalid_args(e.to_string()))?;
let mut buf = Vec::new();
let outcome = async {
tokio::io::stdin()
.read_to_end(&mut buf)
.await
.map_err(|e| generic(format!("read secret from stdin: {e}")))?;
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
if buf.is_empty() {
return Err(invalid_args("no secret value on stdin (pipe the value in)"));
}
KeychainSecretBackend::default()
.set(&args.ref_name, &buf)
.await
.map_err(|e| generic(format!("store secret in keychain: {e}")))
}
.await;
zeroize_secret(&mut buf);
outcome?;
emit_row(
output,
MutationRow {
action: "value-set",
ref_name: Some(args.ref_name),
changed: true,
store: format!("keychain:{DEFAULT_KEYCHAIN_SERVICE}"),
},
)
}
#[cfg(not(feature = "keychain"))]
async fn set_value(_args: SetValueArgs, _output: Option<OutputFormat>) -> Result<(), CliError> {
Err(generic(
"this `net` build has no secret value store; rebuild net-cli with \
`--features keychain` to enter secret values",
))
}
fn resolve_store(override_: Option<&Path>) -> Result<PathBuf, CliError> {
if let Some(p) = override_ {
return Ok(p.to_path_buf());
}
dirs::data_local_dir()
.or_else(dirs::home_dir)
.map(|d| d.join("net-mesh").join("forwarding.json"))
.ok_or_else(|| {
generic(
"could not determine a per-user data directory for the forwarding store; \
pass --store <PATH>",
)
})
}
fn store_err(e: StoreError) -> CliError {
match e {
StoreError::InvalidRefName { .. }
| StoreError::HeaderNotForwardable { .. }
| StoreError::CookieRequiresForce { .. }
| StoreError::SensitiveHeaderNotPlain { .. }
| StoreError::SecretProviderAny { .. }
| StoreError::Header(_) => invalid_args(e.to_string()),
StoreError::Io { .. } | StoreError::Corrupt { .. } => generic(e.to_string()),
}
}
#[derive(Serialize)]
struct MutationRow {
action: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
ref_name: Option<String>,
changed: bool,
store: String,
}
async fn set_enabled(
args: StoreArgs,
output: Option<OutputFormat>,
enabled: bool,
) -> Result<(), CliError> {
let path = resolve_store(args.store.as_deref())?;
let changed = ForwardingStore::mutate(path.clone(), |s| {
let was = s.is_enabled();
s.set_enabled(enabled);
Ok(was != enabled)
})
.await
.map_err(store_err)?;
emit_row(
output,
MutationRow {
action: if enabled { "enabled" } else { "disabled" },
ref_name: None,
changed,
store: path.display().to_string(),
},
)
}
async fn allow(args: AllowArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let providers = if args.any_provider {
ProviderScope::Any
} else if args.provider.is_empty() {
ProviderScope::None
} else {
ProviderScope::Ids(args.provider.clone())
};
let allow = AllowList {
providers,
capabilities: args.capability.clone(),
};
let path = resolve_store(args.store.store.as_deref())?;
let ref_name = args.ref_name.clone();
ForwardingStore::mutate(path.clone(), move |s| {
s.set_secret(
&args.ref_name,
&args.header,
allow,
args.purpose,
args.force,
)
})
.await
.map_err(store_err)?;
emit_row(
output,
MutationRow {
action: "allowed",
ref_name: Some(ref_name),
changed: true,
store: path.display().to_string(),
},
)
}
async fn rm(args: RmArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let path = resolve_store(args.store.store.as_deref())?;
let ref_name = args.ref_name.clone();
let changed =
ForwardingStore::mutate(path.clone(), move |s| Ok(s.remove_secret(&args.ref_name)))
.await
.map_err(store_err)?;
emit_row(
output,
MutationRow {
action: "removed",
ref_name: Some(ref_name),
changed,
store: path.display().to_string(),
},
)
}
async fn audit(args: StoreArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let path = resolve_store(args.store.as_deref())?;
let store = ForwardingStore::load(&path).await.map_err(store_err)?;
let audit = store.audit();
let fmt = OutputFormat::resolve_oneshot(output);
match fmt {
OutputFormat::Text | OutputFormat::Table => {
print!("{}", audit.render());
Ok(())
}
_ => emit_value(fmt, &audit).map_err(|e| generic(format!("write output: {e}"))),
}
}
fn emit_row(output: Option<OutputFormat>, row: MutationRow) -> Result<(), CliError> {
emit_value(OutputFormat::resolve_oneshot(output), &row)
.map_err(|e| generic(format!("write output: {e}")))
}