mod auth; mod config; mod files; mod media; mod models; mod resume; mod routes; mod session; mod setups; mod ssh; mod usage; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use axum::middleware::Next; use clap::Parser; use tokio::signal::unix::{SignalKind, signal}; use wg_app_link::enroll; use wg_app_link::netif::{self, WG_INTERFACE}; use wg_app_link::xdg::{config_home, data_home}; use config::TokenEntry; use session::SessionManager; const DEFAULT_PORT: u16 = 8443; #[derive(Parser)] struct Args { #[arg(long, default_value_t = DEFAULT_PORT)] port: u16, #[arg(long)] bind: Option, #[arg(long)] config: Option, #[arg(long)] data_dir: Option, #[arg(long)] models_dir: Option, #[arg(long)] certs: Option, #[arg(long)] rotate_token: bool, #[arg(long)] enroll_link: bool, #[arg(long, default_value_t = 0, value_name = "MS")] delay: u64, #[arg( long, default_value_t = cfg!(debug_assertions), action = clap::ArgAction::Set, num_args = 0..=1, default_missing_value = "true", value_name = "BOOL", )] throwaway_sessions: bool, } /// Where this run keeps its certificates: `--certs`, else the XDG default. /// One reader because `--enroll-link` returns before the rest of startup /// gets there, and a link minted against a different directory's CA than /// the server presents is a handshake failure with nothing on screen /// saying why. fn certs_dir(certs: &Option) -> std::path::PathBuf { certs .clone() .unwrap_or_else(|| config_home("ai-app").join("certs")) } fn read_ca(certs_dir: &std::path::Path) -> Result { let path = certs_dir.join("ca.pem"); std::fs::read_to_string(&path).with_context(|| { format!( "no CA certificate at {} -- start ai-server once so it generates one, \ or point --certs at the directory that has it", path.display() ) }) } #[tokio::main] async fn main() -> Result<()> { rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("no other TLS crypto provider is installed before main"); // `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than // as the filter, because `with_env_filter("info")` is a fixed directive that // never reads the environment -- so `RUST_LOG=ai_server=debug` printed // nothing, and the switch looked like the code it was meant to instrument. tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let args = Args::parse(); let config_path = args .config .unwrap_or_else(|| config_home("ai-app").join("config.ron")); if args.enroll_link { let bind_ip = match args.bind { Some(ip) => ip, None => netif::wg_address("ai-server")?, }; let token = enroll::generate_token(); enroll::spool_pending( &config::pending_enrollments_dir(&config_path), "phone", &token, )?; println!( "{}", enroll::enrollment_uri( "aiapp", bind_ip, args.port, &token, Some(&read_ca(&certs_dir(&args.certs))?) )? ); return Ok(()); } let data_dir = args .data_dir .unwrap_or_else(|| data_home("ai-app").join("sessions")); // Beside the session data rather than under it: models outlive every session // and are shared by all of them, so deleting a session must never take a // multi-gigabyte download with it. let models_dir = args .models_dir .unwrap_or_else(|| data_home("ai-app").join("models")); let models = Arc::new(models::ModelStore::new(models_dir.clone())); let manager = Arc::new( SessionManager::new(config_path.clone(), data_dir, models_dir.clone()) .with_context(|| format!("failed to load {}", config_path.display()))? .marking_new_sessions_throwaway(args.throwaway_sessions), ); if args.throwaway_sessions { tracing::warn!( "sessions spawned here are marked throwaway -- their processes are stopped when this \ server exits rather than left running (--throwaway-sessions=false to keep them)" ); } // After construction rather than inside it: seeding asks this machine what // it has, and a constructor that quietly runs a subprocess is a surprise to // every caller including the tests. manager.seed_setup().await?; tracing::info!("config: {}", config_path.display()); tracing::info!("models: {}", models_dir.display()); for setup in manager.setups() { match &setup.ssh { Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address), None => tracing::info!(" setup \"{}\" runs here", setup.name), } for provider in &setup.providers { tracing::info!(" provider {} ({:?})", provider.name, provider.kind); } } for info in manager.sessions() { tracing::info!( " session {} ({}, {:?})", info.id, info.provider, info.status ); } let certs_dir = certs_dir(&args.certs); let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses()) .with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?; if certificates.ca_is_new { tracing::warn!( "a new CA was generated in {} -- existing enrollments pin the previous one and can no \ longer reach this server. Enroll each client again with a newly minted link.", certs_dir.display(), ); } let bind_ip = match args.bind { Some(ip) => { tracing::warn!( "binding {ip} by explicit --bind override -- production binds {WG_INTERFACE} only" ); ip } None => netif::wg_address("ai-server")?, }; if args.rotate_token || manager.tokens().is_empty() { let rotating = args.rotate_token && !manager.tokens().is_empty(); let token = enroll::generate_token(); manager.set_tokens(vec![TokenEntry { name: "phone".to_string(), sha256: enroll::token_hash_hex(&token), }])?; if rotating { tracing::info!("rotated the enrolled token; the previous one is now invalid"); } enroll::print_enrollment( "aiapp", bind_ip, args.port, &token, Some(&read_ca(&certs_dir)?), )?; } let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( &certificates.leaf_cert, &certificates.leaf_key, ) .await .context("failed to load TLS cert/key")?; // No providers listed here any more: which machines can be asked, and about // what, comes from the setups at the moment the screen is opened -- so a // machine added from the phone reports its limits without a restart. // The fixture is the manager's, because that is where the `/usage` command // that sets it is typed; the monitor is what serves it. let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture())); tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor))); let app = routes::router(Arc::clone(&manager)) .merge(routes::usage_router(monitor, Arc::clone(&manager))) .merge(routes::models_router(Arc::clone(&models))) .layer(axum::middleware::from_fn_with_state( Arc::clone(&manager), auth::require_token, )); let app = match args.delay { 0 => app, ms => { tracing::warn!("delaying every response by {ms}ms -- development override"); app.layer(axum::middleware::from_fn( move |request, next: Next| async move { tokio::time::sleep(Duration::from_millis(ms)).await; next.run(request).await }, )) } }; let addr = SocketAddr::new(bind_ip, args.port); tracing::info!("serving https://{addr}"); let serving = axum_server::bind_rustls(addr, tls_config) .serve(app.into_make_service_with_connect_info::()); let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?; tokio::select! { served = serving => served.context("TLS listener failed")?, _ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"), _ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"), } manager.stop_throwaway_sessions(); manager.detach_all(); Ok(()) }