Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors

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
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 20:51:34 -04:00
1 parent a6ece28344
commit 967fc814ab
13 files changed
+3349

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
//! 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(())
}