runtime.rs (41142B)
1 use crate::app::identity_storage::DaemonIdentity; 2 use anyhow::{Context, Result, bail}; 3 use jsonrpsee::server::ServerHandle; 4 use std::time::Duration; 5 use tracing::{info, warn}; 6 #[cfg(not(test))] 7 use tracing_subscriber::EnvFilter; 8 9 use crate::app::identity_storage::load_service_identity; 10 use crate::app::{cli, config, paths}; 11 use crate::core::Radrootsd; 12 use crate::core::transport_publish::{ 13 PublishPrincipalInit, TransportPublishStore, generate_bearer_token, hash_bearer_token, 14 parse_explicit_transport_kind, parse_nostr_source_policy, parse_target_policy, 15 write_token_file, 16 }; 17 use crate::host_nostr::{ApplicationHandlerSpec, Kind, build_application_handler, build_profile}; 18 use crate::transport::jsonrpc; 19 #[cfg(not(test))] 20 use crate::transport::nostr::listener::spawn_nip46_listener; 21 #[cfg(not(test))] 22 use clap::Parser; 23 use radroots_event::profile::AuthoredProfile; 24 use std::path::PathBuf; 25 26 #[cfg(test)] 27 type RunLoadHookValue = Result<(cli::Args, config::Settings), String>; 28 29 #[cfg(test)] 30 type RunLoadHook = std::sync::Mutex<Option<RunLoadHookValue>>; 31 32 #[cfg(test)] 33 static RUN_LOAD_HOOK: std::sync::OnceLock<RunLoadHook> = std::sync::OnceLock::new(); 34 35 #[cfg(test)] 36 static RUN_BOOTSTRAP_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<Result<(), String>>>> = 37 std::sync::OnceLock::new(); 38 39 #[cfg(test)] 40 static RUN_WAIT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<RunWaitOutcome>>> = 41 std::sync::OnceLock::new(); 42 43 #[cfg(test)] 44 static RUN_START_RPC_HOOK: std::sync::OnceLock< 45 std::sync::Mutex<Option<Result<ServerHandle, String>>>, 46 > = std::sync::OnceLock::new(); 47 48 #[derive(Clone, Copy)] 49 enum RunWaitOutcome { 50 Shutdown, 51 Stopped, 52 } 53 54 #[cfg(not(test))] 55 fn init_logging(logs_dir: &std::path::Path, default_level: Option<&str>) -> Result<()> { 56 std::fs::create_dir_all(logs_dir) 57 .with_context(|| format!("create log directory {}", logs_dir.display()))?; 58 let appender = tracing_appender::rolling::never(logs_dir, "radrootsd.log"); 59 let (writer, guard) = tracing_appender::non_blocking(appender); 60 static LOG_GUARD: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> = 61 std::sync::OnceLock::new(); 62 let filter = std::env::var("RADROOTS_LOG_LEVEL") 63 .or_else(|_| std::env::var("RUST_LOG")) 64 .ok() 65 .or_else(|| default_level.map(str::to_owned)) 66 .unwrap_or_else(|| "info".to_owned()); 67 tracing_subscriber::fmt() 68 .with_env_filter(EnvFilter::new(filter)) 69 .with_writer(writer) 70 .try_init() 71 .map_err(|error| anyhow::anyhow!("initialize logging: {error}"))?; 72 LOG_GUARD 73 .set(guard) 74 .map_err(|_| anyhow::anyhow!("logging is already initialized"))?; 75 Ok(()) 76 } 77 78 #[cfg(not(test))] 79 async fn shutdown_signal() { 80 let ctrl_c = async { 81 tokio::signal::ctrl_c() 82 .await 83 .expect("failed to install Ctrl+C handler"); 84 }; 85 #[cfg(unix)] 86 let terminate = async { 87 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 88 .expect("failed to install signal handler") 89 .recv() 90 .await; 91 }; 92 #[cfg(not(unix))] 93 let terminate = std::future::pending::<()>(); 94 tokio::select! { 95 _ = ctrl_c => {}, 96 _ = terminate => {}, 97 } 98 } 99 100 #[derive(Debug, Clone, PartialEq, Eq)] 101 struct RadrootsdRuntimeStartupReport { 102 active_profile: String, 103 config_path: PathBuf, 104 config_path_source: String, 105 canonical_config_path: PathBuf, 106 logs_dir: PathBuf, 107 logs_dir_source: String, 108 canonical_logs_dir: PathBuf, 109 identity_path: PathBuf, 110 identity_path_source: String, 111 canonical_identity_path: PathBuf, 112 transport_publish_database_path: PathBuf, 113 transport_publish_database_path_source: String, 114 canonical_transport_publish_database_path: PathBuf, 115 path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput, 116 default_shared_secret_backend: String, 117 allowed_shared_secret_backends: Vec<String>, 118 } 119 120 #[cfg(test)] 121 fn run_load_hook() -> &'static RunLoadHook { 122 RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 123 } 124 125 #[cfg(test)] 126 fn run_bootstrap_hook() -> &'static std::sync::Mutex<Option<Result<(), String>>> { 127 RUN_BOOTSTRAP_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 128 } 129 130 #[cfg(test)] 131 fn run_wait_hook() -> &'static std::sync::Mutex<Option<RunWaitOutcome>> { 132 RUN_WAIT_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 133 } 134 135 #[cfg(test)] 136 fn run_start_rpc_hook() -> &'static std::sync::Mutex<Option<Result<ServerHandle, String>>> { 137 RUN_START_RPC_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 138 } 139 140 #[cfg(test)] 141 fn take_load_hook_result() -> Option<Result<(cli::Args, config::Settings), String>> { 142 run_load_hook() 143 .lock() 144 .unwrap_or_else(std::sync::PoisonError::into_inner) 145 .take() 146 } 147 148 #[cfg(test)] 149 fn take_bootstrap_hook_result() -> Option<Result<(), String>> { 150 run_bootstrap_hook() 151 .lock() 152 .unwrap_or_else(std::sync::PoisonError::into_inner) 153 .take() 154 } 155 156 #[cfg(not(test))] 157 fn take_bootstrap_hook_result() -> Option<Result<(), String>> { 158 None 159 } 160 161 #[cfg(test)] 162 fn take_wait_hook_result() -> Option<RunWaitOutcome> { 163 run_wait_hook() 164 .lock() 165 .unwrap_or_else(std::sync::PoisonError::into_inner) 166 .take() 167 } 168 169 #[cfg(test)] 170 fn take_start_rpc_hook_result() -> Option<Result<ServerHandle, String>> { 171 run_start_rpc_hook() 172 .lock() 173 .unwrap_or_else(std::sync::PoisonError::into_inner) 174 .take() 175 } 176 177 fn load_args_and_settings() -> Result<(cli::Args, config::Settings)> { 178 #[cfg(test)] 179 { 180 if let Some(result) = take_load_hook_result() { 181 return result.map_err(anyhow::Error::msg); 182 } 183 Err(anyhow::anyhow!("run loader hook not set")) 184 } 185 186 #[cfg(not(test))] 187 { 188 let args = cli::Args::try_parse()?; 189 let config_path = args 190 .service 191 .config 192 .clone() 193 .map(Ok) 194 .unwrap_or_else(paths::default_config_path_for_process)?; 195 let settings = 196 config::load_settings_from_path(&config_path).context("load configuration")?; 197 init_logging( 198 std::path::Path::new(settings.config.service.logs_dir.as_str()), 199 None, 200 )?; 201 Ok((args, settings)) 202 } 203 } 204 205 fn runtime_startup_report( 206 args: &cli::Args, 207 settings: &config::Settings, 208 contract: &paths::RadrootsdRuntimeContractOutput, 209 ) -> RadrootsdRuntimeStartupReport { 210 RadrootsdRuntimeStartupReport { 211 active_profile: contract.active_profile.clone(), 212 config_path: args 213 .service 214 .config 215 .clone() 216 .unwrap_or_else(|| contract.canonical_config_path.clone()), 217 config_path_source: cli_or_profile_path_source( 218 args.service.config.is_some(), 219 &args 220 .service 221 .config 222 .clone() 223 .unwrap_or_else(|| contract.canonical_config_path.clone()), 224 &contract.canonical_config_path, 225 ), 226 canonical_config_path: contract.canonical_config_path.clone(), 227 logs_dir: PathBuf::from(settings.config.service.logs_dir.as_str()), 228 logs_dir_source: config_or_profile_path_source( 229 &PathBuf::from(settings.config.service.logs_dir.as_str()), 230 &contract.canonical_logs_dir, 231 ), 232 canonical_logs_dir: contract.canonical_logs_dir.clone(), 233 identity_path: args 234 .service 235 .identity 236 .clone() 237 .unwrap_or_else(|| contract.canonical_identity_path.clone()), 238 identity_path_source: cli_or_profile_path_source( 239 args.service.identity.is_some(), 240 &args 241 .service 242 .identity 243 .clone() 244 .unwrap_or_else(|| contract.canonical_identity_path.clone()), 245 &contract.canonical_identity_path, 246 ), 247 canonical_identity_path: contract.canonical_identity_path.clone(), 248 transport_publish_database_path: settings.config.transport_publish.database_path.clone(), 249 transport_publish_database_path_source: config_or_profile_path_source( 250 &settings.config.transport_publish.database_path, 251 &contract.canonical_transport_publish_database_path, 252 ), 253 canonical_transport_publish_database_path: contract 254 .canonical_transport_publish_database_path 255 .clone(), 256 path_overrides: contract.path_overrides.clone(), 257 default_shared_secret_backend: contract.default_shared_secret_backend.clone(), 258 allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), 259 } 260 } 261 262 fn cli_or_profile_path_source( 263 is_cli_arg: bool, 264 actual_path: &PathBuf, 265 canonical_path: &PathBuf, 266 ) -> String { 267 if is_cli_arg { 268 "cli_arg".to_owned() 269 } else { 270 config_or_profile_path_source(actual_path, canonical_path) 271 } 272 } 273 274 fn config_or_profile_path_source(actual_path: &PathBuf, canonical_path: &PathBuf) -> String { 275 if actual_path == canonical_path { 276 "profile_default".to_owned() 277 } else { 278 "config_artifact".to_owned() 279 } 280 } 281 282 #[cfg(not(test))] 283 fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) { 284 info!( 285 active_profile = report.active_profile.as_str(), 286 profile_source = report.path_overrides.profile_source.as_str(), 287 root_source = report.path_overrides.root_source.as_str(), 288 repo_local_root = ?report.path_overrides.repo_local_root, 289 repo_local_root_source = ?report.path_overrides.repo_local_root_source, 290 subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(), 291 config_path = %report.config_path.display(), 292 config_path_source = report.config_path_source.as_str(), 293 canonical_config_path = %report.canonical_config_path.display(), 294 logs_dir = %report.logs_dir.display(), 295 logs_dir_source = report.logs_dir_source.as_str(), 296 canonical_logs_dir = %report.canonical_logs_dir.display(), 297 identity_path = %report.identity_path.display(), 298 identity_path_source = report.identity_path_source.as_str(), 299 canonical_identity_path = %report.canonical_identity_path.display(), 300 transport_publish_database_path = %report.transport_publish_database_path.display(), 301 transport_publish_database_path_source = report.transport_publish_database_path_source.as_str(), 302 canonical_transport_publish_database_path = %report.canonical_transport_publish_database_path.display(), 303 default_shared_secret_backend = report.default_shared_secret_backend.as_str(), 304 allowed_shared_secret_backends = ?report.allowed_shared_secret_backends, 305 "radrootsd runtime contract" 306 ); 307 } 308 309 #[cfg_attr(coverage_nightly, coverage(off))] 310 async fn bootstrap_presence( 311 client: &crate::host_nostr::DaemonNostrClient, 312 identity: &DaemonIdentity, 313 profile: &AuthoredProfile, 314 handler_spec: &ApplicationHandlerSpec, 315 ) -> Result<()> { 316 if let Some(result) = take_bootstrap_hook_result() { 317 return result.map_err(anyhow::Error::msg); 318 } 319 320 let (profile_event, handler_event) = 321 build_service_presence_events(identity, profile, handler_spec)?; 322 323 client.connect().await; 324 client.wait_for_connection(Duration::from_secs(5)).await; 325 if !client 326 .relays() 327 .await 328 .into_values() 329 .any(|relay| relay.is_connected()) 330 { 331 bail!("service presence requires at least one connected relay"); 332 } 333 334 publish_presence_event(client, &profile_event, "service Profile").await?; 335 publish_presence_event(client, &handler_event, "NIP-89 application handler").await?; 336 Ok(()) 337 } 338 339 fn build_service_presence_events( 340 identity: &DaemonIdentity, 341 profile: &AuthoredProfile, 342 handler_spec: &ApplicationHandlerSpec, 343 ) -> Result<(nostr::Event, nostr::Event)> { 344 let profile_event = build_profile(profile) 345 .context("build service Profile event")? 346 .sign_with_keys(identity.keys()) 347 .context("sign service Profile event")?; 348 let handler_event = build_application_handler(handler_spec) 349 .context("build NIP-89 application handler event")? 350 .sign_with_keys(identity.keys()) 351 .context("sign NIP-89 application handler event")?; 352 Ok((profile_event, handler_event)) 353 } 354 355 async fn publish_presence_event( 356 client: &crate::host_nostr::DaemonNostrClient, 357 event: &nostr::Event, 358 event_name: &str, 359 ) -> Result<()> { 360 let output = client 361 .send_event(event) 362 .await 363 .with_context(|| format!("publish {event_name}"))?; 364 if output.success.is_empty() { 365 bail!("{event_name} publication did not succeed on any relay"); 366 } 367 Ok(()) 368 } 369 370 #[cfg_attr(coverage_nightly, coverage(off))] 371 async fn publish_service_presence( 372 client: crate::host_nostr::DaemonNostrClient, 373 identity: DaemonIdentity, 374 profile: AuthoredProfile, 375 metadata: crate::host_nostr::Metadata, 376 service_cfg: config::NostrServiceConfig, 377 nip46_config: config::Nip46Config, 378 ) -> Result<()> { 379 let kinds = service_presence_kinds(); 380 let mut handler_spec = ApplicationHandlerSpec::new(kinds) 381 .with_metadata(metadata.clone()) 382 .with_extra_tags(service_cfg.nip89_extra_tags.clone()) 383 .with_relays(service_cfg.relays.clone()); 384 if let Some(identifier) = service_cfg.nip89_identifier.clone() { 385 handler_spec = handler_spec.with_identifier(identifier); 386 } 387 if let Some(url) = nip46_config.nostrconnect_url.clone() { 388 handler_spec = handler_spec.with_nostr_connect_url(url); 389 } 390 bootstrap_presence(&client, &identity, &profile, &handler_spec).await 391 } 392 393 #[cfg_attr(coverage_nightly, coverage(off))] 394 async fn maybe_publish_service_presence( 395 client: crate::host_nostr::DaemonNostrClient, 396 identity: DaemonIdentity, 397 profile: AuthoredProfile, 398 metadata: crate::host_nostr::Metadata, 399 service_cfg: config::NostrServiceConfig, 400 nip46_config: config::Nip46Config, 401 ) { 402 #[cfg(test)] 403 { 404 let result = publish_service_presence( 405 client, 406 identity, 407 profile, 408 metadata, 409 service_cfg, 410 nip46_config, 411 ) 412 .await; 413 if let Err(err) = result { 414 warn!("Failed to publish service presence on startup: {err}"); 415 } else { 416 info!("Published service presence on startup"); 417 } 418 } 419 420 #[cfg(not(test))] 421 tokio::spawn(async move { 422 let result = publish_service_presence( 423 client, 424 identity, 425 profile, 426 metadata, 427 service_cfg, 428 nip46_config, 429 ) 430 .await; 431 if let Err(err) = result { 432 warn!("Failed to publish service presence on startup: {err}"); 433 } else { 434 info!("Published service presence on startup"); 435 } 436 }); 437 } 438 439 #[cfg(not(test))] 440 #[cfg_attr(coverage_nightly, coverage(off))] 441 fn spawn_nip46_listener_io(radrootsd: Radrootsd) { 442 spawn_nip46_listener(radrootsd); 443 } 444 445 #[cfg(test)] 446 fn spawn_nip46_listener_io(_radrootsd: Radrootsd) {} 447 448 #[cfg(test)] 449 async fn start_rpc_io( 450 state: Radrootsd, 451 addr: std::net::SocketAddr, 452 rpc_cfg: &config::RpcConfig, 453 ) -> Result<ServerHandle> { 454 if let Some(result) = take_start_rpc_hook_result() { 455 return result.map_err(anyhow::Error::msg); 456 } 457 jsonrpc::start_rpc(state, addr, rpc_cfg).await 458 } 459 460 #[cfg(not(test))] 461 #[cfg_attr(coverage_nightly, coverage(off))] 462 async fn start_rpc_io( 463 state: Radrootsd, 464 addr: std::net::SocketAddr, 465 rpc_cfg: &config::RpcConfig, 466 ) -> Result<ServerHandle> { 467 jsonrpc::start_rpc(state, addr, rpc_cfg).await 468 } 469 470 #[cfg(test)] 471 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { 472 if let Some(outcome) = take_wait_hook_result() { 473 return outcome; 474 } 475 handle.stopped().await; 476 RunWaitOutcome::Stopped 477 } 478 479 #[cfg(not(test))] 480 #[cfg_attr(coverage_nightly, coverage(off))] 481 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { 482 tokio::select! { 483 _ = shutdown_signal() => RunWaitOutcome::Shutdown, 484 _ = handle.stopped() => RunWaitOutcome::Stopped, 485 } 486 } 487 488 async fn handle_command(command: cli::Command, settings: &config::Settings) -> Result<()> { 489 match command { 490 cli::Command::TransportPublish(command) => match command.command { 491 cli::TransportPublishSubcommand::Principal(command) => match command.command { 492 cli::PrincipalSubcommand::Init(args) => { 493 let token = generate_bearer_token(); 494 let token_hash = hash_bearer_token(token.as_str()); 495 let store = TransportPublishStore::open( 496 settings.config.transport_publish.database_path.clone(), 497 )?; 498 let allowed_target_policies = args 499 .allowed_target_policy 500 .iter() 501 .map(|policy| parse_target_policy(policy.as_str())) 502 .collect::<Result<Vec<_>, _>>()?; 503 let allowed_explicit_transport_kinds = args 504 .allowed_explicit_transport_kind 505 .iter() 506 .map(|transport_kind| { 507 parse_explicit_transport_kind(transport_kind.as_str()) 508 }) 509 .collect::<Result<Vec<_>, _>>()?; 510 let allowed_nostr_source_policies = args 511 .allowed_nostr_source_policy 512 .iter() 513 .map(|policy| parse_nostr_source_policy(policy.as_str())) 514 .collect::<Result<Vec<_>, _>>()?; 515 let principal = store.create_principal(PublishPrincipalInit { 516 label: args.label, 517 token_hash, 518 allowed_pubkeys: args.allowed_pubkey, 519 allowed_kinds: args.allowed_kind, 520 allowed_target_policies, 521 allowed_explicit_transport_kinds, 522 allowed_nostr_source_policies, 523 allow_request_targets: args.allow_request_targets, 524 job_visibility: args.job_visibility.parse()?, 525 expires_at_unix: None, 526 })?; 527 write_token_file(&args.token_file, token.as_str())?; 528 println!( 529 "{}", 530 serde_json::json!({ 531 "principal_id": principal.principal_id, 532 "label": principal.label, 533 "token_file": args.token_file, 534 "database_path": settings.config.transport_publish.database_path, 535 }) 536 ); 537 Ok(()) 538 } 539 }, 540 }, 541 } 542 } 543 544 pub async fn run() -> Result<()> { 545 let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?; 546 settings.validate()?; 547 let authored_profile = settings.authored_profile()?; 548 549 #[cfg(not(test))] 550 { 551 let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?; 552 let report = runtime_startup_report(&args, &settings, &contract); 553 log_runtime_startup_report(&report); 554 } 555 556 if let Some(command) = args.command.clone() { 557 return handle_command(command, &settings).await; 558 } 559 560 info!("Starting radrootsd"); 561 562 let identity = load_service_identity( 563 args.service.identity.as_deref(), 564 args.service.allow_generate_identity, 565 )?; 566 let radrootsd = Radrootsd::new( 567 identity.clone(), 568 settings.config.transport_publish.clone(), 569 settings.config.nip46.clone(), 570 ); 571 let radrootsd = radrootsd?; 572 573 for relay in settings.config.service.relays.iter() { 574 radrootsd.client.add_relay(relay).await?; 575 } 576 577 if !settings.config.service.relays.is_empty() { 578 maybe_publish_service_presence( 579 radrootsd.client.clone(), 580 identity.clone(), 581 authored_profile, 582 settings.metadata.clone(), 583 settings.config.service.clone(), 584 settings.config.nip46.clone(), 585 ) 586 .await; 587 588 spawn_nip46_listener_io(radrootsd.clone()); 589 } 590 591 let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?; 592 let handle = start_rpc_io(radrootsd.clone(), addr, &settings.config.rpc).await?; 593 info!("JSON-RPC listening on {addr}"); 594 595 let stop_handle = handle.clone(); 596 597 match wait_for_shutdown_or_stopped(handle).await { 598 RunWaitOutcome::Shutdown => { 599 info!("Shutting down…"); 600 let _ = stop_handle.stop(); 601 } 602 RunWaitOutcome::Stopped => {} 603 } 604 605 Ok(()) 606 } 607 608 fn service_presence_kinds() -> Vec<u32> { 609 let mut kinds = vec![Kind::NostrConnect.as_u16() as u32]; 610 kinds.sort_unstable(); 611 kinds.dedup(); 612 kinds 613 } 614 615 #[cfg(test)] 616 #[cfg_attr(coverage_nightly, coverage(off))] 617 mod tests { 618 use super::{ 619 RadrootsdRuntimeStartupReport, RunWaitOutcome, build_service_presence_events, run, 620 run_bootstrap_hook, run_load_hook, run_start_rpc_hook, run_wait_hook, 621 runtime_startup_report, 622 }; 623 use crate::app::identity_storage::DaemonIdentity; 624 use crate::app::{cli, config, paths}; 625 use crate::core::Radrootsd; 626 use crate::host_nostr::{ApplicationHandlerSpec, Kind, Metadata}; 627 use crate::transport::jsonrpc; 628 use std::path::Path; 629 use std::path::PathBuf; 630 use tokio::sync::{Mutex, MutexGuard}; 631 632 static TEST_LOCK: Mutex<()> = Mutex::const_new(()); 633 634 async fn test_guard() -> MutexGuard<'static, ()> { 635 let guard = TEST_LOCK.lock().await; 636 *run_load_hook() 637 .lock() 638 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 639 *run_bootstrap_hook() 640 .lock() 641 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 642 *run_wait_hook() 643 .lock() 644 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 645 *run_start_rpc_hook() 646 .lock() 647 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 648 guard 649 } 650 651 fn unique_identity_path(suffix: &str) -> PathBuf { 652 let nanos = std::time::SystemTime::now() 653 .duration_since(std::time::UNIX_EPOCH) 654 .expect("time") 655 .as_nanos(); 656 std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.secret.json")) 657 } 658 659 fn cleanup_identity_artifacts(path: &Path) { 660 let _ = std::fs::remove_file(path); 661 let _ = std::fs::remove_file(crate::app::identity_storage::encrypted_identity_key_path( 662 path, 663 )); 664 } 665 666 fn args_for_identity(path: PathBuf, allow_generate: bool) -> cli::Args { 667 cli::Args { 668 service: cli::ServiceCliArgs { 669 config: Some(PathBuf::from("config.toml")), 670 identity: Some(path), 671 allow_generate_identity: allow_generate, 672 }, 673 command: None, 674 } 675 } 676 677 fn settings_with_relays(relays: Vec<String>) -> config::Settings { 678 let metadata: Metadata = 679 serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); 680 config::Settings { 681 metadata, 682 config: config::Configuration { 683 service: config::NostrServiceConfig { 684 logs_dir: "logs".to_string(), 685 relays, 686 nip89_identifier: Some("radrootsd".to_string()), 687 nip89_extra_tags: Vec::new(), 688 }, 689 rpc: config::RpcConfig { 690 addr: "127.0.0.1:0".to_string(), 691 ..config::RpcConfig::default() 692 }, 693 rpc_addr: Some("127.0.0.1:0".to_string()), 694 nip46: config::Nip46Config::default(), 695 transport_publish: config::TransportPublishConfig::default(), 696 }, 697 } 698 } 699 700 fn sample_runtime_contract() -> paths::RadrootsdRuntimeContractOutput { 701 paths::RadrootsdRuntimeContractOutput { 702 active_profile: "interactive_user".to_string(), 703 allowed_profiles: vec![ 704 "interactive_user".to_string(), 705 "service_host".to_string(), 706 "repo_local".to_string(), 707 ], 708 path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput { 709 profile_source: "caller".to_string(), 710 root_source: "host_defaults".to_string(), 711 repo_local_root: None, 712 repo_local_root_source: None, 713 subordinate_path_override_source: "config_artifact".to_string(), 714 subordinate_path_override_keys: vec![ 715 "config.service.logs_dir".to_string(), 716 "config.transport_publish.database_path".to_string(), 717 ], 718 }, 719 default_shared_secret_backend: "encrypted_file".to_string(), 720 allowed_shared_secret_backends: vec!["encrypted_file".to_string()], 721 canonical_config_path: PathBuf::from( 722 "/home/treesap/.radroots/config/services/radrootsd/config.toml", 723 ), 724 canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd"), 725 canonical_identity_path: PathBuf::from( 726 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json", 727 ), 728 canonical_transport_publish_database_path: PathBuf::from( 729 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite", 730 ), 731 } 732 } 733 734 async fn make_handle(settings: &config::Settings) -> jsonrpsee::server::ServerHandle { 735 let identity = DaemonIdentity::generate(); 736 let state = Radrootsd::new( 737 identity, 738 settings.config.transport_publish.clone(), 739 settings.config.nip46.clone(), 740 ) 741 .expect("state"); 742 jsonrpc::start_rpc( 743 state, 744 "127.0.0.1:0".parse().expect("addr"), 745 &settings.config.rpc, 746 ) 747 .await 748 .expect("rpc handle") 749 } 750 751 #[tokio::test] 752 async fn run_returns_error_when_hook_is_missing() { 753 let _guard = test_guard().await; 754 let err = run().await.expect_err("missing loader hook should error"); 755 let msg = format!("{err:#}"); 756 assert!(msg.contains("run loader hook not set")); 757 } 758 759 #[tokio::test] 760 async fn run_returns_error_when_identity_missing() { 761 let _guard = test_guard().await; 762 let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.secret.json"), false); 763 let settings = settings_with_relays(Vec::new()); 764 *run_load_hook() 765 .lock() 766 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 767 let err = run().await.expect_err("missing identity should error"); 768 let msg = format!("{err:#}"); 769 assert!(msg.contains("identity")); 770 } 771 772 #[tokio::test] 773 async fn run_covers_shutdown_path_and_presence_success() { 774 let _guard = test_guard().await; 775 let path = unique_identity_path("shutdown"); 776 let args = args_for_identity(path.clone(), true); 777 let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); 778 let handle = make_handle(&settings).await; 779 *run_load_hook() 780 .lock() 781 .unwrap_or_else(std::sync::PoisonError::into_inner) = 782 Some(Ok((args, settings.clone()))); 783 *run_start_rpc_hook() 784 .lock() 785 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 786 *run_wait_hook() 787 .lock() 788 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); 789 *run_bootstrap_hook() 790 .lock() 791 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(())); 792 assert!(run().await.is_ok()); 793 cleanup_identity_artifacts(&path); 794 } 795 796 #[tokio::test] 797 async fn run_covers_stopped_path_and_presence_failure() { 798 let _guard = test_guard().await; 799 let path = unique_identity_path("stopped"); 800 let args = args_for_identity(path.clone(), true); 801 let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); 802 let handle = make_handle(&settings).await; 803 let _ = handle.stop(); 804 *run_load_hook() 805 .lock() 806 .unwrap_or_else(std::sync::PoisonError::into_inner) = 807 Some(Ok((args, settings.clone()))); 808 *run_start_rpc_hook() 809 .lock() 810 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 811 *run_wait_hook() 812 .lock() 813 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Stopped); 814 *run_bootstrap_hook() 815 .lock() 816 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("boom".to_string())); 817 assert!(run().await.is_ok()); 818 cleanup_identity_artifacts(&path); 819 } 820 821 #[tokio::test] 822 async fn run_skips_presence_when_relays_empty() { 823 let _guard = test_guard().await; 824 let path = unique_identity_path("empty"); 825 let args = args_for_identity(path.clone(), true); 826 let settings = settings_with_relays(Vec::new()); 827 let handle = make_handle(&settings).await; 828 *run_load_hook() 829 .lock() 830 .unwrap_or_else(std::sync::PoisonError::into_inner) = 831 Some(Ok((args, settings.clone()))); 832 *run_start_rpc_hook() 833 .lock() 834 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 835 *run_wait_hook() 836 .lock() 837 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); 838 assert!(run().await.is_ok()); 839 cleanup_identity_artifacts(&path); 840 } 841 842 #[tokio::test] 843 async fn run_returns_error_when_relay_is_invalid() { 844 let _guard = test_guard().await; 845 let path = unique_identity_path("invalid-relay"); 846 let args = args_for_identity(path.clone(), true); 847 let settings = settings_with_relays(vec!["not-a-relay".to_string()]); 848 *run_load_hook() 849 .lock() 850 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 851 let err = run().await.expect_err("invalid relay should error"); 852 let msg = format!("{err:#}"); 853 assert!(!msg.is_empty()); 854 cleanup_identity_artifacts(&path); 855 } 856 857 #[tokio::test] 858 async fn run_returns_error_when_rpc_addr_is_invalid() { 859 let _guard = test_guard().await; 860 let path = unique_identity_path("invalid-rpc-addr"); 861 let args = args_for_identity(path.clone(), true); 862 let mut settings = settings_with_relays(Vec::new()); 863 settings.config.rpc_addr = Some("not-an-addr".to_string()); 864 *run_load_hook() 865 .lock() 866 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 867 let err = run().await.expect_err("invalid rpc addr should error"); 868 let msg = format!("{err:#}"); 869 assert!(msg.contains("invalid")); 870 cleanup_identity_artifacts(&path); 871 } 872 873 #[tokio::test] 874 async fn run_returns_error_when_rpc_start_fails() { 875 let _guard = test_guard().await; 876 let path = unique_identity_path("rpc-start-fail"); 877 let args = args_for_identity(path.clone(), true); 878 let settings = settings_with_relays(Vec::new()); 879 *run_load_hook() 880 .lock() 881 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 882 *run_start_rpc_hook() 883 .lock() 884 .unwrap_or_else(std::sync::PoisonError::into_inner) = 885 Some(Err("rpc start failed".to_string())); 886 let err = run().await.expect_err("rpc start hook should fail"); 887 let msg = format!("{err:#}"); 888 assert!(msg.contains("rpc start failed")); 889 cleanup_identity_artifacts(&path); 890 } 891 892 #[tokio::test] 893 async fn run_waits_for_stopped_when_wait_hook_is_not_set() { 894 let _guard = test_guard().await; 895 let path = unique_identity_path("wait-no-hook"); 896 let args = args_for_identity(path.clone(), true); 897 let settings = settings_with_relays(Vec::new()); 898 let handle = make_handle(&settings).await; 899 let _ = handle.stop(); 900 *run_load_hook() 901 .lock() 902 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 903 *run_start_rpc_hook() 904 .lock() 905 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 906 assert!(run().await.is_ok()); 907 cleanup_identity_artifacts(&path); 908 } 909 910 #[tokio::test] 911 async fn run_starts_rpc_when_start_hook_is_not_set() { 912 let _guard = test_guard().await; 913 let path = unique_identity_path("start-rpc-real"); 914 let args = args_for_identity(path.clone(), true); 915 let settings = settings_with_relays(Vec::new()); 916 *run_load_hook() 917 .lock() 918 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 919 *run_wait_hook() 920 .lock() 921 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); 922 assert!(run().await.is_ok()); 923 cleanup_identity_artifacts(&path); 924 } 925 926 #[test] 927 fn service_presence_kinds_include_nostr_connect_only() { 928 let kinds = super::service_presence_kinds(); 929 930 assert!(kinds.contains(&(crate::host_nostr::Kind::NostrConnect.as_u16() as u32))); 931 assert_eq!(kinds.len(), 1); 932 } 933 934 #[test] 935 fn service_presence_events_use_strict_profile_and_nip89_contracts() { 936 let identity = DaemonIdentity::generate(); 937 let settings = settings_with_relays(vec!["wss://relay.example.com".to_owned()]); 938 let profile = settings.authored_profile().expect("authored profile"); 939 let handler_spec = ApplicationHandlerSpec::new(super::service_presence_kinds()) 940 .with_identifier("radrootsd") 941 .with_metadata(settings.metadata) 942 .with_relays(settings.config.service.relays); 943 944 let (profile_event, handler_event) = 945 build_service_presence_events(&identity, &profile, &handler_spec) 946 .expect("service presence events"); 947 948 assert_eq!(profile_event.kind, Kind::Metadata); 949 assert!(profile_event.tags.is_empty()); 950 assert_eq!(profile_event.pubkey, identity.public_key()); 951 assert_eq!( 952 serde_json::from_str::<serde_json::Value>(&profile_event.content) 953 .expect("profile JSON"), 954 serde_json::json!({"name": "radrootsd-test"}) 955 ); 956 assert_eq!( 957 handler_event.kind, 958 Kind::Custom(radroots_event::envelope::kind::KIND_APPLICATION_HANDLER as u16) 959 ); 960 assert_eq!(handler_event.pubkey, identity.public_key()); 961 assert!( 962 handler_event.tags.iter().any(|tag| { 963 tag.as_slice() == ["d".to_owned(), "radrootsd".to_owned()].as_slice() 964 }) 965 ); 966 assert!(handler_event.tags.iter().any(|tag| { 967 tag.as_slice() == ["k".to_owned(), Kind::NostrConnect.as_u16().to_string()].as_slice() 968 })); 969 assert!(handler_event.tags.iter().any(|tag| { 970 tag.as_slice() == ["relay".to_owned(), "wss://relay.example.com".to_owned()].as_slice() 971 })); 972 assert!(handler_event.verify().is_ok()); 973 } 974 975 #[tokio::test] 976 async fn bootstrap_presence_fails_closed_without_a_connected_relay() { 977 let _guard = test_guard().await; 978 let identity = DaemonIdentity::generate(); 979 let client = crate::host_nostr::DaemonNostrClient::from_identity(&identity); 980 let settings = settings_with_relays(Vec::new()); 981 let profile = settings.authored_profile().expect("authored profile"); 982 let handler_spec = ApplicationHandlerSpec::new(super::service_presence_kinds()) 983 .with_identifier("radrootsd") 984 .with_metadata(settings.metadata); 985 986 let error = super::bootstrap_presence(&client, &identity, &profile, &handler_spec) 987 .await 988 .expect_err("missing relay connection must fail closed"); 989 assert!(error.to_string().contains("at least one connected relay")); 990 } 991 992 #[test] 993 fn runtime_startup_report_prefers_explicit_cli_paths() { 994 let args = cli::Args { 995 service: cli::ServiceCliArgs { 996 config: Some(PathBuf::from("/tmp/radrootsd/config.toml")), 997 identity: Some(PathBuf::from("/tmp/radrootsd/identity.secret.json")), 998 allow_generate_identity: false, 999 }, 1000 command: None, 1001 }; 1002 let mut settings = settings_with_relays(Vec::new()); 1003 settings.config.service.logs_dir = "/tmp/radrootsd/logs".to_string(); 1004 settings.config.transport_publish.database_path = 1005 PathBuf::from("/tmp/radrootsd/transport_publish.sqlite"); 1006 1007 let contract = sample_runtime_contract(); 1008 let report = runtime_startup_report(&args, &settings, &contract); 1009 1010 assert_eq!( 1011 report, 1012 RadrootsdRuntimeStartupReport { 1013 active_profile: "interactive_user".to_string(), 1014 config_path: PathBuf::from("/tmp/radrootsd/config.toml"), 1015 config_path_source: "cli_arg".to_string(), 1016 canonical_config_path: PathBuf::from( 1017 "/home/treesap/.radroots/config/services/radrootsd/config.toml" 1018 ), 1019 logs_dir: PathBuf::from("/tmp/radrootsd/logs"), 1020 logs_dir_source: "config_artifact".to_string(), 1021 canonical_logs_dir: PathBuf::from( 1022 "/home/treesap/.radroots/logs/services/radrootsd" 1023 ), 1024 identity_path: PathBuf::from("/tmp/radrootsd/identity.secret.json"), 1025 identity_path_source: "cli_arg".to_string(), 1026 canonical_identity_path: PathBuf::from( 1027 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json" 1028 ), 1029 transport_publish_database_path: PathBuf::from( 1030 "/tmp/radrootsd/transport_publish.sqlite" 1031 ), 1032 transport_publish_database_path_source: "config_artifact".to_string(), 1033 canonical_transport_publish_database_path: PathBuf::from( 1034 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite" 1035 ), 1036 path_overrides: sample_runtime_contract().path_overrides, 1037 default_shared_secret_backend: "encrypted_file".to_string(), 1038 allowed_shared_secret_backends: vec!["encrypted_file".to_string()], 1039 } 1040 ); 1041 } 1042 1043 #[test] 1044 fn runtime_startup_report_falls_back_to_canonical_contract_paths() { 1045 let args = cli::Args { 1046 service: cli::ServiceCliArgs { 1047 config: None, 1048 identity: None, 1049 allow_generate_identity: false, 1050 }, 1051 command: None, 1052 }; 1053 let contract = sample_runtime_contract(); 1054 let mut settings = settings_with_relays(Vec::new()); 1055 settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); 1056 settings.config.transport_publish.database_path = 1057 contract.canonical_transport_publish_database_path.clone(); 1058 1059 let report = runtime_startup_report(&args, &settings, &contract); 1060 1061 assert_eq!(report.config_path, contract.canonical_config_path); 1062 assert_eq!(report.config_path_source, "profile_default"); 1063 assert_eq!(report.logs_dir, contract.canonical_logs_dir); 1064 assert_eq!(report.logs_dir_source, "profile_default"); 1065 assert_eq!(report.identity_path, contract.canonical_identity_path); 1066 assert_eq!(report.identity_path_source, "profile_default"); 1067 assert_eq!( 1068 report.transport_publish_database_path, 1069 contract.canonical_transport_publish_database_path 1070 ); 1071 assert_eq!( 1072 report.transport_publish_database_path_source, 1073 "profile_default" 1074 ); 1075 assert_eq!(report.path_overrides, contract.path_overrides); 1076 assert_eq!(report.default_shared_secret_backend, "encrypted_file"); 1077 assert_eq!( 1078 report.allowed_shared_secret_backends, 1079 vec!["encrypted_file".to_string()] 1080 ); 1081 } 1082 }