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:
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);