runtime.rs (35802B)
1 use anyhow::Result; 2 use jsonrpsee::server::ServerHandle; 3 use radroots_identity::RadrootsIdentity; 4 use std::time::Duration; 5 use tracing::{info, warn}; 6 7 use crate::app::identity_storage::load_service_identity; 8 use crate::app::{cli, config, paths}; 9 use crate::core::Radrootsd; 10 use crate::transport::jsonrpc; 11 #[cfg(not(test))] 12 use crate::transport::nostr::listener::spawn_nip46_listener; 13 #[cfg(not(test))] 14 use anyhow::Context; 15 #[cfg(not(test))] 16 use clap::Parser; 17 use radroots_events::profile::RadrootsProfileType; 18 use radroots_nostr::prelude::{ 19 RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind, 20 radroots_nostr_bootstrap_service_presence, 21 }; 22 use std::path::PathBuf; 23 24 #[cfg(test)] 25 static RUN_LOAD_HOOK: std::sync::OnceLock< 26 std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>>, 27 > = std::sync::OnceLock::new(); 28 29 #[cfg(test)] 30 static RUN_BOOTSTRAP_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<Result<(), String>>>> = 31 std::sync::OnceLock::new(); 32 33 #[cfg(test)] 34 static RUN_WAIT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<RunWaitOutcome>>> = 35 std::sync::OnceLock::new(); 36 37 #[cfg(test)] 38 static RUN_START_RPC_HOOK: std::sync::OnceLock< 39 std::sync::Mutex<Option<Result<ServerHandle, String>>>, 40 > = std::sync::OnceLock::new(); 41 42 #[derive(Clone, Copy)] 43 enum RunWaitOutcome { 44 Shutdown, 45 Stopped, 46 } 47 48 #[derive(Debug, Clone, PartialEq, Eq)] 49 struct RadrootsdRuntimeStartupReport { 50 active_profile: String, 51 config_path: PathBuf, 52 config_path_source: String, 53 canonical_config_path: PathBuf, 54 logs_dir: PathBuf, 55 logs_dir_source: String, 56 canonical_logs_dir: PathBuf, 57 identity_path: PathBuf, 58 identity_path_source: String, 59 canonical_identity_path: PathBuf, 60 publish_proxy_database_path: PathBuf, 61 publish_proxy_database_path_source: String, 62 canonical_publish_proxy_database_path: PathBuf, 63 path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput, 64 migration: paths::RadrootsdRuntimeMigrationContractOutput, 65 default_shared_secret_backend: String, 66 allowed_shared_secret_backends: Vec<String>, 67 } 68 69 #[cfg(test)] 70 fn run_load_hook() 71 -> &'static std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>> { 72 RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 73 } 74 75 #[cfg(test)] 76 fn run_bootstrap_hook() -> &'static std::sync::Mutex<Option<Result<(), String>>> { 77 RUN_BOOTSTRAP_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 78 } 79 80 #[cfg(test)] 81 fn run_wait_hook() -> &'static std::sync::Mutex<Option<RunWaitOutcome>> { 82 RUN_WAIT_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 83 } 84 85 #[cfg(test)] 86 fn run_start_rpc_hook() -> &'static std::sync::Mutex<Option<Result<ServerHandle, String>>> { 87 RUN_START_RPC_HOOK.get_or_init(|| std::sync::Mutex::new(None)) 88 } 89 90 #[cfg(test)] 91 fn take_load_hook_result() -> Option<Result<(cli::Args, config::Settings), String>> { 92 run_load_hook() 93 .lock() 94 .unwrap_or_else(std::sync::PoisonError::into_inner) 95 .take() 96 } 97 98 #[cfg(test)] 99 fn take_bootstrap_hook_result() -> Option<Result<(), String>> { 100 run_bootstrap_hook() 101 .lock() 102 .unwrap_or_else(std::sync::PoisonError::into_inner) 103 .take() 104 } 105 106 #[cfg(not(test))] 107 fn take_bootstrap_hook_result() -> Option<Result<(), String>> { 108 None 109 } 110 111 #[cfg(test)] 112 fn take_wait_hook_result() -> Option<RunWaitOutcome> { 113 run_wait_hook() 114 .lock() 115 .unwrap_or_else(std::sync::PoisonError::into_inner) 116 .take() 117 } 118 119 #[cfg(test)] 120 fn take_start_rpc_hook_result() -> Option<Result<ServerHandle, String>> { 121 run_start_rpc_hook() 122 .lock() 123 .unwrap_or_else(std::sync::PoisonError::into_inner) 124 .take() 125 } 126 127 fn load_args_and_settings() -> Result<(cli::Args, config::Settings)> { 128 #[cfg(test)] 129 { 130 if let Some(result) = take_load_hook_result() { 131 return result.map_err(anyhow::Error::msg); 132 } 133 return Err(anyhow::anyhow!("run loader hook not set")); 134 } 135 136 #[cfg(not(test))] 137 { 138 let args = cli::Args::try_parse().map_err(radroots_runtime::RuntimeCliError::from)?; 139 let config_path = args 140 .service 141 .config 142 .clone() 143 .map(Ok) 144 .unwrap_or_else(paths::default_config_path_for_process)?; 145 let settings = 146 config::load_settings_from_path(&config_path).context("load configuration")?; 147 radroots_runtime::init_with_logs_dir( 148 std::path::Path::new(settings.config.service.logs_dir.as_str()), 149 None, 150 )?; 151 Ok((args, settings)) 152 } 153 } 154 155 fn runtime_startup_report( 156 args: &cli::Args, 157 settings: &config::Settings, 158 contract: &paths::RadrootsdRuntimeContractOutput, 159 migration: paths::RadrootsdRuntimeMigrationContractOutput, 160 ) -> RadrootsdRuntimeStartupReport { 161 RadrootsdRuntimeStartupReport { 162 active_profile: contract.active_profile.clone(), 163 config_path: args 164 .service 165 .config 166 .clone() 167 .unwrap_or_else(|| contract.canonical_config_path.clone()), 168 config_path_source: cli_or_profile_path_source( 169 args.service.config.is_some(), 170 &args 171 .service 172 .config 173 .clone() 174 .unwrap_or_else(|| contract.canonical_config_path.clone()), 175 &contract.canonical_config_path, 176 ), 177 canonical_config_path: contract.canonical_config_path.clone(), 178 logs_dir: PathBuf::from(settings.config.service.logs_dir.as_str()), 179 logs_dir_source: config_or_profile_path_source( 180 &PathBuf::from(settings.config.service.logs_dir.as_str()), 181 &contract.canonical_logs_dir, 182 ), 183 canonical_logs_dir: contract.canonical_logs_dir.clone(), 184 identity_path: args 185 .service 186 .identity 187 .clone() 188 .unwrap_or_else(|| contract.canonical_identity_path.clone()), 189 identity_path_source: cli_or_profile_path_source( 190 args.service.identity.is_some(), 191 &args 192 .service 193 .identity 194 .clone() 195 .unwrap_or_else(|| contract.canonical_identity_path.clone()), 196 &contract.canonical_identity_path, 197 ), 198 canonical_identity_path: contract.canonical_identity_path.clone(), 199 publish_proxy_database_path: settings.config.publish_proxy.database_path.clone(), 200 publish_proxy_database_path_source: config_or_profile_path_source( 201 &settings.config.publish_proxy.database_path, 202 &contract.canonical_publish_proxy_database_path, 203 ), 204 canonical_publish_proxy_database_path: contract 205 .canonical_publish_proxy_database_path 206 .clone(), 207 path_overrides: contract.path_overrides.clone(), 208 migration, 209 default_shared_secret_backend: contract.default_shared_secret_backend.clone(), 210 allowed_shared_secret_backends: contract.allowed_shared_secret_backends.clone(), 211 } 212 } 213 214 fn cli_or_profile_path_source( 215 is_cli_arg: bool, 216 actual_path: &PathBuf, 217 canonical_path: &PathBuf, 218 ) -> String { 219 if is_cli_arg { 220 "cli_arg".to_owned() 221 } else { 222 config_or_profile_path_source(actual_path, canonical_path) 223 } 224 } 225 226 fn config_or_profile_path_source(actual_path: &PathBuf, canonical_path: &PathBuf) -> String { 227 if actual_path == canonical_path { 228 "profile_default".to_owned() 229 } else { 230 "config_artifact".to_owned() 231 } 232 } 233 234 #[cfg(not(test))] 235 fn log_runtime_startup_report(report: &RadrootsdRuntimeStartupReport) { 236 info!( 237 active_profile = report.active_profile.as_str(), 238 profile_source = report.path_overrides.profile_source.as_str(), 239 root_source = report.path_overrides.root_source.as_str(), 240 repo_local_root = ?report.path_overrides.repo_local_root, 241 repo_local_root_source = ?report.path_overrides.repo_local_root_source, 242 subordinate_path_override_source = report.path_overrides.subordinate_path_override_source.as_str(), 243 migration_posture = report.migration.posture.as_str(), 244 migration_state = report.migration.state.as_str(), 245 migration_detected_legacy_paths = report.migration.detected_legacy_paths.len(), 246 silent_startup_relocation = report.migration.silent_startup_relocation, 247 config_path = %report.config_path.display(), 248 config_path_source = report.config_path_source.as_str(), 249 canonical_config_path = %report.canonical_config_path.display(), 250 logs_dir = %report.logs_dir.display(), 251 logs_dir_source = report.logs_dir_source.as_str(), 252 canonical_logs_dir = %report.canonical_logs_dir.display(), 253 identity_path = %report.identity_path.display(), 254 identity_path_source = report.identity_path_source.as_str(), 255 canonical_identity_path = %report.canonical_identity_path.display(), 256 publish_proxy_database_path = %report.publish_proxy_database_path.display(), 257 publish_proxy_database_path_source = report.publish_proxy_database_path_source.as_str(), 258 canonical_publish_proxy_database_path = %report.canonical_publish_proxy_database_path.display(), 259 default_shared_secret_backend = report.default_shared_secret_backend.as_str(), 260 allowed_shared_secret_backends = ?report.allowed_shared_secret_backends, 261 "radrootsd runtime contract" 262 ); 263 } 264 265 #[cfg_attr(coverage_nightly, coverage(off))] 266 async fn bootstrap_presence( 267 client: &radroots_nostr::prelude::RadrootsNostrClient, 268 identity: &RadrootsIdentity, 269 metadata: &radroots_nostr::prelude::RadrootsNostrMetadata, 270 handler_spec: &RadrootsNostrApplicationHandlerSpec, 271 ) -> Result<()> { 272 let bootstrap_result: Result<()> = match take_bootstrap_hook_result() { 273 Some(result) => result.map_err(anyhow::Error::msg), 274 None => radroots_nostr_bootstrap_service_presence( 275 client, 276 identity, 277 Some(RadrootsProfileType::Radrootsd), 278 metadata, 279 handler_spec, 280 Duration::from_secs(5), 281 ) 282 .await 283 .map(|_| ()) 284 .map_err(anyhow::Error::from), 285 }; 286 bootstrap_result?; 287 Ok(()) 288 } 289 290 #[cfg_attr(coverage_nightly, coverage(off))] 291 async fn publish_service_presence( 292 client: radroots_nostr::prelude::RadrootsNostrClient, 293 identity: RadrootsIdentity, 294 metadata: radroots_nostr::prelude::RadrootsNostrMetadata, 295 service_cfg: radroots_runtime::RadrootsNostrServiceConfig, 296 nip46_config: config::Nip46Config, 297 ) -> Result<()> { 298 let kinds = service_presence_kinds(); 299 let handler_spec = RadrootsNostrApplicationHandlerSpec { 300 kinds, 301 identifier: service_cfg.nip89_identifier.clone(), 302 metadata: Some(metadata.clone()), 303 extra_tags: service_cfg.nip89_extra_tags.clone(), 304 relays: service_cfg.relays.clone(), 305 nostrconnect_url: nip46_config.nostrconnect_url.clone(), 306 }; 307 bootstrap_presence(&client, &identity, &metadata, &handler_spec).await 308 } 309 310 #[cfg_attr(coverage_nightly, coverage(off))] 311 async fn maybe_publish_service_presence( 312 client: radroots_nostr::prelude::RadrootsNostrClient, 313 identity: RadrootsIdentity, 314 metadata: radroots_nostr::prelude::RadrootsNostrMetadata, 315 service_cfg: radroots_runtime::RadrootsNostrServiceConfig, 316 nip46_config: config::Nip46Config, 317 ) { 318 #[cfg(test)] 319 { 320 let result = 321 publish_service_presence(client, identity, metadata, service_cfg, nip46_config).await; 322 if let Err(err) = result { 323 warn!("Failed to publish service presence on startup: {err}"); 324 } else { 325 info!("Published service presence on startup"); 326 } 327 return; 328 } 329 330 #[cfg(not(test))] 331 tokio::spawn(async move { 332 let result = 333 publish_service_presence(client, identity, metadata, service_cfg, nip46_config).await; 334 if let Err(err) = result { 335 warn!("Failed to publish service presence on startup: {err}"); 336 } else { 337 info!("Published service presence on startup"); 338 } 339 }); 340 } 341 342 #[cfg(not(test))] 343 #[cfg_attr(coverage_nightly, coverage(off))] 344 fn spawn_nip46_listener_io(radrootsd: Radrootsd) { 345 spawn_nip46_listener(radrootsd); 346 } 347 348 #[cfg(test)] 349 fn spawn_nip46_listener_io(_radrootsd: Radrootsd) {} 350 351 #[cfg(test)] 352 async fn start_rpc_io( 353 state: Radrootsd, 354 addr: std::net::SocketAddr, 355 rpc_cfg: &config::RpcConfig, 356 ) -> Result<ServerHandle> { 357 if let Some(result) = take_start_rpc_hook_result() { 358 return result.map_err(anyhow::Error::msg); 359 } 360 jsonrpc::start_rpc(state, addr, rpc_cfg).await 361 } 362 363 #[cfg(not(test))] 364 #[cfg_attr(coverage_nightly, coverage(off))] 365 async fn start_rpc_io( 366 state: Radrootsd, 367 addr: std::net::SocketAddr, 368 rpc_cfg: &config::RpcConfig, 369 ) -> Result<ServerHandle> { 370 jsonrpc::start_rpc(state, addr, rpc_cfg).await 371 } 372 373 #[cfg(test)] 374 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { 375 if let Some(outcome) = take_wait_hook_result() { 376 return outcome; 377 } 378 handle.stopped().await; 379 RunWaitOutcome::Stopped 380 } 381 382 #[cfg(not(test))] 383 #[cfg_attr(coverage_nightly, coverage(off))] 384 async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { 385 tokio::select! { 386 _ = radroots_runtime::shutdown_signal() => RunWaitOutcome::Shutdown, 387 _ = handle.stopped() => RunWaitOutcome::Stopped, 388 } 389 } 390 391 async fn handle_command(command: cli::Command, settings: &config::Settings) -> Result<()> { 392 match command { 393 cli::Command::PublishProxy(command) => match command.command { 394 cli::PublishProxySubcommand::Principal(command) => match command.command { 395 cli::PrincipalSubcommand::Init(args) => { 396 let token = crate::core::publish_proxy::generate_bearer_token(); 397 let token_hash = crate::core::publish_proxy::hash_bearer_token(token.as_str()); 398 let store = crate::core::publish_proxy::PublishProxyStore::open( 399 settings.config.publish_proxy.database_path.clone(), 400 )?; 401 let principal = store.create_principal( 402 crate::core::publish_proxy::PublishPrincipalInit { 403 label: args.label, 404 token_hash, 405 allowed_pubkeys: args.allowed_pubkey, 406 allowed_kinds: args.allowed_kind, 407 allowed_relay_policies: args 408 .allowed_relay_policy 409 .iter() 410 .map(|policy| { 411 crate::core::publish_proxy::parse_relay_policy(policy.as_str()) 412 }) 413 .collect::<Result<Vec<_>, _>>()?, 414 allow_request_relays: args.allow_request_relays, 415 job_visibility: args.job_visibility.parse()?, 416 expires_at_unix: None, 417 }, 418 )?; 419 crate::core::publish_proxy::write_token_file(&args.token_file, token.as_str())?; 420 println!( 421 "{}", 422 serde_json::json!({ 423 "principal_id": principal.principal_id, 424 "label": principal.label, 425 "token_file": args.token_file, 426 "database_path": settings.config.publish_proxy.database_path, 427 }) 428 ); 429 Ok(()) 430 } 431 }, 432 }, 433 } 434 } 435 436 pub async fn run() -> Result<()> { 437 let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?; 438 settings.config.validate()?; 439 440 #[cfg(not(test))] 441 { 442 let contract = paths::runtime_contract_for_process().context("resolve runtime contract")?; 443 let migration = 444 paths::runtime_migration_for_process(&contract).context("inspect runtime migration")?; 445 let report = runtime_startup_report(&args, &settings, &contract, migration); 446 log_runtime_startup_report(&report); 447 } 448 449 if let Some(command) = args.command.clone() { 450 return handle_command(command, &settings).await; 451 } 452 453 info!("Starting radrootsd"); 454 455 let identity = load_service_identity( 456 args.service.identity.as_deref(), 457 args.service.allow_generate_identity, 458 )?; 459 let radrootsd = Radrootsd::new( 460 identity.clone(), 461 settings.metadata.clone(), 462 settings.config.publish_proxy.clone(), 463 settings.config.nip46.clone(), 464 ); 465 let radrootsd = radrootsd?; 466 467 for relay in settings.config.service.relays.iter() { 468 radrootsd.client.add_relay(relay).await?; 469 } 470 471 if !settings.config.service.relays.is_empty() { 472 maybe_publish_service_presence( 473 radrootsd.client.clone(), 474 identity.clone(), 475 settings.metadata.clone(), 476 settings.config.service.clone(), 477 settings.config.nip46.clone(), 478 ) 479 .await; 480 481 spawn_nip46_listener_io(radrootsd.clone()); 482 } 483 484 let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?; 485 let handle = start_rpc_io(radrootsd.clone(), addr, &settings.config.rpc).await?; 486 info!("JSON-RPC listening on {addr}"); 487 488 let stop_handle = handle.clone(); 489 490 match wait_for_shutdown_or_stopped(handle).await { 491 RunWaitOutcome::Shutdown => { 492 info!("Shutting down…"); 493 let _ = stop_handle.stop(); 494 } 495 RunWaitOutcome::Stopped => {} 496 } 497 498 Ok(()) 499 } 500 501 fn service_presence_kinds() -> Vec<u32> { 502 let mut kinds = vec![RadrootsNostrKind::NostrConnect.as_u16() as u32]; 503 kinds.sort_unstable(); 504 kinds.dedup(); 505 kinds 506 } 507 508 #[cfg(test)] 509 #[cfg_attr(coverage_nightly, coverage(off))] 510 mod tests { 511 use super::{ 512 RadrootsdRuntimeStartupReport, RunWaitOutcome, run, run_bootstrap_hook, run_load_hook, 513 run_start_rpc_hook, run_wait_hook, runtime_startup_report, 514 }; 515 use crate::app::{cli, config, paths}; 516 use crate::core::Radrootsd; 517 use crate::transport::jsonrpc; 518 use radroots_identity::RadrootsIdentity; 519 use radroots_nostr::prelude::RadrootsNostrMetadata; 520 use std::path::Path; 521 use std::path::PathBuf; 522 use std::sync::{Mutex, MutexGuard}; 523 524 static TEST_LOCK: Mutex<()> = Mutex::new(()); 525 526 fn test_guard() -> MutexGuard<'static, ()> { 527 let guard = TEST_LOCK 528 .lock() 529 .unwrap_or_else(std::sync::PoisonError::into_inner); 530 *run_load_hook() 531 .lock() 532 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 533 *run_bootstrap_hook() 534 .lock() 535 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 536 *run_wait_hook() 537 .lock() 538 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 539 *run_start_rpc_hook() 540 .lock() 541 .unwrap_or_else(std::sync::PoisonError::into_inner) = None; 542 guard 543 } 544 545 fn unique_identity_path(suffix: &str) -> PathBuf { 546 let nanos = std::time::SystemTime::now() 547 .duration_since(std::time::UNIX_EPOCH) 548 .expect("time") 549 .as_nanos(); 550 std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.secret.json")) 551 } 552 553 fn cleanup_identity_artifacts(path: &Path) { 554 let _ = std::fs::remove_file(path); 555 let _ = std::fs::remove_file(crate::app::identity_storage::encrypted_identity_key_path( 556 path, 557 )); 558 } 559 560 fn args_for_identity(path: PathBuf, allow_generate: bool) -> cli::Args { 561 cli::Args { 562 service: radroots_runtime::RadrootsServiceCliArgs { 563 config: Some(PathBuf::from("config.toml")), 564 identity: Some(path), 565 allow_generate_identity: allow_generate, 566 }, 567 command: None, 568 } 569 } 570 571 fn settings_with_relays(relays: Vec<String>) -> config::Settings { 572 let metadata: RadrootsNostrMetadata = 573 serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); 574 config::Settings { 575 metadata, 576 config: config::Configuration { 577 service: radroots_runtime::RadrootsNostrServiceConfig { 578 logs_dir: "logs".to_string(), 579 relays, 580 nip89_identifier: Some("radrootsd".to_string()), 581 nip89_extra_tags: Vec::new(), 582 }, 583 rpc: config::RpcConfig { 584 addr: "127.0.0.1:0".to_string(), 585 ..config::RpcConfig::default() 586 }, 587 rpc_addr: Some("127.0.0.1:0".to_string()), 588 nip46: config::Nip46Config::default(), 589 publish_proxy: config::PublishProxyConfig::default(), 590 obsolete_bridge_config_present: false, 591 }, 592 } 593 } 594 595 fn sample_runtime_contract() -> paths::RadrootsdRuntimeContractOutput { 596 paths::RadrootsdRuntimeContractOutput { 597 active_profile: "interactive_user".to_string(), 598 allowed_profiles: vec![ 599 "interactive_user".to_string(), 600 "service_host".to_string(), 601 "repo_local".to_string(), 602 ], 603 path_overrides: paths::RadrootsdRuntimePathOverrideContractOutput { 604 profile_source: "caller".to_string(), 605 root_source: "host_defaults".to_string(), 606 repo_local_root: None, 607 repo_local_root_source: None, 608 subordinate_path_override_source: "config_artifact".to_string(), 609 subordinate_path_override_keys: vec![ 610 "config.service.logs_dir".to_string(), 611 "config.publish_proxy.database_path".to_string(), 612 ], 613 }, 614 default_shared_secret_backend: "encrypted_file".to_string(), 615 allowed_shared_secret_backends: vec!["encrypted_file".to_string()], 616 migration: paths::RadrootsdRuntimeMigrationContractOutput { 617 posture: "explicit_operator_import_required".to_string(), 618 state: "ready".to_string(), 619 silent_startup_relocation: false, 620 compatibility_window: "detect_and_report_only".to_string(), 621 detected_legacy_paths: Vec::new(), 622 }, 623 canonical_config_path: PathBuf::from( 624 "/home/treesap/.radroots/config/services/radrootsd/config.toml", 625 ), 626 canonical_logs_dir: PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd"), 627 canonical_identity_path: PathBuf::from( 628 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json", 629 ), 630 canonical_publish_proxy_database_path: PathBuf::from( 631 "/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite", 632 ), 633 } 634 } 635 636 async fn make_handle(settings: &config::Settings) -> jsonrpsee::server::ServerHandle { 637 let identity = RadrootsIdentity::generate(); 638 let state = Radrootsd::new( 639 identity, 640 settings.metadata.clone(), 641 settings.config.publish_proxy.clone(), 642 settings.config.nip46.clone(), 643 ) 644 .expect("state"); 645 jsonrpc::start_rpc( 646 state, 647 "127.0.0.1:0".parse().expect("addr"), 648 &settings.config.rpc, 649 ) 650 .await 651 .expect("rpc handle") 652 } 653 654 #[tokio::test] 655 async fn run_returns_error_when_hook_is_missing() { 656 let _guard = test_guard(); 657 let err = run().await.expect_err("missing loader hook should error"); 658 let msg = format!("{err:#}"); 659 assert!(msg.contains("run loader hook not set")); 660 } 661 662 #[tokio::test] 663 async fn run_returns_error_when_identity_missing() { 664 let _guard = test_guard(); 665 let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.secret.json"), false); 666 let settings = settings_with_relays(Vec::new()); 667 *run_load_hook() 668 .lock() 669 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 670 let err = run().await.expect_err("missing identity should error"); 671 let msg = format!("{err:#}"); 672 assert!(msg.contains("identity")); 673 } 674 675 #[tokio::test] 676 async fn run_covers_shutdown_path_and_presence_success() { 677 let _guard = test_guard(); 678 let path = unique_identity_path("shutdown"); 679 let args = args_for_identity(path.clone(), true); 680 let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); 681 let handle = make_handle(&settings).await; 682 *run_load_hook() 683 .lock() 684 .unwrap_or_else(std::sync::PoisonError::into_inner) = 685 Some(Ok((args, settings.clone()))); 686 *run_start_rpc_hook() 687 .lock() 688 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 689 *run_wait_hook() 690 .lock() 691 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); 692 *run_bootstrap_hook() 693 .lock() 694 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(())); 695 assert!(run().await.is_ok()); 696 cleanup_identity_artifacts(&path); 697 } 698 699 #[tokio::test] 700 async fn run_covers_stopped_path_and_presence_failure() { 701 let _guard = test_guard(); 702 let path = unique_identity_path("stopped"); 703 let args = args_for_identity(path.clone(), true); 704 let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); 705 let handle = make_handle(&settings).await; 706 let _ = handle.stop(); 707 *run_load_hook() 708 .lock() 709 .unwrap_or_else(std::sync::PoisonError::into_inner) = 710 Some(Ok((args, settings.clone()))); 711 *run_start_rpc_hook() 712 .lock() 713 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 714 *run_wait_hook() 715 .lock() 716 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Stopped); 717 *run_bootstrap_hook() 718 .lock() 719 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("boom".to_string())); 720 assert!(run().await.is_ok()); 721 cleanup_identity_artifacts(&path); 722 } 723 724 #[tokio::test] 725 async fn run_skips_presence_when_relays_empty() { 726 let _guard = test_guard(); 727 let path = unique_identity_path("empty"); 728 let args = args_for_identity(path.clone(), true); 729 let settings = settings_with_relays(Vec::new()); 730 let handle = make_handle(&settings).await; 731 *run_load_hook() 732 .lock() 733 .unwrap_or_else(std::sync::PoisonError::into_inner) = 734 Some(Ok((args, settings.clone()))); 735 *run_start_rpc_hook() 736 .lock() 737 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 738 *run_wait_hook() 739 .lock() 740 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); 741 assert!(run().await.is_ok()); 742 cleanup_identity_artifacts(&path); 743 } 744 745 #[tokio::test] 746 async fn run_returns_error_when_relay_is_invalid() { 747 let _guard = test_guard(); 748 let path = unique_identity_path("invalid-relay"); 749 let args = args_for_identity(path.clone(), true); 750 let settings = settings_with_relays(vec!["not-a-relay".to_string()]); 751 *run_load_hook() 752 .lock() 753 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 754 let err = run().await.expect_err("invalid relay should error"); 755 let msg = format!("{err:#}"); 756 assert!(!msg.is_empty()); 757 cleanup_identity_artifacts(&path); 758 } 759 760 #[tokio::test] 761 async fn run_returns_error_when_rpc_addr_is_invalid() { 762 let _guard = test_guard(); 763 let path = unique_identity_path("invalid-rpc-addr"); 764 let args = args_for_identity(path.clone(), true); 765 let mut settings = settings_with_relays(Vec::new()); 766 settings.config.rpc_addr = Some("not-an-addr".to_string()); 767 *run_load_hook() 768 .lock() 769 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 770 let err = run().await.expect_err("invalid rpc addr should error"); 771 let msg = format!("{err:#}"); 772 assert!(msg.contains("invalid")); 773 cleanup_identity_artifacts(&path); 774 } 775 776 #[tokio::test] 777 async fn run_returns_error_when_rpc_start_fails() { 778 let _guard = test_guard(); 779 let path = unique_identity_path("rpc-start-fail"); 780 let args = args_for_identity(path.clone(), true); 781 let settings = settings_with_relays(Vec::new()); 782 *run_load_hook() 783 .lock() 784 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 785 *run_start_rpc_hook() 786 .lock() 787 .unwrap_or_else(std::sync::PoisonError::into_inner) = 788 Some(Err("rpc start failed".to_string())); 789 let err = run().await.expect_err("rpc start hook should fail"); 790 let msg = format!("{err:#}"); 791 assert!(msg.contains("rpc start failed")); 792 cleanup_identity_artifacts(&path); 793 } 794 795 #[tokio::test] 796 async fn run_waits_for_stopped_when_wait_hook_is_not_set() { 797 let _guard = test_guard(); 798 let path = unique_identity_path("wait-no-hook"); 799 let args = args_for_identity(path.clone(), true); 800 let settings = settings_with_relays(Vec::new()); 801 let handle = make_handle(&settings).await; 802 let _ = handle.stop(); 803 *run_load_hook() 804 .lock() 805 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 806 *run_start_rpc_hook() 807 .lock() 808 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); 809 assert!(run().await.is_ok()); 810 cleanup_identity_artifacts(&path); 811 } 812 813 #[tokio::test] 814 async fn run_starts_rpc_when_start_hook_is_not_set() { 815 let _guard = test_guard(); 816 let path = unique_identity_path("start-rpc-real"); 817 let args = args_for_identity(path.clone(), true); 818 let settings = settings_with_relays(Vec::new()); 819 *run_load_hook() 820 .lock() 821 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); 822 *run_wait_hook() 823 .lock() 824 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); 825 assert!(run().await.is_ok()); 826 cleanup_identity_artifacts(&path); 827 } 828 829 #[test] 830 fn service_presence_kinds_include_nostr_connect_only() { 831 let kinds = super::service_presence_kinds(); 832 833 assert!( 834 kinds.contains( 835 &(radroots_nostr::prelude::RadrootsNostrKind::NostrConnect.as_u16() as u32) 836 ) 837 ); 838 assert_eq!(kinds.len(), 1); 839 } 840 841 #[test] 842 fn runtime_startup_report_prefers_explicit_cli_paths() { 843 let args = cli::Args { 844 service: radroots_runtime::RadrootsServiceCliArgs { 845 config: Some(PathBuf::from("/tmp/radrootsd/config.toml")), 846 identity: Some(PathBuf::from("/tmp/radrootsd/identity.secret.json")), 847 allow_generate_identity: false, 848 }, 849 command: None, 850 }; 851 let mut settings = settings_with_relays(Vec::new()); 852 settings.config.service.logs_dir = "/tmp/radrootsd/logs".to_string(); 853 settings.config.publish_proxy.database_path = 854 PathBuf::from("/tmp/radrootsd/publish_proxy.sqlite"); 855 856 let contract = sample_runtime_contract(); 857 let report = 858 runtime_startup_report(&args, &settings, &contract, contract.migration.clone()); 859 860 assert_eq!( 861 report, 862 RadrootsdRuntimeStartupReport { 863 active_profile: "interactive_user".to_string(), 864 config_path: PathBuf::from("/tmp/radrootsd/config.toml"), 865 config_path_source: "cli_arg".to_string(), 866 canonical_config_path: PathBuf::from( 867 "/home/treesap/.radroots/config/services/radrootsd/config.toml" 868 ), 869 logs_dir: PathBuf::from("/tmp/radrootsd/logs"), 870 logs_dir_source: "config_artifact".to_string(), 871 canonical_logs_dir: PathBuf::from( 872 "/home/treesap/.radroots/logs/services/radrootsd" 873 ), 874 identity_path: PathBuf::from("/tmp/radrootsd/identity.secret.json"), 875 identity_path_source: "cli_arg".to_string(), 876 canonical_identity_path: PathBuf::from( 877 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json" 878 ), 879 publish_proxy_database_path: PathBuf::from("/tmp/radrootsd/publish_proxy.sqlite"), 880 publish_proxy_database_path_source: "config_artifact".to_string(), 881 canonical_publish_proxy_database_path: PathBuf::from( 882 "/home/treesap/.radroots/data/services/radrootsd/publish_proxy.sqlite" 883 ), 884 path_overrides: sample_runtime_contract().path_overrides, 885 migration: sample_runtime_contract().migration, 886 default_shared_secret_backend: "encrypted_file".to_string(), 887 allowed_shared_secret_backends: vec!["encrypted_file".to_string()], 888 } 889 ); 890 } 891 892 #[test] 893 fn runtime_startup_report_falls_back_to_canonical_contract_paths() { 894 let args = cli::Args { 895 service: radroots_runtime::RadrootsServiceCliArgs { 896 config: None, 897 identity: None, 898 allow_generate_identity: false, 899 }, 900 command: None, 901 }; 902 let contract = sample_runtime_contract(); 903 let mut settings = settings_with_relays(Vec::new()); 904 settings.config.service.logs_dir = contract.canonical_logs_dir.display().to_string(); 905 settings.config.publish_proxy.database_path = 906 contract.canonical_publish_proxy_database_path.clone(); 907 908 let report = 909 runtime_startup_report(&args, &settings, &contract, contract.migration.clone()); 910 911 assert_eq!(report.config_path, contract.canonical_config_path); 912 assert_eq!(report.config_path_source, "profile_default"); 913 assert_eq!(report.logs_dir, contract.canonical_logs_dir); 914 assert_eq!(report.logs_dir_source, "profile_default"); 915 assert_eq!(report.identity_path, contract.canonical_identity_path); 916 assert_eq!(report.identity_path_source, "profile_default"); 917 assert_eq!( 918 report.publish_proxy_database_path, 919 contract.canonical_publish_proxy_database_path 920 ); 921 assert_eq!(report.publish_proxy_database_path_source, "profile_default"); 922 assert_eq!(report.path_overrides, contract.path_overrides); 923 assert_eq!(report.migration, contract.migration); 924 assert_eq!(report.default_shared_secret_backend, "encrypted_file"); 925 assert_eq!( 926 report.allowed_shared_secret_backends, 927 vec!["encrypted_file".to_string()] 928 ); 929 } 930 }