//! 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 routes; mod session; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result, bail}; use clap::Parser; use config::TokenEntry; use session::SessionManager; const DEFAULT_PORT: u16 = 8443; const WG_INTERFACE: &str = "wg0"; /// The repo root, one level above this crate. Everything the server reads /// by default -- the TLS cert, the config, the session data -- resolves /// from here, so there's one definition of it rather than one per caller. fn repo_root() -> &'static Path { static ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); ROOT.get_or_init(|| { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("CARGO_MANIFEST_DIR has a repo-root parent") .to_path_buf() }) } /// 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 and session list live. Defaults to /// `config.json` beside this repo's `certs/`. #[arg(long)] config: Option, /// Directory for per-session data (transcripts, attachments, images). /// Defaults to `sessions/` in the repo root. #[arg(long)] data_dir: Option, /// Directory holding `leaf.pem`/`leaf-key.pem`. Defaults to this /// repo's `certs/`, as produced by `gen-dev-cert.sh`. #[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, } /// The IPv4 address on the WireGuard interface, or a refusal to start. /// Failing closed here (rather than falling back to a wider bind) is part /// of the security posture -- see the module doc comment. fn wg_address() -> Result { let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?; interfaces .into_iter() .find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4()) .map(|iface| iface.ip()) .ok_or_else(|| { anyhow::anyhow!( "no IPv4 address on interface {WG_INTERFACE} -- this server binds only to the \ WireGuard tunnel and refuses to fall back to a wider address. Bring the tunnel \ up, or pass --bind explicitly for development." ) }) } /// Prints the one-time enrollment QR: an `aiapp://enroll` URI carrying /// where to connect and the bearer token. The CA stays embedded in the APK, /// so this carries no trust material -- photographing the terminal leaks /// only the token, which is rotatable (`--rotate-token`). Printed to /// stdout, not the log: it is for the human at the terminal, once. fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> { let uri = format!("aiapp://enroll?host={host}&port={port}&token={token}"); let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?; let rendered = code .render::() .quiet_zone(true) .build(); println!("\n{rendered}\n"); println!("Scan with the phone's camera to enroll (or paste into the app's settings):"); println!(" {uri}"); println!("The token is not stored in the clear and won't be shown again;"); println!("a lost phone means `--rotate-token`.\n"); Ok(()) } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); let config_path = args.config.unwrap_or_else(|| repo_root().join("config.json")); let data_dir = args.data_dir.unwrap_or_else(|| repo_root().join("sessions")); let manager = Arc::new( SessionManager::new(config_path.clone(), data_dir) .with_context(|| format!("failed to load {}", config_path.display()))?, ); tracing::info!("config: {}", config_path.display()); for info in manager.sessions() { tracing::info!(" session {} ({:?}, {:?})", info.id, info.kind, info.status); } let bind_ip = match args.bind { Some(ip) => { tracing::warn!( "binding {ip} by explicit --bind override -- production binds {WG_INTERFACE} only" ); ip } None => wg_address()?, }; // 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 = auth::generate_token(); manager.set_tokens(vec![TokenEntry { name: "phone".to_string(), sha256: auth::token_hash_hex(&token), }])?; if rotating { tracing::info!("rotated the enrolled token; the previous one is now invalid"); } print_enrollment(bind_ip, args.port, &token)?; } let certs_dir = args.certs.unwrap_or_else(|| repo_root().join("certs")); let leaf_cert = certs_dir.join("leaf.pem"); let leaf_key = certs_dir.join("leaf-key.pem"); if !leaf_cert.is_file() || !leaf_key.is_file() { bail!( "missing {} / {} -- run ./gen-dev-cert.sh first (the app pins the CA it generates, \ and this server refuses to serve without TLS)", leaf_cert.display(), leaf_key.display(), ); } let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key) .await .context("failed to load TLS cert/key")?; // 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)).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}"); axum_server::bind_rustls(addr, tls_config) .serve(app.into_make_service_with_connect_info::()) .await .context("TLS listener failed")?; Ok(()) }