The whole pipe behind one Driver trait and a common event model: spawn/list/delete sessions, message + question answering, append-only JSONL transcripts whose sequence numbers are the phone's resume cursor (surviving backend restarts), bearer-token middleware wrapping every route including the fallback, wg0-only binding that fails closed, and first-run token enrollment via a terminal QR. Verified: cargo test (10), clippy clean, and curl end-to-end over pinned TLS -- auth rejection, spawn, streamed SSE replay/resume, /question round trip, restart continuing seq numbers, delete removing everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
194 lines
7.4 KiB
Rust
194 lines
7.4 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 (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<PathBuf> = 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<IpAddr>,
|
|
|
|
/// Where the token hashes and session list live. Defaults to
|
|
/// `config.json` beside this repo's `certs/`.
|
|
#[arg(long)]
|
|
config: Option<PathBuf>,
|
|
|
|
/// Directory for per-session data (transcripts, attachments, images).
|
|
/// Defaults to `sessions/` in the repo root.
|
|
#[arg(long)]
|
|
data_dir: Option<PathBuf>,
|
|
|
|
/// Directory holding `leaf.pem`/`leaf-key.pem`. Defaults to this
|
|
/// repo's `certs/`, as produced by `gen-dev-cert.sh`.
|
|
#[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,
|
|
}
|
|
|
|
/// 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<IpAddr> {
|
|
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 <ip> 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::<qrcode::render::unicode::Dense1x2>()
|
|
.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::<SocketAddr>())
|
|
.await
|
|
.context("TLS listener failed")?;
|
|
|
|
Ok(())
|
|
}
|