//! A phone interface to AI coding sessions -- the backend. See PLAN.md for //! the whole picture; this is the entry point: config + session registry, //! token bootstrap, and the one TLS listener. //! //! The listener binds the WireGuard interface's address only, and fails //! closed -- if `wg0` is down the server refuses to start rather than //! falling back to `0.0.0.0`, because this API *is* remote code execution //! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP //! parsing, auth middleware) off the open internet. `--bind` overrides //! explicitly for development; that is a deliberate, logged choice, never a //! fallback. //! //! There is no plaintext listener at all, so the bearer token can't travel //! unencrypted by misconfiguration -- even inside the tunnel. mod auth; mod config; mod media; mod models; mod routes; mod session; mod setups; mod ssh; mod usage; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result}; 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; /// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app. #[derive(Parser)] struct Args { /// TLS port for the whole API surface. #[arg(long, default_value_t = DEFAULT_PORT)] port: u16, /// Address to bind instead of the wg0 interface's -- a development /// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone /// before the tunnel exists). Production runs without it and fails /// closed when wg0 is absent. #[arg(long)] bind: Option, /// Where the token hashes, providers, hosts, and session list live. /// Defaults to `$XDG_CONFIG_HOME/ai-app/config.ron`. #[arg(long)] config: Option, /// Directory for per-session data (transcripts, attachments, images). /// Defaults to `$XDG_DATA_HOME/ai-app/sessions`. #[arg(long)] data_dir: Option, /// Directory for downloaded GGUF models. Defaults to /// `$XDG_DATA_HOME/ai-app/models`. #[arg(long)] models_dir: Option, /// Directory holding the TLS certificates, generated here on first /// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`. #[arg(long)] certs: Option, /// Invalidate every enrolled token, generate a fresh one, and print /// its enrollment QR -- the whole lost-phone story. #[arg(long)] rotate_token: bool, } #[tokio::main] async fn main() -> Result<()> { // Both rustls crypto providers are in the dependency graph (ureq // brings ring, axum-server brings aws-lc-rs), so rustls refuses to // pick one itself; choose before anything touches TLS. rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("no other TLS crypto provider is installed before main"); tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); let config_path = args .config .unwrap_or_else(|| config_home("ai-app").join("config.ron")); 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()))?, ); // After construction rather than inside it: seeding asks this machine // what it has, which is I/O, 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), // No parenthetical naming the local machine: the default // setup is *called* "this machine", and the line read // "setup this machine (this machine)". 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 ); } // Before the interface check below, deliberately: the certificates are // also what the phone app embeds at build time, so they need to be // obtainable on a machine whose tunnel isn't up yet. The leaf is // reissued on every start, so once wg0 exists the next start covers it. let certs_dir = args .certs .unwrap_or_else(|| config_home("ai-app").join("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 {} -- any installed app pins the previous one and can no \ longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \ and reinstall through Dev Updater.", 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")?, }; // Token bootstrap: first run generates one; --rotate-token replaces // whatever exists. Either way the plaintext appears exactly once, in // the QR printed here. 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)?; } 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")?; let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new( usage::ClaudeUsage { credentials_path: std::env::home_dir() .unwrap_or_else(|| PathBuf::from("/")) .join(".claude/.credentials.json"), }, )])); // The bearer-token middleware wraps the entire router -- routes and // fallback alike -- here and only here, so a new route can't forget // auth. Zero unauthenticated endpoints. let app = routes::router(Arc::clone(&manager)) .merge(routes::usage_router(monitor)) .merge(routes::models_router(Arc::clone(&models))) .layer(axum::middleware::from_fn_with_state( Arc::clone(&manager), auth::require_token, )); let addr = SocketAddr::new(bind_ip, args.port); tracing::info!("serving https://{addr}"); // Stop the sessions' processes on the way out. Without this a signal // kills this process and leaves its children running -- which for the // Claude CLI is untidy and for a `llama-server` holding a model is // gigabytes of memory belonging to nobody. Both signals, because // systemd and OpenRC send TERM while a terminal sends INT. 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 -- stopping sessions"), _ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- stopping sessions"), } manager.shutdown_all(); Ok(()) }