The Models tab was about this backend's own disk, which is the wrong disk for every session that runs anywhere else: llama.cpp reads the file where it runs. So the models of a machine live under that machine's llama.cpp provider now, beside the settings deciding how each is loaded, and the download that produces one happens there. A download is a detached `curl` on that machine, started by a script this server writes and never spoken to again. Its state is a file beside the partial, so nothing about it is held here: it survives the app closing, this backend restarting and a second device watching, and the progress is `wc -c` of the partial against the size HuggingFace published rather than anything remembered. A run whose process is gone is reported failed, since `kill -0` is asked at each listing, and there is no "finished" state -- a download that finished is a model, in the list beside the ones still going. Resuming is guarded by the published sha256, which is also checked before the file takes its real name. Two other things the same screens wanted: A provider is drawn as a card rather than as a line of text, bordered against the machine card it sits in -- the tint it had was one step along the surface ladder and rendered as one flat block -- with room to tap and no chevron. Nothing in a raw block wraps any more; the block scrolls sideways instead, one offset for all its lines, so a diff or a column-aligned test run still reads as one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
14 KiB
Rust
338 lines
14 KiB
Rust
//! 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 off the open internet. `--bind` overrides
|
|
//! explicitly for development; 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 files;
|
|
mod gguf;
|
|
mod machines;
|
|
mod media;
|
|
mod models;
|
|
mod provider_auth;
|
|
mod resume;
|
|
mod routes;
|
|
mod session;
|
|
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;
|
|
|
|
/// Serves AI coding sessions (Codex, 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
|
|
/// (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<IpAddr>,
|
|
|
|
/// Where the token hashes, providers, hosts, and session list live.
|
|
/// Defaults to `$XDG_CONFIG_HOME/ai-app/config.ron`.
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
|
|
/// Directory for per-session data (transcripts, attachments, images).
|
|
/// Defaults to `$XDG_DATA_HOME/ai-app/sessions`.
|
|
#[arg(long)]
|
|
data_dir: Option<PathBuf>,
|
|
|
|
/// Directory for downloaded GGUF models. Defaults to
|
|
/// `$XDG_DATA_HOME/ai-app/models`.
|
|
#[arg(long)]
|
|
models_dir: Option<PathBuf>,
|
|
|
|
/// Directory holding the TLS certificates, generated here on first
|
|
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
|
#[arg(long)]
|
|
certs: Option<PathBuf>,
|
|
|
|
/// Invalidate every enrolled token, generate a fresh one, and print
|
|
/// its enrollment QR -- the whole lost-phone story.
|
|
#[arg(long)]
|
|
rotate_token: bool,
|
|
|
|
/// Enroll one more device without touching the running server: mint a
|
|
/// token, print its enrollment link (one line, stdout, nothing else) and
|
|
/// exit. The server adopts the token the first time that device uses it.
|
|
/// For a tool that opens the link on the phone, where a QR printed here
|
|
/// cannot be scanned.
|
|
#[arg(long)]
|
|
enroll_link: bool,
|
|
|
|
/// Hold every response back by this many milliseconds.
|
|
///
|
|
/// A development aid, and a specific one: over the tunnel a phone's requests
|
|
/// take tens to hundreds of milliseconds, and several faults live entirely
|
|
/// in what the app does *while* one is outstanding. On a loopback server
|
|
/// those windows close before anything can be observed and the bug looks
|
|
/// like it is not there.
|
|
#[arg(long, default_value_t = 0, value_name = "MS")]
|
|
delay: u64,
|
|
|
|
/// Mark every session spawned here as throwaway: its process is stopped
|
|
/// when this server exits, instead of being left running for the next start
|
|
/// to adopt. On by default in a debug build.
|
|
///
|
|
/// Sessions outlive the backend on purpose, which is right for the ones
|
|
/// somebody is using and wrong for the ones a test made -- twelve of those
|
|
/// accumulated on this machine in a day, each holding a conversation open.
|
|
///
|
|
/// The flag decides only what *new* sessions are marked as. What happens on
|
|
/// the way out is decided by the mark, which outlives the server that made
|
|
/// it.
|
|
#[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,
|
|
}
|
|
|
|
#[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.
|
|
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"));
|
|
// Before the manager exists, on purpose: constructing it and seeding machines
|
|
// touches sessions and subprocesses this invocation has no business with
|
|
// while another instance is serving. Only the hash reaches disk, in the
|
|
// spool `auth.rs` reads; the link goes to stdout alone.
|
|
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)
|
|
);
|
|
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 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_machine().await?;
|
|
|
|
tracing::info!("config: {}", config_path.display());
|
|
tracing::info!("models: {}", models_dir.display());
|
|
for machine in manager.machines() {
|
|
match &machine.ssh {
|
|
Some(ssh) => tracing::info!(" machine \"{}\" -> {}", machine.name, ssh.address),
|
|
// No parenthetical naming the local machine: the default machine is
|
|
// *called* "this machine", so repeating a local qualifier read
|
|
// like a stutter.
|
|
None => tracing::info!(" machine \"{}\" runs here", machine.name),
|
|
}
|
|
for provider in &machine.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.
|
|
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.
|
|
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")?;
|
|
|
|
// No providers listed here any more: which machines can be asked, and about
|
|
// what, comes from the machines 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()));
|
|
let provider_logins = Arc::new(provider_auth::LoginManager::new(Arc::clone(&monitor)));
|
|
|
|
// The one thing in here that acts without a request behind it: a session
|
|
// switched to auto-resume waits out its account's usage limit and picks
|
|
// itself back up. Started whether or not any session has it on, because
|
|
// the setting is per session and changes from the phone -- see
|
|
// `resume::run`.
|
|
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
|
|
|
|
// The bearer-token middleware wraps the entire router -- routes and fallback
|
|
// alike -- here and only here, so a new route can't forget auth.
|
|
let app = routes::router(Arc::clone(&manager))
|
|
.merge(routes::usage_router(
|
|
Arc::clone(&monitor),
|
|
Arc::clone(&manager),
|
|
))
|
|
.merge(routes::provider_auth_router(
|
|
Arc::clone(&provider_logins),
|
|
Arc::clone(&manager),
|
|
))
|
|
.layer(axum::middleware::from_fn_with_state(
|
|
Arc::clone(&manager),
|
|
auth::require_token,
|
|
));
|
|
|
|
// Outside the auth layer, so an unauthenticated request is refused at the
|
|
// speed it always was: this is here to slow the app down, not to widen the
|
|
// window on anything guessing at tokens.
|
|
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 go of the sessions on the way out rather than stopping them: their
|
|
// processes are meant to outlive this one. Each is recorded in its session
|
|
// directory and adopted again on the way back up. The exception is the
|
|
// sessions marked throwaway, which are stopped first. 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::<SocketAddr>());
|
|
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"),
|
|
}
|
|
// Stopped before the rest are let go of, and on every way out of the select
|
|
// above: a throwaway session is one nobody meant to keep, and the whole point
|
|
// is that nothing has to remember to clean it up.
|
|
manager.stop_throwaway_sessions();
|
|
provider_logins.cancel_all();
|
|
manager.detach_all();
|
|
|
|
Ok(())
|
|
}
|