Pure formatting pass — no behavior changes. Catches the line-wrapping drift across the new authz / api_keys / middleware / handler edits that piled up during the implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
8.9 KiB
Rust
243 lines
8.9 KiB
Rust
//! 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 <username>`.
|
|
// Kept handwritten to avoid pulling clap in just for one verb. Falls
|
|
// through to the server when no subcommand is given.
|
|
let args: Vec<String> = 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<String> = 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<dyn AdminSessionRepository>) {
|
|
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 <username> [--password-hash <hash>]"
|
|
)
|
|
})?;
|
|
// Optional inline hash via --password-hash <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 <username>"
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn parse_flag(args: &[String], name: &str) -> Option<String> {
|
|
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<String>) -> 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<String> {
|
|
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"),
|
|
}
|
|
}
|