1use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10
11use anyhow::Context;
12use clap::Parser;
13use prospero_core::bus::{EventBus, InProcessBus};
14use prospero_core::config_store::{ConfigStore, SqliteConfigStore};
15use prospero_core::discovery::{DiscoveryEnv, EnsureConfig};
16use prospero_core::fleet::{FleetConfig, FleetManager};
17use prospero_core::fleet_provider::LocalFleet;
18use prospero_core::ownership::{Ownership, SelfOwnsAll};
19use prospero_core::sqlite_store::SqliteStore;
20use prospero_core::store::Store;
21use prospero_core::{DistributedBus, LeasedOwnership, PostgresConfigStore, PostgresStore};
22use tokio::task::JoinHandle;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
31#[value(rename_all = "lower")]
32enum FleetBackend {
33 Local,
34 K8s,
35}
36
37#[derive(Debug, Parser)]
39#[command(name = "prosperod", version, about)]
40struct Args {
41 #[arg(long, env = "PROSPERO_ADDR", default_value = "127.0.0.1:7878")]
43 addr: SocketAddr,
44
45 #[arg(long, env = "PROSPERO_FLEET", default_value = "local")]
49 fleet_backend: FleetBackend,
50
51 #[arg(long, env = "PROSPERO_K8S_CALIBAND_CA_FILE")]
54 k8s_caliband_ca_file: Option<PathBuf>,
55
56 #[arg(long, env = "PROSPERO_K8S_CALIBAND_TOKEN_FILE")]
59 k8s_caliband_token_file: Option<PathBuf>,
60
61 #[arg(
63 long,
64 env = "PROSPERO_K8S_CALIBAND_SERVER_NAME",
65 default_value = "caliband"
66 )]
67 k8s_caliband_server_name: String,
68
69 #[arg(long, env = "KUBECONFIG")]
72 kubeconfig: Option<PathBuf>,
73
74 #[arg(long, env = "PROSPERO_DATA_DIR")]
76 data_dir: Option<PathBuf>,
77
78 #[arg(long, env = "PROSPERO_HOST", default_value = "local")]
80 host: String,
81
82 #[arg(long, default_value_t = 2000)]
84 poll_interval_ms: u64,
85
86 #[arg(long, default_value_t = 30_000)]
90 automation_interval_ms: u64,
91
92 #[arg(long)]
94 no_autostart: bool,
95
96 #[arg(long, default_value = "caliband")]
98 caliband_bin: String,
99
100 #[arg(long = "default-env", value_parser = parse_key_val)]
102 default_env: Vec<(String, String)>,
103
104 #[arg(long, default_value_t = 0)]
106 retention_days: u64,
107
108 #[arg(long, env = "PROSPERO_DATABASE_URL")]
112 database_url: Option<String>,
113
114 #[arg(long, env = "PROSPERO_REPLICA_ID")]
117 replica_id: Option<String>,
118
119 #[arg(long, default_value_t = 30.0)]
122 lease_ttl_secs: f64,
123
124 #[arg(long)]
127 heartbeat_interval_ms: Option<u64>,
128
129 #[arg(long, env = "PROSPERO_API_TOKENS_FILE")]
132 api_tokens_file: Option<PathBuf>,
133
134 #[arg(long, env = "PROSPERO_SESSION_KEY_FILE")]
137 session_key_file: Option<PathBuf>,
138
139 #[arg(
145 long,
146 env = "PROSPERO_INSECURE_NO_AUTH",
147 action = clap::ArgAction::SetTrue,
148 value_parser = clap::builder::BoolishValueParser::new()
149 )]
150 insecure_no_auth: bool,
151
152 #[arg(
158 long,
159 env = "PROSPERO_COOKIE_SECURE",
160 action = clap::ArgAction::SetTrue,
161 value_parser = clap::builder::BoolishValueParser::new()
162 )]
163 cookie_secure: bool,
164}
165
166fn parse_key_val(s: &str) -> Result<(String, String), String> {
168 match s.split_once('=') {
169 Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
170 _ => Err(format!("expected KEY=VALUE, got '{s}'")),
171 }
172}
173
174#[cfg_attr(not(feature = "k8s"), allow(dead_code))]
188fn require_token_tls(token_present: bool, tls_present: bool) -> anyhow::Result<()> {
189 if token_present && !tls_present {
190 anyhow::bail!(
191 "a session-plane token is configured (--k8s-caliband-token-file / \
192 PROSPERO_K8S_CALIBAND_TOKEN_FILE) but TLS is not (--k8s-caliband-ca-file / \
193 PROSPERO_K8S_CALIBAND_CA_FILE); the token would be sent in cleartext. \
194 Configure the CA file to enable TLS, or unset the token."
195 );
196 }
197 Ok(())
198}
199
200#[cfg_attr(not(feature = "k8s"), allow(dead_code))]
201fn read_token_file(path: &Path) -> anyhow::Result<String> {
202 let raw = std::fs::read_to_string(path)
203 .with_context(|| format!("reading session-plane token file {}", path.display()))?;
204 Ok(raw.trim_end().to_string())
205}
206
207fn resolve_auth(
210 addr: SocketAddr,
211 clustered: bool,
212 tokens_file: Option<&Path>,
213 session_key_file: Option<&Path>,
214 insecure_no_auth: bool,
215 cookie_secure: bool,
216) -> anyhow::Result<prospero_api::auth::AuthState> {
217 use prospero_api::auth::{AuthState, SessionKey};
218
219 let Some(tokens_path) = tokens_file else {
220 if !addr.ip().is_loopback() && !insecure_no_auth {
221 anyhow::bail!(
222 "refusing to bind {addr} without authentication: set --api-tokens-file / \
223 PROSPERO_API_TOKENS_FILE, or pass --insecure-no-auth to run unauthenticated"
224 );
225 }
226 return Ok(AuthState::disabled());
227 };
228 if insecure_no_auth {
229 anyhow::bail!("--insecure-no-auth cannot be combined with --api-tokens-file");
230 }
231 let tokens = prospero_core::auth::TokenSet::load(tokens_path)?;
232 let session_key = match session_key_file {
233 Some(path) => {
234 let raw = std::fs::read(path)
235 .with_context(|| format!("reading session key file {}", path.display()))?;
236 let trimmed = raw.trim_ascii_end().to_vec();
237 SessionKey::from_bytes(trimmed)
238 .map_err(|e| anyhow::anyhow!("session key file {}: {e}", path.display()))?
239 }
240 None if clustered => anyhow::bail!(
241 "clustered prosperod (PROSPERO_DATABASE_URL) with API tokens needs \
242 --session-key-file / PROSPERO_SESSION_KEY_FILE so every replica signs \
243 sessions with the same key"
244 ),
245 None => SessionKey::random(),
246 };
247 Ok(AuthState::enabled(tokens, session_key, cookie_secure))
248}
249
250#[cfg(feature = "k8s")]
256fn load_session_plane_tls(
257 ca_file: Option<&Path>,
258 server_name: &str,
259) -> anyhow::Result<Option<prospero_core::caliband::transport::TlsClient>> {
260 let Some(ca_file) = ca_file else {
261 return Ok(None);
262 };
263 let ca_pem = std::fs::read(ca_file)
264 .with_context(|| format!("reading session-plane CA file {}", ca_file.display()))?;
265 let client = prospero_core::caliband::transport::tls_client_from_pem(&ca_pem, server_name)
266 .with_context(|| format!("building session-plane TLS from {}", ca_file.display()))?;
267 Ok(Some(client))
268}
269
270#[cfg(feature = "k8s")]
273async fn build_kube_client(kubeconfig: Option<&Path>) -> anyhow::Result<kube::Client> {
274 match kubeconfig {
275 Some(path) => {
276 let kc = kube::config::Kubeconfig::read_from(path)
277 .with_context(|| format!("reading kubeconfig {}", path.display()))?;
278 let cfg = kube::Config::from_custom_kubeconfig(
279 kc,
280 &kube::config::KubeConfigOptions::default(),
281 )
282 .await
283 .with_context(|| format!("loading kubeconfig {}", path.display()))?;
284 kube::Client::try_from(cfg).with_context(|| "building kube client from kubeconfig")
285 }
286 None => kube::Client::try_default()
287 .await
288 .with_context(|| "connecting to the Kubernetes API server"),
289 }
290}
291
292fn resolve_replica_id(explicit: Option<&str>) -> String {
295 explicit
296 .map(str::to_string)
297 .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
298 .unwrap_or_else(|| "prosperod-local".to_string())
299}
300
301fn heartbeat_interval(explicit_ms: Option<u64>, lease_ttl_secs: f64) -> Duration {
304 match explicit_ms {
305 Some(ms) => Duration::from_millis(ms.max(1)),
306 None => {
307 let secs = (lease_ttl_secs / 3.0).max(1.0);
308 Duration::from_secs_f64(secs)
309 }
310 }
311}
312
313fn default_data_dir() -> PathBuf {
315 if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") {
316 PathBuf::from(xdg).join("prospero")
317 } else if let Some(home) = std::env::var_os("HOME") {
318 PathBuf::from(home).join(".local/share/prospero")
319 } else {
320 PathBuf::from(".prospero")
321 }
322}
323
324#[tokio::main]
325async fn main() -> anyhow::Result<()> {
326 tracing_subscriber::fmt()
327 .with_env_filter(
328 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
329 )
330 .init();
331
332 let args = Args::parse();
333
334 #[cfg(not(feature = "k8s"))]
342 if args.fleet_backend == FleetBackend::K8s {
343 anyhow::bail!(
344 "PROSPERO_FLEET=k8s requires a prosperod built with the k8s feature \
345 (`cargo build -p prospero-daemon --features k8s`)."
346 );
347 }
348
349 let auth = resolve_auth(
350 args.addr,
351 args.database_url.is_some(),
352 args.api_tokens_file.as_deref(),
353 args.session_key_file.as_deref(),
354 args.insecure_no_auth,
355 args.cookie_secure,
356 )?;
357 if args.insecure_no_auth {
358 let addr = args.addr;
359 let warn = move || {
360 tracing::warn!(
361 target: "prosperod", %addr,
362 "authentication DISABLED (--insecure-no-auth): anyone who can reach this address controls the fleet"
363 )
364 };
365 warn();
366 tokio::spawn(async move {
367 let mut tick = tokio::time::interval(Duration::from_secs(60));
368 tick.tick().await;
369 loop {
370 tick.tick().await;
371 warn();
372 }
373 });
374 }
375 let auth_enabled = auth.is_enabled();
376
377 let data_dir = args.data_dir.clone().unwrap_or_else(default_data_dir);
378 std::fs::create_dir_all(&data_dir)
379 .with_context(|| format!("creating data dir {}", data_dir.display()))?;
380
381 let mut config = FleetConfig::new(args.host.clone(), data_dir.clone());
382 config.poll_interval = Duration::from_millis(args.poll_interval_ms);
383 config.discovery_env = DiscoveryEnv::from_process();
384 config.ensure = EnsureConfig {
385 autostart: !args.no_autostart,
386 caliband_bin: args.caliband_bin.clone(),
387 ..EnsureConfig::default()
388 };
389 config.default_env = args.default_env.iter().cloned().collect();
390
391 let (store, bus): (Arc<dyn Store>, Arc<dyn EventBus>) = if let Some(url) =
397 args.database_url.clone()
398 {
399 let store: Arc<dyn Store> = Arc::new(
400 PostgresStore::connect(&url)
401 .await
402 .with_context(|| "connecting clustered event store")?,
403 );
404 let bus: Arc<dyn EventBus> = Arc::new(
405 DistributedBus::connect(&url, store.clone())
406 .await
407 .with_context(|| "connecting clustered event bus")?,
408 );
409 tracing::info!(target: "prosperod", topology = "clustered", "selected clustered topology");
410 (store, bus)
411 } else {
412 let store: Arc<dyn Store> = Arc::new(
413 SqliteStore::open(&data_dir)
414 .await
415 .with_context(|| "opening event store")?,
416 );
417 let bus: Arc<dyn EventBus> = Arc::new(InProcessBus::new(config.event_buffer));
418 tracing::info!(target: "prosperod", topology = "standalone", "selected standalone topology");
419 (store, bus)
420 };
421
422 #[allow(clippy::type_complexity)]
433 let (fleet, admin, poll_handle, heartbeat_handle, manager_for_shutdown, automation_config): (
434 Arc<dyn prospero_core::FleetProvider>,
435 Option<Arc<dyn prospero_core::FleetAdmin>>,
436 Option<JoinHandle<()>>,
437 Option<JoinHandle<()>>,
438 Option<FleetManager>,
439 Option<Arc<dyn ConfigStore>>,
440 ) = match args.fleet_backend {
441 FleetBackend::Local => {
442 let (config_store, ownership, heartbeat_handle): (
446 Arc<dyn ConfigStore>,
447 Arc<dyn Ownership>,
448 Option<JoinHandle<()>>,
449 ) = if let Some(url) = args.database_url.clone() {
450 let replica_id = resolve_replica_id(args.replica_id.as_deref());
451 let config_store: Arc<dyn ConfigStore> = Arc::new(
452 PostgresConfigStore::connect(&url)
453 .await
454 .with_context(|| "connecting clustered config store")?,
455 );
456 let ownership = Arc::new(
457 LeasedOwnership::connect(&url, replica_id.clone(), args.lease_ttl_secs)
458 .await
459 .with_context(|| "connecting clustered ownership")?,
460 );
461
462 let interval = heartbeat_interval(args.heartbeat_interval_ms, args.lease_ttl_secs);
465 let hb = ownership.clone();
466 let heartbeat_handle = tokio::spawn(async move {
467 let mut tick = tokio::time::interval(interval);
468 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
469 loop {
470 tick.tick().await;
471 hb.heartbeat().await;
472 }
473 });
474 tracing::info!(
475 target: "prosperod", %replica_id,
476 lease_ttl_secs = args.lease_ttl_secs,
477 heartbeat_ms = interval.as_millis() as u64,
478 "clustered ownership + heartbeat active"
479 );
480 (
481 config_store,
482 ownership as Arc<dyn Ownership>,
483 Some(heartbeat_handle),
484 )
485 } else {
486 let config_store: Arc<dyn ConfigStore> = Arc::new(
487 SqliteConfigStore::open(&data_dir)
488 .await
489 .with_context(|| "opening config store")?,
490 );
491 (
492 config_store,
493 Arc::new(SelfOwnsAll) as Arc<dyn Ownership>,
494 None,
495 )
496 };
497
498 let automation_config = config_store.clone();
501 let manager = FleetManager::with_seams(
502 config,
503 store.clone(),
504 config_store,
505 bus.clone(),
506 ownership,
507 )
508 .await
509 .with_context(|| "building fleet manager")?;
510
511 let local = LocalFleet::new(manager.clone());
512 let poll_handle = tokio::spawn(manager.clone().run());
513 tracing::info!(target: "prosperod", backend = "local", "serving via LocalFleet");
514 (
515 Arc::new(local.clone()) as Arc<dyn prospero_core::FleetProvider>,
516 Some(Arc::new(local) as Arc<dyn prospero_core::FleetAdmin>),
517 Some(poll_handle),
518 heartbeat_handle,
519 Some(manager),
520 Some(automation_config),
521 )
522 }
523 #[cfg(feature = "k8s")]
524 FleetBackend::K8s => {
525 let client = build_kube_client(args.kubeconfig.as_deref()).await?;
526 let ns =
527 std::env::var("PROSPERO_K8S_NAMESPACE").unwrap_or_else(|_| "default".to_string());
528 let api = prospero_core::KubeTaskApi::new(client.clone(), &ns);
529
530 let automation_config: Option<Arc<dyn ConfigStore>> =
544 if let Some(url) = args.database_url.clone() {
545 Some(Arc::new(
546 PostgresConfigStore::connect(&url)
547 .await
548 .with_context(|| "connecting config store for automations (k8s)")?,
549 ))
550 } else {
551 None
552 };
553
554 let workspace_api = Arc::new(prospero_core::KubeWorkspaceApi::new(client, &ns));
555 let workspace_admin =
556 Arc::new(prospero_core::K8sWorkspaceAdmin::new(workspace_api.clone()));
557
558 let tls = load_session_plane_tls(
563 args.k8s_caliband_ca_file.as_deref(),
564 &args.k8s_caliband_server_name,
565 )?;
566 let token = args
567 .k8s_caliband_token_file
568 .as_deref()
569 .map(read_token_file)
570 .transpose()?;
571
572 require_token_tls(token.is_some(), tls.is_some())?;
574
575 let (ownership, heartbeat_handle): (Arc<dyn Ownership>, Option<JoinHandle<()>>) =
582 if let Some(url) = args.database_url.clone() {
583 let replica_id = resolve_replica_id(args.replica_id.as_deref());
584 let ownership = Arc::new(
585 LeasedOwnership::connect(&url, replica_id.clone(), args.lease_ttl_secs)
586 .await
587 .with_context(|| "connecting clustered ownership (k8s)")?,
588 );
589 let interval =
590 heartbeat_interval(args.heartbeat_interval_ms, args.lease_ttl_secs);
591 let hb = ownership.clone();
592 let heartbeat_handle = tokio::spawn(async move {
593 let mut tick = tokio::time::interval(interval);
594 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
595 loop {
596 tick.tick().await;
597 hb.heartbeat().await;
598 }
599 });
600 tracing::info!(
601 target: "prosperod", backend = "k8s", %replica_id,
602 lease_ttl_secs = args.lease_ttl_secs,
603 heartbeat_ms = interval.as_millis() as u64,
604 "clustered k8s ownership + heartbeat active"
605 );
606 (ownership as Arc<dyn Ownership>, Some(heartbeat_handle))
607 } else {
608 (Arc::new(SelfOwnsAll) as Arc<dyn Ownership>, None)
609 };
610
611 let include_thinking = std::env::var("PROSPERO_INCLUDE_THINKING")
619 .map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
620 .unwrap_or(false);
621 let k8s = prospero_core::K8sFleet::new(api, bus.clone(), store.clone())
622 .with_network(tls.clone(), token.clone())
623 .with_ownership(ownership)
624 .with_workspaces(workspace_api)
625 .with_include_thinking(include_thinking);
626 tracing::info!(
627 target: "prosperod", backend = "k8s", namespace = %ns,
628 session_tls = tls.is_some(), session_token = token.is_some(),
629 include_thinking,
630 "serving via K8sFleet (no FleetManager)"
631 );
632 (
633 Arc::new(k8s) as Arc<dyn prospero_core::FleetProvider>,
634 Some(workspace_admin as Arc<dyn prospero_core::FleetAdmin>),
635 None,
636 heartbeat_handle,
637 None,
638 automation_config,
639 )
640 }
641 #[cfg(not(feature = "k8s"))]
642 FleetBackend::K8s => anyhow::bail!(
643 "PROSPERO_FLEET=k8s requires a prosperod built with the k8s feature \
644 (`cargo build -p prospero-daemon --features k8s`)."
645 ),
646 };
647
648 if args.retention_days > 0 {
651 let s = store.clone();
652 let max_age = Duration::from_secs(args.retention_days * 24 * 3600);
653 tokio::spawn(async move {
654 let mut tick = tokio::time::interval(Duration::from_secs(3600));
655 loop {
656 tick.tick().await;
657 match prospero_core::store::prune_store_older_than(s.as_ref(), max_age).await {
658 Ok(n) if n > 0 => {
659 tracing::info!(target: "prosperod", pruned = n, "retention swept old events")
660 }
661 Ok(_) => {}
662 Err(e) => {
663 tracing::warn!(target: "prosperod", error = %e, "retention prune failed")
664 }
665 }
666 }
667 });
668 }
669
670 let (automation_stop, automation_stop_rx) = tokio::sync::watch::channel(false);
674 let (automations, automation_handle) = match automation_config {
675 Some(config_store) => {
676 let engine = Arc::new(prospero_core::automation::AutomationEngine::new(
677 config_store,
678 fleet.clone(),
679 ));
680 let interval = Duration::from_millis(args.automation_interval_ms);
681 let handle = tokio::spawn(engine.clone().run(interval, automation_stop_rx));
682 tracing::info!(
683 target: "prosperod",
684 interval_ms = args.automation_interval_ms,
685 "automation scheduler active"
686 );
687 (Some(engine), Some(handle))
688 }
689 None => {
690 tracing::info!(
691 target: "prosperod",
692 "automations disabled (no shared config store)"
693 );
694 (None, None)
695 }
696 };
697
698 let app =
699 prospero_api::router_with_auth(fleet, admin, store.clone(), bus.clone(), automations, auth);
700 let listener = tokio::net::TcpListener::bind(args.addr)
701 .await
702 .with_context(|| format!("binding {}", args.addr))?;
703
704 tracing::info!(
705 addr = %args.addr,
706 data_dir = %data_dir.display(),
707 auth = auth_enabled,
708 "prosperod listening"
709 );
710
711 axum::serve(listener, app)
712 .with_graceful_shutdown(shutdown_signal())
713 .await
714 .with_context(|| "serving HTTP")?;
715
716 let _ = automation_stop.send(true);
720 if let Some(handle) = automation_handle {
721 let _ = handle.await;
722 }
723 if let Some(manager) = &manager_for_shutdown {
724 manager.begin_shutdown();
725 }
726 if let Some(poll_handle) = poll_handle
727 && let Err(e) = poll_handle.await
728 {
729 tracing::warn!(error = %e, "poll loop did not drain cleanly");
730 }
731 if let Some(hb) = heartbeat_handle {
732 hb.abort();
733 }
734
735 tracing::info!("prosperod shut down");
736 Ok(())
737}
738
739async fn shutdown_signal() {
741 let ctrl_c = async {
742 let _ = tokio::signal::ctrl_c().await;
743 };
744
745 #[cfg(unix)]
746 let terminate = async {
747 if let Ok(mut sig) =
748 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
749 {
750 sig.recv().await;
751 }
752 };
753 #[cfg(not(unix))]
754 let terminate = std::future::pending::<()>();
755
756 tokio::select! {
757 _ = ctrl_c => {},
758 _ = terminate => {},
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::parse_key_val;
765 use super::read_token_file;
766 use super::require_token_tls;
767 use super::{heartbeat_interval, resolve_replica_id};
768 use std::time::Duration;
769
770 #[test]
771 fn read_token_file_trims_trailing_newline() {
772 let dir = tempfile::tempdir().unwrap();
773 let p = dir.path().join("token");
774 std::fs::write(&p, "s3cr3t\n").unwrap();
775 assert_eq!(read_token_file(&p).unwrap(), "s3cr3t");
776 }
777
778 #[test]
779 fn read_token_file_missing_is_err() {
780 let dir = tempfile::tempdir().unwrap();
781 assert!(read_token_file(&dir.path().join("nope")).is_err());
782 }
783
784 #[test]
785 fn require_token_tls_rejects_token_without_tls() {
786 assert!(require_token_tls(true, false).is_err());
788 assert!(require_token_tls(true, true).is_ok());
791 assert!(require_token_tls(false, false).is_ok());
792 assert!(require_token_tls(false, true).is_ok());
793 }
794
795 #[cfg(feature = "k8s")]
796 mod k8s_tls {
797 use super::super::load_session_plane_tls;
798
799 fn write_ca(dir: &std::path::Path) -> std::path::PathBuf {
800 let cert = rcgen::generate_simple_self_signed(vec!["caliband".into()]).unwrap();
802 let p = dir.join("ca.crt");
803 std::fs::write(&p, cert.cert.pem()).unwrap();
804 p
805 }
806
807 #[test]
808 fn none_ca_means_tls_off() {
809 assert!(load_session_plane_tls(None, "caliband").unwrap().is_none());
810 }
811
812 #[test]
813 fn good_ca_builds_a_client() {
814 let dir = tempfile::tempdir().unwrap();
815 let ca = write_ca(dir.path());
816 assert!(
817 load_session_plane_tls(Some(&ca), "caliband")
818 .unwrap()
819 .is_some()
820 );
821 }
822
823 #[test]
824 fn unparseable_pem_is_err() {
825 let dir = tempfile::tempdir().unwrap();
826 let p = dir.path().join("bad.crt");
827 std::fs::write(&p, "not a pem").unwrap();
828 assert!(load_session_plane_tls(Some(&p), "caliband").is_err());
829 }
830
831 #[test]
832 fn missing_ca_file_is_err() {
833 let dir = tempfile::tempdir().unwrap();
834 assert!(load_session_plane_tls(Some(&dir.path().join("nope")), "caliband").is_err());
835 }
836 }
837
838 #[test]
839 fn replica_id_prefers_explicit_then_falls_back() {
840 assert_eq!(resolve_replica_id(Some("r7")), "r7");
841 assert!(!resolve_replica_id(None).is_empty());
844 }
845
846 #[test]
847 fn heartbeat_defaults_to_a_third_of_ttl_and_is_clamped() {
848 assert_eq!(
849 heartbeat_interval(Some(500), 30.0),
850 Duration::from_millis(500)
851 );
852 assert_eq!(heartbeat_interval(None, 30.0), Duration::from_secs(10));
853 assert_eq!(heartbeat_interval(None, 0.6), Duration::from_secs(1));
855 assert_eq!(heartbeat_interval(Some(0), 30.0), Duration::from_millis(1));
856 }
857
858 #[test]
859 fn parses_key_value() {
860 assert_eq!(
861 parse_key_val("A=b").unwrap(),
862 ("A".to_string(), "b".to_string())
863 );
864 assert_eq!(
866 parse_key_val("URL=http://h:1?x=1").unwrap(),
867 ("URL".to_string(), "http://h:1?x=1".to_string())
868 );
869 assert!(parse_key_val("noequals").is_err());
870 assert!(parse_key_val("=val").is_err()); }
872
873 mod bool_env_args {
882 use super::super::Args;
883 use clap::Parser;
884 use std::sync::Mutex;
885
886 static ENV_LOCK: Mutex<()> = Mutex::new(());
887
888 struct EnvVarGuard {
891 key: &'static str,
892 prev: Option<String>,
893 }
894
895 impl EnvVarGuard {
896 fn set(key: &'static str, value: &str) -> Self {
897 let prev = std::env::var(key).ok();
898 unsafe { std::env::set_var(key, value) };
901 Self { key, prev }
902 }
903
904 fn unset(key: &'static str) -> Self {
905 let prev = std::env::var(key).ok();
906 unsafe { std::env::remove_var(key) };
908 Self { key, prev }
909 }
910 }
911
912 impl Drop for EnvVarGuard {
913 fn drop(&mut self) {
914 unsafe {
916 match &self.prev {
917 Some(v) => std::env::set_var(self.key, v),
918 None => std::env::remove_var(self.key),
919 }
920 }
921 }
922 }
923
924 #[test]
925 fn flag_alone_sets_true_with_no_value() {
926 let _lock = ENV_LOCK.lock().unwrap();
927 let _g1 = EnvVarGuard::unset("PROSPERO_INSECURE_NO_AUTH");
928 let _g2 = EnvVarGuard::unset("PROSPERO_COOKIE_SECURE");
929 let args = Args::try_parse_from(["prosperod", "--insecure-no-auth"]).unwrap();
930 assert!(args.insecure_no_auth);
931 assert!(!args.cookie_secure);
932 }
933
934 #[test]
935 fn absent_flag_and_env_defaults_to_false() {
936 let _lock = ENV_LOCK.lock().unwrap();
937 let _g1 = EnvVarGuard::unset("PROSPERO_INSECURE_NO_AUTH");
938 let _g2 = EnvVarGuard::unset("PROSPERO_COOKIE_SECURE");
939 let args = Args::try_parse_from(["prosperod"]).unwrap();
940 assert!(!args.insecure_no_auth);
941 assert!(!args.cookie_secure);
942 }
943
944 #[test]
945 fn env_var_equal_1_is_true() {
946 let _lock = ENV_LOCK.lock().unwrap();
947 let _g1 = EnvVarGuard::set("PROSPERO_INSECURE_NO_AUTH", "1");
948 let _g2 = EnvVarGuard::set("PROSPERO_COOKIE_SECURE", "1");
949 let args = Args::try_parse_from(["prosperod"]).unwrap();
950 assert!(args.insecure_no_auth);
951 assert!(args.cookie_secure);
952 }
953
954 #[test]
955 fn env_var_equal_0_or_false_is_false() {
956 let _lock = ENV_LOCK.lock().unwrap();
957 let _g1 = EnvVarGuard::set("PROSPERO_INSECURE_NO_AUTH", "0");
958 let _g2 = EnvVarGuard::set("PROSPERO_COOKIE_SECURE", "false");
959 let args = Args::try_parse_from(["prosperod"]).unwrap();
960 assert!(!args.insecure_no_auth);
961 assert!(!args.cookie_secure);
962 }
963 }
964
965 mod auth {
966 use super::super::resolve_auth;
967 use prospero_core::Scope;
968 use prospero_core::auth::{generate_token, tokens_file_line};
969 use std::net::SocketAddr;
970 use std::path::PathBuf;
971
972 fn loopback() -> SocketAddr {
973 "127.0.0.1:7878".parse().unwrap()
974 }
975 fn public() -> SocketAddr {
976 "0.0.0.0:7878".parse().unwrap()
977 }
978 fn write(dir: &std::path::Path, name: &str, contents: &str) -> PathBuf {
979 let p = dir.join(name);
980 std::fs::write(&p, contents).unwrap();
981 p
982 }
983 fn tokens(dir: &std::path::Path) -> PathBuf {
984 write(
985 dir,
986 "tokens",
987 &tokens_file_line("root", Scope::Admin, &generate_token()),
988 )
989 }
990 fn msg(e: anyhow::Error) -> String {
991 format!("{e:#}")
992 }
993
994 #[test]
995 fn loopback_without_tokens_runs_with_auth_disabled() {
996 let a = resolve_auth(loopback(), false, None, None, false, false).unwrap();
997 assert!(!a.is_enabled());
998 }
999
1000 #[test]
1001 fn public_bind_without_tokens_is_refused_unless_insecure() {
1002 let e = msg(resolve_auth(public(), false, None, None, false, false).unwrap_err());
1003 assert!(
1004 e.contains("--api-tokens-file") && e.contains("--insecure-no-auth"),
1005 "{e}"
1006 );
1007 let a = resolve_auth(public(), false, None, None, true, false).unwrap();
1008 assert!(!a.is_enabled());
1009 }
1010
1011 #[test]
1012 fn tokens_enable_auth_and_standalone_needs_no_key_file() {
1013 let dir = tempfile::tempdir().unwrap();
1014 let a = resolve_auth(
1015 public(),
1016 false,
1017 Some(&tokens(dir.path())),
1018 None,
1019 false,
1020 false,
1021 )
1022 .unwrap();
1023 assert!(a.is_enabled());
1024 }
1025
1026 #[test]
1027 fn clustered_tokens_require_a_session_key_of_32_bytes() {
1028 let dir = tempfile::tempdir().unwrap();
1029 let t = tokens(dir.path());
1030 let e = msg(resolve_auth(public(), true, Some(&t), None, false, false).unwrap_err());
1031 assert!(e.contains("--session-key-file"), "{e}");
1032 let short = write(dir.path(), "short", "too-short\n");
1033 let e = msg(
1034 resolve_auth(public(), true, Some(&t), Some(&short), false, false).unwrap_err(),
1035 );
1036 assert!(e.contains("at least 32 bytes"), "{e}");
1037 let good = write(dir.path(), "key", &format!("{}\n", "k".repeat(48)));
1038 assert!(
1039 resolve_auth(public(), true, Some(&t), Some(&good), false, false)
1040 .unwrap()
1041 .is_enabled()
1042 );
1043 }
1044
1045 #[test]
1046 fn malformed_or_missing_tokens_file_is_fatal() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let bad = write(dir.path(), "bad", "root admin sha256:nothex\n");
1049 let e =
1050 msg(resolve_auth(loopback(), false, Some(&bad), None, false, false).unwrap_err());
1051 assert!(e.contains("line 1"), "{e}");
1052 let missing = dir.path().join("nope");
1053 assert!(resolve_auth(loopback(), false, Some(&missing), None, false, false).is_err());
1054 }
1055
1056 #[test]
1057 fn insecure_flag_conflicts_with_tokens() {
1058 let dir = tempfile::tempdir().unwrap();
1059 let e = msg(resolve_auth(
1060 public(),
1061 false,
1062 Some(&tokens(dir.path())),
1063 None,
1064 true,
1065 false,
1066 )
1067 .unwrap_err());
1068 assert!(e.contains("cannot be combined"), "{e}");
1069 }
1070 }
1071}