//! Application entry point — tracing, config, pool, then hands off to `build_app`. use axum::http::Request; use sqlx::ConnectOptions; use sqlx::postgres::PgPoolOptions; use std::time::{Duration, Instant}; use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::trace::TraceLayer; use tower_sessions::cookie::time::Duration as CookieDuration; use tower_sessions::cookie::SameSite; use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions_sqlx_store::PostgresStore; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; use makenotwork::config::Config; use makenotwork::constants; use docengine::{Assumptions, DocLoader, DocLoaderConfig}; use makenotwork::email::{EmailClient, EmailConfig}; use makenotwork::payments::StripeClient; use makenotwork::storage::S3Client; use makenotwork::scanning::ScanPipeline; use makenotwork::{build_app, AppState}; use webauthn_rs::WebauthnBuilder; #[tokio::main] async fn main() { dotenvy::dotenv().ok(); // JSON in release, human-readable in dev tracing_subscriber::registry() .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "makenotwork=debug,tower_http=debug,sqlx=info".into()), ) .with(if cfg!(debug_assertions) { tracing_subscriber::fmt::layer().boxed() } else { tracing_subscriber::fmt::layer().json().boxed() }) .init(); // Sando boot-smoke gate spawns the binary with SANDO_BOOT_SMOKE=1 to // verify it loads + links + survives. The real init loads // assumptions.toml and other runtime files that don't exist in the // sando build workspace. Short-circuit to a tiny axum server that // proves the binary, tokio runtime, axum, and TCP bind all work, // then idles until the gate kills it. if std::env::var("SANDO_BOOT_SMOKE").is_ok() { tracing::info!("SANDO_BOOT_SMOKE=1; running minimal smoke server"); let app = axum::Router::new() .route("/health", axum::routing::get(|| async { "ok" })); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await .expect("smoke: bind 127.0.0.1:0"); axum::serve(listener, app).await.expect("smoke: serve"); return; } let config = Config::from_env().expect("Failed to load configuration"); tracing::info!("Configuration loaded"); // Create database connection pool with health checks and lifecycle limits. // - test_before_acquire: validates connections before use (catches stale/broken conns) // - max_lifetime: rotates connections to prevent long-lived session issues // - idle_timeout: prunes idle connections to free server resources // - min_connections: keeps warm connections ready for immediate use // - log_slow_statements: logs queries exceeding 100ms at WARN level let connect_options: sqlx::postgres::PgConnectOptions = config.database_url.parse() .expect("Invalid DATABASE_URL"); let connect_options = connect_options .log_statements(log::LevelFilter::Trace) .log_slow_statements(log::LevelFilter::Warn, Duration::from_millis(100)); let db = PgPoolOptions::new() .max_connections(constants::DB_POOL_MAX_CONNECTIONS) .min_connections(constants::DB_POOL_MIN_CONNECTIONS) .acquire_timeout(Duration::from_secs(constants::DB_ACQUIRE_TIMEOUT_SECS)) .max_lifetime(Duration::from_secs(constants::DB_MAX_LIFETIME_SECS)) .idle_timeout(Duration::from_secs(constants::DB_IDLE_TIMEOUT_SECS)) .test_before_acquire(true) .connect_with(connect_options) .await .expect("Failed to connect to database"); tracing::info!("Database connected"); // Run migrations — exit cleanly (code 2) on failure instead of panicking. // This prevents systemd from crash-looping on migration errors (e.g. version // conflicts after a bad deploy). WAM ticket alerts the operator. if let Err(e) = sqlx::migrate!("./migrations").run(&db).await { tracing::error!(error = %e, "Migration failed — exiting without restart"); // Best-effort WAM alert (DB is up, WAM may be reachable) if let Ok(wam_url) = std::env::var("WAM_URL") { let body = format!("Migration error on startup:\n{e}"); let _ = reqwest::Client::new() .post(format!("{wam_url}/tickets")) .json(&serde_json::json!({ "title": "Migration failure — server not starting", "body": body, "priority": "critical", "source": "migration-failure", })) .timeout(std::time::Duration::from_secs(5)) .send() .await; } // Exit code 2 — systemd configured not to restart on this code std::process::exit(2); } tracing::info!("Migrations complete"); // Create PostgreSQL-backed session store (persists across restarts) let session_store = PostgresStore::new(db.clone()); session_store .migrate() .await .expect("Failed to migrate session store"); // In release mode, require HTTPS for session cookies (override with INSECURE_COOKIES=1 for staging) let secure_cookies = !cfg!(debug_assertions) && std::env::var("INSECURE_COOKIES").unwrap_or_default() != "1"; if secure_cookies { tracing::info!("Session cookies configured for HTTPS (secure=true)"); } else if !cfg!(debug_assertions) { tracing::warn!("Session cookies set to insecure (INSECURE_COOKIES=1)"); } let session_layer = SessionManagerLayer::new(session_store) .with_secure(secure_cookies) .with_http_only(true) .with_same_site(SameSite::Lax) // Lax allows session on top-level navigations (OAuth redirects) .with_expiry(Expiry::OnInactivity(CookieDuration::days( constants::SESSION_EXPIRY_DAYS, ))); tracing::info!("PostgreSQL session store initialized"); // Initialize S3 client if storage is configured let s3: Option> = if let Some(ref storage_config) = config.storage { match S3Client::new(storage_config, &config.host_url).await { Ok(client) => { tracing::info!(bucket = %storage_config.bucket, "S3 storage initialized"); Some(std::sync::Arc::new(client)) } Err(e) => { tracing::warn!(error = ?e, "failed to initialize S3 storage, file uploads unavailable"); None } } } else { tracing::info!("S3 storage not configured. File uploads will be unavailable."); None }; // Initialize SyncKit blob S3 client if configured (separate bucket) let synckit_s3: Option> = if let Some(ref synckit_storage_config) = config.synckit_storage { match S3Client::new(synckit_storage_config, &config.host_url).await { Ok(client) => { tracing::info!(bucket = %synckit_storage_config.bucket, "SyncKit blob storage initialized"); Some(std::sync::Arc::new(client)) } Err(e) => { tracing::warn!(error = ?e, "Failed to initialize SyncKit blob storage"); None } } } else { tracing::info!("SyncKit blob storage not configured"); None }; // Initialize Stripe client if configured let stripe: Option> = if let Some(ref stripe_config) = config.stripe { tracing::info!("Stripe payments initialized"); Some(std::sync::Arc::new(StripeClient::new(stripe_config))) } else { tracing::info!("Stripe not configured. Payments will be unavailable."); None }; // Initialize email client (logs in dev mode when POSTMARK_TOKEN is not set) let email = EmailClient::new(EmailConfig::from_env(), Some(db.clone())); // Load business assumptions (single source of truth for figures in the docs). // Validation failure aborts startup — we don't want to serve stale numbers. let assumptions_path = std::env::var("ASSUMPTIONS_PATH") .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string()); let assumptions = std::sync::Arc::new( match Assumptions::load(&assumptions_path) { Ok(a) => { if let Err(e) = a.validate() { panic!("assumptions validation failed:\n{e}"); } tracing::info!(path = %assumptions_path, "assumptions loaded and validated"); a } Err(e) => panic!("failed to load assumptions from {assumptions_path}: {e}"), } ); // Load documentation pages from disk let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string()); let docs = std::sync::Arc::new(DocLoader::load( std::path::Path::new(&docs_path), &DocLoaderConfig { sections: vec![ ("about".to_string(), "About".to_string()), ("guide".to_string(), "Guide".to_string()), ("developer".to_string(), "Developer".to_string()), ("legal".to_string(), "Legal".to_string()), ("support".to_string(), "Support".to_string()), ("tech".to_string(), "Tech".to_string()), ], link_prefix: "/docs".to_string(), unpublished_pattern: Some("unpublished/".to_string()), examples_path: Some(std::path::Path::new(&docs_path).join("../examples")), pre_process: { let assumptions = assumptions.clone(); Some(Box::new(move |md: &str| { assumptions.substitute(md).map_err(|e| e.to_string()) })) }, }, )); // Initialize file scanning pipeline (optional). If scanning is configured, // assert at least one AV layer is actually live — otherwise the FailOpen // policy on ClamAV silently passes everything as Clean and the deploy // looks healthy while shipping zero real coverage. Refuse to boot. let scanner = if let Some(ref scan_config) = config.scan { match ScanPipeline::new(scan_config) { Ok(s) => match s.assert_live().await { Ok(()) => { tracing::info!("File scanning enabled"); Some(std::sync::Arc::new(s)) } Err(e) => panic!("Scanning configured but no live AV layer: {e}"), }, Err(e) => { tracing::warn!(error = %e, "File scanning disabled"); None } } } else { tracing::info!("File scanning not configured"); None }; // Initialize WebAuthn from HOST_URL (passkey / passwordless login) let rp_origin = url::Url::parse(&config.host_url).expect("HOST_URL is not a valid URL"); let rp_id = rp_origin.host_str().expect("HOST_URL has no host").to_string(); let webauthn = std::sync::Arc::new( WebauthnBuilder::new(&rp_id, &rp_origin) .expect("Failed to create WebauthnBuilder") .rp_name("Makenotwork") .build() .expect("Failed to build Webauthn"), ); tracing::info!("WebAuthn initialized (rp_id={rp_id})"); // Initialize syntax highlighter if git repos path is configured let syntax = if config.git_repos_path.is_some() { tracing::info!(path = ?config.git_repos_path, "Git source browser enabled"); Some(std::sync::Arc::new(makenotwork::git::SyntaxHighlighter::new())) } else { tracing::info!("Git source browser not configured (GIT_REPOS_PATH unset)"); None }; // Construct MT client when both mt_base_url and internal_shared_secret are set let mt_client = match (&config.mt_base_url, &config.internal_shared_secret) { (Some(base_url), Some(secret)) => { tracing::info!(base_url = %base_url, "MT integration enabled"); Some(makenotwork::mt_client::MtClient::new(base_url.clone(), secret.clone())) } _ => { tracing::info!("MT integration not configured (need MT_BASE_URL + INTERNAL_SHARED_SECRET)"); None } }; // WAM ticket manager client (tailnet-only, for operational alerts) let wam = config.wam_url.as_ref().map(|url| { tracing::info!(url = %url, "WAM integration enabled"); makenotwork::wam_client::WamClient::new(url.clone()) }); // Warm custom domain cache let domain_cache = std::sync::Arc::new(dashmap::DashMap::new()); match makenotwork::db::custom_domains::get_all_verified_domains(&db).await { Ok(domains) => { for d in &domains { domain_cache.insert(d.domain.clone(), d.user_id); } if !domains.is_empty() { tracing::info!(count = domains.len(), "Custom domain cache warmed"); } } Err(e) => { tracing::warn!(error = ?e, "Failed to warm custom domain cache"); } } let started_at = chrono::Utc::now(); let start_instant = Instant::now(); let page_view_tx = makenotwork::db::page_views::spawn_batcher(db.clone()); let bg = makenotwork::background::spawn_pool(); let state = AppState { db, config: config.clone(), s3, synckit_s3, stripe, email, docs, tier_prices: makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions), cost_allocation: { let tp = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions); makenotwork::tier_prices::CostAllocation::from_assumptions(&assumptions, &tp) }, runway_config: makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions), scanner, webauthn, syntax, started_at, start_instant, session_cache: std::sync::Arc::new(dashmap::DashMap::new()), mt_client, wam, domain_cache, scan_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::SCAN_MAX_CONCURRENT)), caddy_ask_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(makenotwork::constants::CADDY_ASK_MAX_CONCURRENT)), restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), sync_notify: std::sync::Arc::new(dashmap::DashMap::new()), sse_connections: std::sync::Arc::new(dashmap::DashMap::new()), metrics_handle: Some(makenotwork::metrics::init()), page_view_tx, bg, }; // Log active features at startup tracing::info!( s3 = state.s3.is_some(), synckit_s3 = state.synckit_s3.is_some(), stripe = state.stripe.is_some(), scanner = state.scanner.is_some(), mt = state.mt_client.is_some(), wam = state.wam.is_some(), git = state.config.git_repos_path.is_some(), "Active features" ); // Start background health monitor and scheduler let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(()); let _monitor_handle = makenotwork::monitor::spawn_monitor(state.clone(), shutdown_rx); let scheduler_shutdown_rx = shutdown_tx.subscribe(); let _scheduler_handle = makenotwork::scheduler::spawn_scheduler(state.clone(), scheduler_shutdown_rx); // Start scan worker pool. Only meaningful if a scanner is configured; // otherwise enqueue_scan_for never enqueues (trust-gate fast path). if let (Some(scanner), Some(s3_for_workers)) = (state.scanner.clone(), state.s3.clone()) { let scan_ctx = std::sync::Arc::new(makenotwork::scanning::worker::WorkerContext { db: state.db.clone(), s3: s3_for_workers, pipeline: scanner, scan_semaphore: state.scan_semaphore.clone(), wam: state.wam.clone(), }); let worker_count = makenotwork::constants::SCAN_WORKER_COUNT; let worker_shutdown_rx = shutdown_tx.subscribe(); makenotwork::scanning::worker::spawn_pool(worker_count, scan_ctx, worker_shutdown_rx); tracing::info!(worker_count, "scan worker pool started"); let report = makenotwork::scanning::spool::reap_all( std::path::Path::new(makenotwork::constants::SCAN_SPOOL_DIR), ); if report.deleted > 0 || report.errors > 0 { tracing::info!( deleted = report.deleted, errors = report.errors, "scan spool startup reaper completed" ); } } // Build router (shared with integration tests via lib.rs) let app = build_app(state, session_layer) // Request ID: propagate → trace → set (Axum applies inside-out) .layer(PropagateRequestIdLayer::x_request_id()) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { let request_id = request .headers() .get("x-request-id") .and_then(|v| v.to_str().ok()) .unwrap_or("-"); tracing::info_span!( "request", method = %request.method(), uri = %request.uri(), request_id = %request_id, user_id = tracing::field::Empty, ) }) .on_response(|response: &axum::http::Response<_>, latency: Duration, _span: &tracing::Span| { let status = response.status().as_u16(); if status >= 500 { tracing::error!(status, latency_ms = latency.as_millis() as u64, "response"); } else if status >= 400 { tracing::warn!(status, latency_ms = latency.as_millis() as u64, "response"); } else { tracing::debug!(status, latency_ms = latency.as_millis() as u64, "response"); } }), ) .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)); let addr = config.socket_addr(); tracing::info!(addr = %addr, "listening"); let listener = tokio::net::TcpListener::bind(addr) .await .expect("Failed to bind to address"); // Use into_make_service_with_connect_info to provide peer IP for rate limiting. // After the shutdown signal fires, in-flight requests get a 10-second window // to complete before the process force-exits. This prevents hung connections // from blocking deployment restarts indefinitely. axum::serve( listener, app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(shutdown_signal()) .await .expect("Server error"); drop(shutdown_tx); // signal monitor to stop tracing::info!("Server shut down gracefully"); } /// Wait for SIGINT (Ctrl-C) or SIGTERM, then return to trigger graceful shutdown. /// In-flight requests are allowed up to 10 seconds to complete. After that, /// the process force-exits to prevent hung connections from blocking restarts. async fn shutdown_signal() { use tokio::signal; let ctrl_c = async { signal::ctrl_c() .await .expect("Failed to install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("Failed to install SIGTERM handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { () = ctrl_c => tracing::info!("Received SIGINT, starting graceful shutdown"), () = terminate => tracing::info!("Received SIGTERM, starting graceful shutdown"), } // Spawn a hard deadline: if graceful drain takes longer than 10s, force exit tokio::spawn(async { tokio::time::sleep(Duration::from_secs(10)).await; eprintln!("Graceful shutdown timed out after 10s, forcing exit"); std::process::exit(1); }); }