cli

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

commit c685656cd9604417e9ce965770fa66740e595283
parent c83384f32fa144e1ba51160e7b7f63531248aba3
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 03:53:01 +0000

transport: wire proxy auth into SDK publish

- Resolve proxy bearer tokens from token files or configured secret ids before building SDK profiles.
- Reuse the selected account secret vault for proxy token secret lookup without serializing token bytes.
- Update CLI proxy publish tests to require bearer auth and speak transport.publish.event.
- Validate with cargo fmt, cargo check, focused CLI tests, and retired daemon-field scans.

Diffstat:
MCargo.lock | 16++++++++--------
Msrc/runtime/account.rs | 7+++++++
Msrc/runtime/sdk.rs | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/target_cli.rs | 40++++++++++++++++------------------------
4 files changed, 145 insertions(+), 36 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3835,13 +3835,6 @@ dependencies = [ ] [[package]] -name = "radroots_publish_proxy_protocol" -version = "0.1.0-alpha.2" -dependencies = [ - "serde", -] - -[[package]] name = "radroots_replica_db" version = "0.1.0-alpha.2" dependencies = [ @@ -3929,11 +3922,11 @@ dependencies = [ "radroots_nostr_connect", "radroots_nostr_signer", "radroots_outbox", - "radroots_publish_proxy_protocol", "radroots_runtime_paths", "radroots_trade", "radroots_transport", "radroots_transport_nostr", + "radroots_transport_publish_protocol", "radroots_transport_reticulum", "reqwest", "serde", @@ -4033,6 +4026,13 @@ dependencies = [ ] [[package]] +name = "radroots_transport_publish_protocol" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", +] + +[[package]] name = "radroots_transport_reticulum" version = "0.1.0-alpha.2" dependencies = [ diff --git a/src/runtime/account.rs b/src/runtime/account.rs @@ -775,6 +775,13 @@ fn secret_backend_resolution_error(error: SecretBackendResolutionError) -> Runti } } +pub(crate) fn account_secret_vault( + config: &RuntimeConfig, +) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> { + let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?; + secret_vault_for_backend(config, resolved.backend, HOST_VAULT_SERVICE_NAME) +} + fn secret_vault_for_backend( config: &RuntimeConfig, backend: RadrootsSecretBackend, diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -1,3 +1,4 @@ +use std::fs; use std::future::Future; use std::path::PathBuf; use std::sync::Arc; @@ -644,12 +645,64 @@ fn sdk_transport_profile(config: &RuntimeConfig) -> Result<TransportProfile, Run ReticulumPreviewProfile::preview_unavailable().with_behavior(behavior), )) } - TransportProfileKind::Proxy => Ok(TransportProfile::proxy(ProxyProfile::new( - config.transport.proxy.url.clone(), - ))), + TransportProfileKind::Proxy => { + let mut profile = ProxyProfile::new(config.transport.proxy.url.clone()); + if let Some(token) = proxy_bearer_token(config)? { + profile = profile.with_bearer_token(token); + } + Ok(TransportProfile::proxy(profile)) + } } } +fn proxy_bearer_token(config: &RuntimeConfig) -> Result<Option<String>, RuntimeError> { + if let Some(path) = config.transport.proxy.token_file.as_ref() { + let token = fs::read_to_string(path).map_err(|error| { + RuntimeError::Config(format!( + "failed to read proxy token file {}: {error}", + path.display() + )) + })?; + return normalize_proxy_bearer_token( + token.as_str(), + format!("proxy token file {}", path.display()).as_str(), + ) + .map(Some); + } + + if let Some(secret_id) = config.transport.proxy.token_secret_id.as_ref() { + let vault = account::account_secret_vault(config)?; + let token = vault.load_secret(secret_id).map_err(|error| { + RuntimeError::Config(format!( + "failed to load proxy token secret `{secret_id}`: {error}" + )) + })?; + let token = token.ok_or_else(|| { + RuntimeError::Config(format!("proxy token secret `{secret_id}` was not found")) + })?; + return normalize_proxy_bearer_token( + token.as_str(), + format!("proxy token secret `{secret_id}`").as_str(), + ) + .map(Some); + } + + Ok(None) +} + +fn normalize_proxy_bearer_token(raw: &str, source: &str) -> Result<String, RuntimeError> { + let token = raw.trim(); + if token.is_empty() { + return Err(RuntimeError::Config(format!("{source} is empty"))); + } + if token.bytes().any(|byte| byte.is_ascii_control()) { + return Err(RuntimeError::Config(format!( + "{source} contains unsupported control characters" + ))); + } + Ok(token.to_owned()) +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -658,7 +711,7 @@ mod tests { use std::time::Duration; use radroots_authority::RadrootsEventSigner; - use radroots_sdk::{SdkStorageKind, StorageStatusRequest}; + use radroots_sdk::{ProxyAuth, SdkStorageKind, StorageStatusRequest}; use radroots_secret_vault::RadrootsSecretBackend; use tempfile::tempdir; @@ -1126,6 +1179,63 @@ mod tests { } #[test] + fn maps_proxy_token_file_to_sdk_profile_auth() { + let root = tempdir().expect("tempdir"); + let mut config = sample_config(root.path(), Vec::new()); + let token_file = root.path().join("proxy.token"); + fs::write(&token_file, "proxy-file-token\n").expect("write token file"); + config.transport.profile = TransportProfileKind::Proxy; + config.transport.proxy.url = "http://127.0.0.1:7070".to_owned(); + config.transport.proxy.token_file = Some(token_file); + + let sdk_config = CliSdkConfig::from_runtime_config(&config).expect("sdk config"); + + let TransportProfile::Proxy { profile } = sdk_config.transport_profile else { + panic!("expected proxy transport profile"); + }; + assert_eq!(profile.endpoint_url(), "http://127.0.0.1:7070"); + assert_eq!( + profile.auth(), + &ProxyAuth::BearerToken("proxy-file-token".to_owned()) + ); + } + + #[test] + fn maps_proxy_token_secret_id_to_sdk_profile_auth() { + let root = tempdir().expect("tempdir"); + let mut config = sample_config(root.path(), Vec::new()); + config.transport.profile = TransportProfileKind::Proxy; + config.transport.proxy.url = "http://127.0.0.1:7070".to_owned(); + config.transport.proxy.token_secret_id = Some("proxy_token".to_owned()); + let vault = account::account_secret_vault(&config).expect("account vault"); + vault + .store_secret("proxy_token", "proxy-secret-token") + .expect("store proxy token"); + + let sdk_config = CliSdkConfig::from_runtime_config(&config).expect("sdk config"); + + let TransportProfile::Proxy { profile } = sdk_config.transport_profile else { + panic!("expected proxy transport profile"); + }; + assert_eq!( + profile.auth(), + &ProxyAuth::BearerToken("proxy-secret-token".to_owned()) + ); + } + + #[test] + fn proxy_token_resolution_rejects_empty_or_header_unsafe_tokens() { + assert!(matches!( + normalize_proxy_bearer_token(" \n", "proxy token"), + Err(RuntimeError::Config(message)) if message.contains("empty") + )); + assert!(matches!( + normalize_proxy_bearer_token("proxy\nsecret", "proxy token"), + Err(RuntimeError::Config(message)) if message.contains("control characters") + )); + } + + #[test] fn maps_localhost_ws_relays_to_localhost_sdk_policy() { let root = tempdir().expect("tempdir"); let config = sample_config(root.path(), vec!["ws://127.0.0.1:8080".to_owned()]); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -519,23 +519,15 @@ fn handle_proxy_connection(mut stream: TcpStream, expected_token: Option<&str>) } let request: Value = serde_json::from_str(body.as_str()).expect("proxy json"); assert_eq!(request["jsonrpc"], "2.0"); - assert_eq!(request["method"], "publish.event"); + assert_eq!(request["method"], "transport.publish.event"); let event = &request["params"]["event"]; - let relays = request["params"]["relays"] - .as_array() - .cloned() - .unwrap_or_default(); - let relay_results = relays - .iter() - .map(|relay| { - json!({ - "relay_url": relay, - "source": "request", - "attempted": true, - "outcome_kind": "accepted" - }) - }) - .collect::<Vec<_>>(); + let target_result = json!({ + "transport_kind": "nostr", + "endpoint_uri": "wss://daemon-resolved.radroots.test", + "source": "daemon_default", + "attempted": true, + "outcome_kind": "accepted" + }); let response = json!({ "jsonrpc": "2.0", "id": request["id"], @@ -549,15 +541,15 @@ fn handle_proxy_connection(mut stream: TcpStream, expected_token: Option<&str>) "event_id": event["id"], "pubkey": event["pubkey"], "event_kind": event["kind"], - "relay_policy": request["params"]["relay_policy"], + "target_policy": request["params"]["target_policy"], "delivery_policy": request["params"]["delivery_policy"], - "relay_count": relay_results.len(), - "acknowledged_count": relay_results.len(), + "target_count": 1, + "acknowledged_count": 1, "retryable_count": 0, "terminal_count": 0, "requested_at_ms": 1_700_000_000_000_i64, "completed_at_ms": 1_700_000_000_001_i64, - "relays": relay_results + "targets": [target_result] } } }); @@ -1853,7 +1845,7 @@ fn proxy_listing_publish_update_and_archive_dry_run_without_direct_publish_relay #[test] fn proxy_listing_publish_non_dry_run_uses_local_jsonrpc_server() { let sandbox = RadrootsCliSandbox::new(); - let proxy = ProxyJsonRpcServer::once(None); + let proxy = ProxyJsonRpcServer::once(Some("proxy_test_token")); let token_file = proxy_token_file(&sandbox); sandbox.write_app_config( format!( @@ -1930,9 +1922,9 @@ token_file = "{}" value["result"]["event_id"] ); assert_eq!( - request["params"]["relays"] + request["params"]["target_policy"]["relay_urls"] .as_array() - .expect("relays") + .expect("relay urls") .len(), 0 ); @@ -2105,7 +2097,7 @@ fn proxy_listing_publish_uses_myc_nip46_sdk_signer() { user_keys, Nip46RelayFinish::SignResponse, ); - let proxy = ProxyJsonRpcServer::once(None); + let proxy = ProxyJsonRpcServer::once(Some("proxy_test_token")); let token_file = proxy_token_file(&sandbox); let public_identity_file = write_public_identity_profile(&sandbox, "myc-proxy-user", &user_public);