cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit f369dd7a63330630dbe46da6f59131b672441e5f
parent 082b9b19bdbcf072084fd51668f9736419cda9cb
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 03:51:55 +0000

transport: require proxy token sources

- add proxy token file and secret id profile-set arguments
- reject missing or conflicting proxy token source configuration
- expose redacted proxy token source fields in transport views
- cover proxy profile readiness and redaction in target tests

Diffstat:
Msrc/cli/input.rs | 6++++++
Msrc/cli/transport.rs | 6++++++
Msrc/main.rs | 17++++++++++++++++-
Msrc/out/terminal/renderers/runtime.rs | 18++++++++++++++++++
Msrc/runtime/config.rs | 39+++++++++++++++++++++++++++++++++++++--
Msrc/runtime/transport.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/view/runtime.rs | 6++++++
Mtests/target_cli.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 382 insertions(+), 8 deletions(-)

diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -338,6 +338,12 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { ); } insert_string(&mut input, "proxy_url", &args.proxy_url); + insert_path(&mut input, "proxy_token_file", &args.proxy_token_file); + insert_string( + &mut input, + "proxy_token_secret_id", + &args.proxy_token_secret_id, + ); } TransportProfileCommand::Get => {} }, diff --git a/src/cli/transport.rs b/src/cli/transport.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{Args, Subcommand, ValueEnum}; #[derive(Debug, Clone, Args)] @@ -35,6 +37,10 @@ pub struct TransportProfileSetArgs { pub reticulum_preview_behavior: Option<ReticulumPreviewBehaviorArg>, #[arg(long = "proxy-url")] pub proxy_url: Option<String>, + #[arg(long = "proxy-token-file", value_name = "PATH")] + pub proxy_token_file: Option<PathBuf>, + #[arg(long = "proxy-token-secret-id", value_name = "SECRET_ID")] + pub proxy_token_secret_id: Option<String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] diff --git a/src/main.rs b/src/main.rs @@ -526,6 +526,18 @@ fn validate_transport_profile_contract( ), }); } + if matches!(config.transport.profile, TransportProfileKind::Proxy) + && config.transport.proxy.token_file.is_none() + && config.transport.proxy.token_secret_id.is_none() + { + return Err(OperationAdapterError::NetworkUnavailable { + operation_id: spec.operation_id.to_owned(), + message: format!( + "`{}` requires a configured proxy token file or token secret id", + spec.cli_path + ), + }); + } Ok(()) } @@ -543,7 +555,10 @@ fn is_transport_profile_routed_operation(operation_id: &str) -> bool { fn transport_profile_is_usable_for_delivery(config: &RuntimeConfig) -> bool { match config.transport.profile { TransportProfileKind::Nostr => !config.transport.nostr_relay_urls.is_empty(), - TransportProfileKind::Proxy => true, + TransportProfileKind::Proxy => { + config.transport.proxy.token_file.is_some() + || config.transport.proxy.token_secret_id.is_some() + } TransportProfileKind::LocalOnly | TransportProfileKind::ReticulumPreview => false, } } diff --git a/src/out/terminal/renderers/runtime.rs b/src/out/terminal/renderers/runtime.rs @@ -108,6 +108,24 @@ fn transport_profile_document(envelope: &OutputEnvelope, result: &Value) -> Term ); common::push_bool_field(&mut document, "Usable", result, &["usable_for_delivery"]); common::push_path_field(&mut document, "Message", result, &["message"]); + common::push_path_field( + &mut document, + "Proxy token", + result, + &["proxy_token_source"], + ); + common::push_path_field( + &mut document, + "Proxy token file", + result, + &["proxy_token_file"], + ); + common::push_path_field( + &mut document, + "Proxy token secret", + result, + &["proxy_token_secret_id"], + ); if let Some(relays) = common::array(result, &["nostr_relays"]) { let rows = relays .iter() diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -1083,6 +1083,11 @@ fn resolve_transport_proxy_env( let token_file = env_value(env, env_file, &[ENV_CLI_TRANSPORT_PROXY_TOKEN_FILE]).map(PathBuf::from); let token_secret_id = env_value(env, env_file, &[ENV_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID]); + validate_proxy_token_source( + token_file.as_ref(), + token_secret_id.as_deref(), + "environment proxy transport", + )?; Ok(ProxyTransportConfig { url: validate_rpc_url(url.as_str())?, token_file, @@ -1097,6 +1102,13 @@ fn resolve_transport_proxy_file( let url = proxy .and_then(|proxy| proxy.url.clone()) .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); + let token_file = proxy.and_then(|proxy| proxy.token_file.clone()); + let token_secret_id = proxy.and_then(|proxy| proxy.token_secret_id.clone()); + validate_proxy_token_source( + token_file.as_ref(), + token_secret_id.as_deref(), + format!("{source_label}.proxy").as_str(), + )?; Ok(ProxyTransportConfig { url: validate_rpc_url(url.as_str()).map_err(|error| match error { RuntimeError::Config(message) => { @@ -1104,11 +1116,34 @@ fn resolve_transport_proxy_file( } other => other, })?, - token_file: proxy.and_then(|proxy| proxy.token_file.clone()), - token_secret_id: proxy.and_then(|proxy| proxy.token_secret_id.clone()), + token_file, + token_secret_id, }) } +fn validate_proxy_token_source( + token_file: Option<&PathBuf>, + token_secret_id: Option<&str>, + source_label: &str, +) -> Result<(), RuntimeError> { + if token_file.is_some_and(|path| path.as_os_str().is_empty()) { + return Err(RuntimeError::Config(format!( + "{source_label}.token_file must not be empty" + ))); + } + if token_secret_id.is_some_and(|secret_id| secret_id.trim().is_empty()) { + return Err(RuntimeError::Config(format!( + "{source_label}.token_secret_id must not be empty" + ))); + } + if token_file.is_some() && token_secret_id.is_some() { + return Err(RuntimeError::Config(format!( + "{source_label} cannot set both token_file and token_secret_id" + ))); + } + Ok(()) +} + fn resolve_rhi_config( env: &dyn Environment, env_file: &EnvFileValues, diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs @@ -54,8 +54,23 @@ pub fn set_profile( "transport profile `proxy` requires --proxy-url".to_owned(), )); }; + let token_file = string_input(input, "proxy_token_file").map(str::to_owned); + let token_secret_id = string_input(input, "proxy_token_secret_id").map(str::to_owned); + validate_proxy_token_source(token_file.as_deref(), token_secret_id.as_deref())?; let mut proxy = Map::new(); proxy.insert("url".to_owned(), Value::String(url.to_owned())); + if let Some(token_file) = token_file.as_ref() { + proxy.insert( + "token_file".to_owned(), + Value::String(token_file.to_owned()), + ); + } + if let Some(token_secret_id) = token_secret_id.as_ref() { + proxy.insert( + "token_secret_id".to_owned(), + Value::String(token_secret_id.to_owned()), + ); + } transport.insert("proxy".to_owned(), Value::Table(proxy)); } other => { @@ -70,6 +85,8 @@ pub fn set_profile( string_array_input(input, "nostr_relays"), string_input(input, "reticulum_preview_behavior").map(str::to_owned), string_input(input, "proxy_url").map(str::to_owned), + string_input(input, "proxy_token_file").map(str::to_owned), + string_input(input, "proxy_token_secret_id").map(str::to_owned), "configured", )) } @@ -82,6 +99,8 @@ pub fn status(config: &RuntimeConfig) -> TransportStatusView { Vec::new(), Some("reject_delivery_attempts".to_owned()), None, + None, + None, "preview_unavailable", )); } @@ -169,14 +188,22 @@ pub fn outbox_push(config: &RuntimeConfig) -> Result<TransportOutboxPushView, Cl fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView { match config.transport.profile { - TransportProfileKind::LocalOnly => { - profile_view_from_parts("local_only", Vec::new(), None, None, "configured") - } + TransportProfileKind::LocalOnly => profile_view_from_parts( + "local_only", + Vec::new(), + None, + None, + None, + None, + "configured", + ), TransportProfileKind::Nostr => profile_view_from_parts( "nostr", config.transport.nostr_relay_urls.clone(), None, None, + None, + None, if config.transport.nostr_relay_urls.is_empty() { "unconfigured" } else { @@ -194,6 +221,8 @@ fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView { .to_owned(), ), None, + None, + None, "preview_unavailable", ), TransportProfileKind::Proxy => profile_view_from_parts( @@ -201,7 +230,18 @@ fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView { Vec::new(), None, Some(config.transport.proxy.url.clone()), - "configured", + config + .transport + .proxy + .token_file + .as_ref() + .map(|path| path.display().to_string()), + config.transport.proxy.token_secret_id.clone(), + if proxy_token_configured(config) { + "configured" + } else { + "unconfigured" + }, ), } } @@ -211,6 +251,8 @@ fn profile_view_from_parts( nostr_relays: Vec<String>, reticulum_preview_behavior: Option<String>, proxy_url: Option<String>, + proxy_token_file: Option<String>, + proxy_token_secret_id: Option<String>, configured_state: &str, ) -> TransportProfileView { let transport_kind = match profile_id { @@ -231,9 +273,22 @@ fn profile_view_from_parts( "nostr" if usable_for_delivery => "Nostr relay transport is configured for delivery", "nostr" => "Nostr transport requires configured Nostr relay targets", "reticulum_preview" => RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - "proxy" => "Proxy transport delegates delivery to the configured endpoint", + "proxy" if usable_for_delivery => { + "Proxy transport delegates delivery to the configured endpoint" + } + "proxy" => "Proxy transport requires a configured token file or token secret id", _ => "Local-only profile does not deliver to network transports", }; + let proxy_token_source = match ( + proxy_token_file.as_ref().filter(|value| !value.is_empty()), + proxy_token_secret_id + .as_ref() + .filter(|value| !value.is_empty()), + ) { + (Some(_), None) => Some("token_file".to_owned()), + (None, Some(_)) => Some("token_secret_id".to_owned()), + _ => None, + }; TransportProfileView { state: configured_state.to_owned(), source: TRANSPORT_SOURCE.to_owned(), @@ -246,6 +301,9 @@ fn profile_view_from_parts( nostr_relays, reticulum_preview_behavior, proxy_url, + proxy_token_source, + proxy_token_file, + proxy_token_secret_id, actions: profile_actions(profile_id, usable_for_delivery), } } @@ -265,10 +323,41 @@ fn profile_actions(profile_id: &str, usable_for_delivery: bool) -> Vec<String> { .to_owned(), ] } + "proxy" => vec![ + "radroots transport profile set --kind proxy --proxy-url http://127.0.0.1:7070 --proxy-token-file <path>" + .to_owned(), + ], _ => vec!["radroots transport profile get".to_owned()], } } +fn validate_proxy_token_source( + token_file: Option<&str>, + token_secret_id: Option<&str>, +) -> Result<(), RuntimeError> { + match (token_file, token_secret_id) { + (None, None) => Err(RuntimeError::Config( + "transport profile `proxy` requires --proxy-token-file or --proxy-token-secret-id" + .to_owned(), + )), + (Some(file), None) if file.trim().is_empty() => Err(RuntimeError::Config( + "transport profile `proxy` requires a non-empty --proxy-token-file".to_owned(), + )), + (None, Some(secret_id)) if secret_id.trim().is_empty() => Err(RuntimeError::Config( + "transport profile `proxy` requires a non-empty --proxy-token-secret-id".to_owned(), + )), + (Some(_), Some(_)) => Err(RuntimeError::Config( + "transport profile `proxy` cannot set both --proxy-token-file and --proxy-token-secret-id" + .to_owned(), + )), + _ => Ok(()), + } +} + +fn proxy_token_configured(config: &RuntimeConfig) -> bool { + config.transport.proxy.token_file.is_some() || config.transport.proxy.token_secret_id.is_some() +} + pub(crate) fn update_app_config_table( config: &RuntimeConfig, key: &str, diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -3283,6 +3283,12 @@ pub struct TransportProfileView { pub reticulum_preview_behavior: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub proxy_url: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_token_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_token_file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy_token_secret_id: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, } diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1528,10 +1528,15 @@ fn config_get_exposes_proxy_missing_token_state() { ); assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); assert_eq!(value["result"]["transport"]["profile_id"], "proxy"); + assert_eq!(value["result"]["transport"]["state"], "unconfigured"); + assert_eq!(value["result"]["transport"]["usable_for_delivery"], false); assert_eq!( value["result"]["transport"]["proxy_url"], "http://127.0.0.1:7070" ); + assert!(value["result"]["transport"]["proxy_token_source"].is_null()); + assert!(value["result"]["transport"]["proxy_token_file"].is_null()); + assert!(value["result"]["transport"]["proxy_token_secret_id"].is_null()); assert_eq!( value["result"]["actions"][0], "configure RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_FILE or RADROOTS_CLI_TRANSPORT_PROXY_TOKEN_SECRET_ID" @@ -1562,6 +1567,22 @@ fn config_get_proxy_with_token_file_reports_ready_transport() { assert_eq!(value["result"]["publish"]["executable"], true); assert_eq!(value["result"]["publish"]["reason"], Value::Null); assert_eq!(value["result"]["transport"]["profile_id"], "proxy"); + assert_eq!(value["result"]["transport"]["state"], "configured"); + assert_eq!(value["result"]["transport"]["usable_for_delivery"], true); + assert_eq!( + value["result"]["transport"]["proxy_token_source"], + "token_file" + ); + assert_eq!( + value["result"]["transport"]["proxy_token_file"], + token_file.display().to_string() + ); + assert!(value["result"]["transport"]["proxy_token_secret_id"].is_null()); + assert!( + !serde_json::to_string(&value) + .expect("json") + .contains("proxy_test_token") + ); assert_eq!( value["result"]["actions"] .as_array() @@ -1572,6 +1593,184 @@ fn config_get_proxy_with_token_file_reports_ready_transport() { } #[test] +fn transport_profile_set_proxy_persists_token_file_without_printing_token_material() { + let sandbox = RadrootsCliSandbox::new(); + let token_file = proxy_token_file(&sandbox); + + let value = sandbox.json_success(&[ + "--format", + "json", + "transport", + "profile", + "set", + "--kind", + "proxy", + "--proxy-url", + "http://127.0.0.1:7070", + "--proxy-token-file", + token_file.to_string_lossy().as_ref(), + ]); + + assert_eq!(value["operation_id"], "transport.profile.set"); + assert_eq!(value["result"]["profile_id"], "proxy"); + assert_eq!(value["result"]["state"], "configured"); + assert_eq!(value["result"]["usable_for_delivery"], true); + assert_eq!(value["result"]["proxy_token_source"], "token_file"); + assert_eq!( + value["result"]["proxy_token_file"], + token_file.display().to_string() + ); + assert!(value["result"]["proxy_token_secret_id"].is_null()); + assert!( + !serde_json::to_string(&value) + .expect("json") + .contains("proxy_test_token") + ); + + let get = sandbox.json_success(&["--format", "json", "transport", "profile", "get"]); + assert_eq!(get["result"]["proxy_token_source"], "token_file"); + assert_eq!( + get["result"]["proxy_token_file"], + token_file.display().to_string() + ); + assert!( + !serde_json::to_string(&get) + .expect("json") + .contains("proxy_test_token") + ); +} + +#[test] +fn transport_profile_set_proxy_persists_token_secret_id_without_printing_token_material() { + let sandbox = RadrootsCliSandbox::new(); + let token_file = proxy_token_file(&sandbox); + + let value = sandbox.json_success(&[ + "--format", + "json", + "transport", + "profile", + "set", + "--kind", + "proxy", + "--proxy-url", + "http://127.0.0.1:7070", + "--proxy-token-secret-id", + "proxy.publish.token", + ]); + + assert_eq!(value["operation_id"], "transport.profile.set"); + assert_eq!(value["result"]["profile_id"], "proxy"); + assert_eq!(value["result"]["state"], "configured"); + assert_eq!(value["result"]["usable_for_delivery"], true); + assert_eq!(value["result"]["proxy_token_source"], "token_secret_id"); + assert!(value["result"]["proxy_token_file"].is_null()); + assert_eq!( + value["result"]["proxy_token_secret_id"], + "proxy.publish.token" + ); + assert!( + !serde_json::to_string(&value) + .expect("json") + .contains("proxy_test_token") + ); + + let get = sandbox.json_success(&["--format", "json", "transport", "profile", "get"]); + assert_eq!(get["result"]["proxy_token_source"], "token_secret_id"); + assert_eq!( + get["result"]["proxy_token_secret_id"], + "proxy.publish.token" + ); + assert!( + !serde_json::to_string(&get) + .expect("json") + .contains("proxy_test_token") + ); + assert!(token_file.exists()); +} + +#[test] +fn transport_profile_set_proxy_rejects_missing_and_conflicting_token_sources() { + let sandbox = RadrootsCliSandbox::new(); + let token_file = proxy_token_file(&sandbox); + + let (missing_output, missing_value) = sandbox.json_output(&[ + "--format", + "json", + "transport", + "profile", + "set", + "--kind", + "proxy", + "--proxy-url", + "http://127.0.0.1:7070", + ]); + assert!(!missing_output.status.success()); + assert_contains( + &missing_value["errors"][0]["message"], + "requires --proxy-token-file or --proxy-token-secret-id", + ); + + let (conflict_output, conflict_value) = sandbox.json_output(&[ + "--format", + "json", + "transport", + "profile", + "set", + "--kind", + "proxy", + "--proxy-url", + "http://127.0.0.1:7070", + "--proxy-token-file", + token_file.to_string_lossy().as_ref(), + "--proxy-token-secret-id", + "proxy.publish.token", + ]); + assert!(!conflict_output.status.success()); + assert_contains( + &conflict_value["errors"][0]["message"], + "cannot set both --proxy-token-file and --proxy-token-secret-id", + ); + assert!( + !serde_json::to_string(&conflict_value) + .expect("json") + .contains("proxy_test_token") + ); +} + +#[test] +fn config_get_rejects_proxy_transport_config_with_conflicting_token_sources() { + let sandbox = RadrootsCliSandbox::new(); + let token_file = proxy_token_file(&sandbox); + sandbox.write_app_config( + format!( + r#"[transport] +profile = "proxy" + +[transport.proxy] +token_file = "{}" +token_secret_id = "proxy.publish.token" +"#, + toml_string(token_file.display().to_string().as_str()) + ) + .as_str(), + ); + + let (output, value) = sandbox.json_output(&["--format", "json", "config", "get"]); + + assert!(!output.status.success()); + assert_contains( + &value["errors"][0]["message"], + "cannot set both token_file and token_secret_id", + ); + assert!( + !serde_json::to_string(&value) + .expect("json") + .contains("proxy_test_token") + ); +} + +#[test] fn config_get_marks_proxy_unconfigured_with_incomplete_myc_signer() { let sandbox = RadrootsCliSandbox::new(); let token_file = proxy_token_file(&sandbox);