//! PiCloud all-in-one binary — see `lib.rs` for the actual app //! composition; this file is only the runtime shell (env config, //! logger, migrations, listener) plus the small `admin` CLI subcommand //! used for out-of-band password recovery. use std::io::{BufRead, Write}; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use picloud::{build_app, init_db, AuthDeps}; use picloud_manager_core::{ auth::{hash_password, validate_password_hash}, bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository, AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository, PostgresScriptRepository, }; use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> anyhow::Result<()> { init_tracing(); // Subcommand dispatch — `picloud admin reset-password `. // Kept handwritten to avoid pulling clap in just for one verb. Falls // through to the server when no subcommand is given. let args: Vec = std::env::args().collect(); if args.get(1).map(String::as_str) == Some("admin") { return run_admin_cli(&args[2..]).await; } run_server().await } async fn run_server() -> anyhow::Result<()> { let addr: SocketAddr = std::env::var("PICLOUD_BIND") .unwrap_or_else(|_| "0.0.0.0:8080".into()) .parse()?; let database_url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?; let pool = init_db(&database_url).await?; migrations::run(&pool).await?; tracing::info!("migrations applied"); let auth = AuthDeps::from_pool(pool.clone()); bootstrap_first_admin(&*auth.users).await?; warn_on_multi_owner_install(&*auth.users).await; // Seed Hello World into the default app when this is a fresh // install (no scripts and no routes). Idempotent on upgrades. let apps = Arc::new(PostgresAppRepository::new(pool.clone())); let scripts = Arc::new(PostgresScriptRepository::new(pool.clone())); let routes = Arc::new(PostgresRouteRepository::new(pool.clone())); match seed_hello_world_if_fresh(apps, scripts, routes).await { Ok(HelloWorldOutcome::Seeded) => { tracing::info!("hello-world seed inserted into the default app"); } Ok(HelloWorldOutcome::SkippedExisting) => { tracing::debug!("hello-world seed skipped (default app already populated)"); } Err(err) => { tracing::warn!(?err, "hello-world seed failed (continuing startup)"); } } // Background session-prune sweep. Cheap; keeps the table from // growing unbounded. Expired rows are also rejected at lookup time, // so a delayed sweep can't extend session lifetimes. spawn_session_pruner(auth.sessions.clone()); let app = build_app(pool, auth).await?; let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!(%addr, "picloud all-in-one listening"); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } /// Multi-owner startup warning — Phase 3.5 migration upgraded every /// pre-existing admin_users row to `Owner` via DEFAULT, which for /// installs with several Phase 3a admins means several co-owners. /// Surface this once at boot so the operator can demote extras via /// `PATCH /api/v1/admin/admins/{id}` with `instance_role: "admin"`. /// Soft-fail: a DB blip should not block startup. async fn warn_on_multi_owner_install(users: &dyn AdminUserRepository) { match users.list_active_owners().await { Ok(owners) if owners.len() > 1 => { let names: Vec = owners.into_iter().map(|u| u.username).collect(); tracing::warn!( count = names.len(), owners = ?names, "multiple active owners detected — Phase 3.5 promoted every \ pre-existing admin to owner. Demote extras via \ PATCH /api/v1/admin/admins/{{id}} with instance_role." ); } Ok(_) => {} Err(err) => { tracing::warn!( ?err, "could not count active owners for multi-owner startup check" ); } } } fn spawn_session_pruner(sessions: Arc) { tokio::spawn(async move { let mut ticker = tokio::time::interval(Duration::from_secs(600)); // First tick fires immediately; skip it so we don't race startup. ticker.tick().await; loop { ticker.tick().await; match sessions.prune_expired().await { Ok(n) if n > 0 => tracing::debug!(pruned = n, "expired admin sessions pruned"), Ok(_) => {} Err(err) => tracing::warn!(?err, "admin session prune failed"), } } }); } // ---------------------------------------------------------------------------- // `admin` subcommand // ---------------------------------------------------------------------------- async fn run_admin_cli(args: &[String]) -> anyhow::Result<()> { match args.first().map(String::as_str) { Some("reset-password") => { let username = args.get(1).ok_or_else(|| { anyhow::anyhow!( "usage: picloud admin reset-password [--password-hash ]" ) })?; // Optional inline hash via --password-hash ; otherwise // read a raw password from stdin. let hash_arg = parse_flag(&args[2..], "--password-hash"); cmd_reset_password(username, hash_arg).await } Some(other) => Err(anyhow::anyhow!("unknown admin subcommand: {other}")), None => Err(anyhow::anyhow!( "usage: picloud admin reset-password " )), } } fn parse_flag(args: &[String], name: &str) -> Option { let mut it = args.iter(); while let Some(a) = it.next() { if a == name { return it.next().cloned(); } } None } async fn cmd_reset_password(username: &str, password_hash: Option) -> anyhow::Result<()> { let database_url = std::env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("DATABASE_URL is required"))?; let pool = init_db(&database_url).await?; migrations::run(&pool).await?; let users = picloud_manager_core::PostgresAdminUserRepository::new(pool.clone()); let sessions = picloud_manager_core::PostgresAdminSessionRepository::new(pool); let target = users .get_by_username(username) .await? .ok_or_else(|| anyhow::anyhow!("no admin user named {username:?}"))?; let hash = if let Some(h) = password_hash { validate_password_hash(&h) .map_err(|_| anyhow::anyhow!("--password-hash is not a valid Argon2id PHC string"))?; h } else { let raw = prompt_password_from_stdin()?; hash_password(&raw).map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))? }; users.update_password_hash(target.id, &hash).await?; // Recovery implies the operator already lost control of the account; // re-activate it (so a deactivated admin can also recover) and wipe // any pre-existing sessions in case the original holder is still // signed in elsewhere. if !target.is_active { users.set_active(target.id, true).await?; } let dropped = sessions.delete_for_user(target.id).await?; println!("Password reset for {username}. Sessions dropped: {dropped}. Active: true."); Ok(()) } fn prompt_password_from_stdin() -> anyhow::Result { eprint!("New password (will be read from stdin, no echo): "); std::io::stderr().flush().ok(); let mut line = String::new(); std::io::stdin() .lock() .read_line(&mut line) .map_err(|e| anyhow::anyhow!("failed to read stdin: {e}"))?; let pw = line.trim_end_matches(['\n', '\r']).to_string(); if pw.is_empty() { return Err(anyhow::anyhow!("password must not be empty")); } Ok(pw) } // ---------------------------------------------------------------------------- // Misc // ---------------------------------------------------------------------------- fn init_tracing() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .json() .init(); } async fn shutdown_signal() { let ctrl_c = async { let _ = tokio::signal::ctrl_c().await; }; #[cfg(unix)] let terminate = async { if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { s.recv().await; } }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { () = ctrl_c => tracing::info!("ctrl-c received, draining"), () = terminate => tracing::info!("SIGTERM received, draining"), } }