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:
1 parent
a6ece28344
commit
967fc814ab
13 files changed
+3349
No files matched your search
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user