ai-app: a phone interface to Claude Code and llama.cpp sessions

A Rust backend that owns the sessions and an Android app that reads them.
The server spawns and adopts CLI processes, normalises everything they emit
into one event model, keeps the transcript, and serves it over pinned TLS on
a WireGuard interface; the phone streams that, replies, sends images, and
imports conversations the machine already has.

`AGENTS.md` is the working guide -- what runs where, what has been measured,
and the faults that were expensive to find. `PLAN.md` is the design record.

History before this point was squashed away. It was a personal project's
running commentary and carried a name and a couple of machine paths that
have no business in a public repository; the tree is what mattered and the
tree is here.
This commit is contained in:
iris committed 2026-08-31 20:29:07 -04:00
commit b172c464ea
100 files changed
+31795

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
//! Bearer-token auth for the entire HTTP surface.
//!
//! This server's API *is* remote code execution, so the token gates every
//! route with zero unauthenticated endpoints -- the middleware is applied
//! once around the whole router (including the fallback) in `main.rs`,
//! never per-route, so a new route can't forget it. See PLAN.md's security
//! section for the threat model; the short version is that the token gates
//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it
//! defense in depth rather than the sole gate.
//!
//! Nothing in this module -- and nothing anywhere else -- may log the
//! Authorization header or the token; the test below holds a tripwire
//! against a logging change silently starting to. It is one test covering
//! both gating and logging on purpose -- see the note in it.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use wg_app_link::enroll::token_matches;
use crate::session::SessionManager;
/// Applied to every rejection. Not against brute force -- infeasible at 256
/// bits -- but so a scanner probing the port shows up as a slow, loggable
/// drip rather than a fast one.
const REJECT_DELAY: Duration = Duration::from_millis(300);
pub async fn require_token(
State(manager): State<Arc<SessionManager>>,
request: Request,
next: Next,
) -> Response {
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "));
if let Some(token) = presented {
let hashes: Vec<String> = manager
.tokens()
.into_iter()
.map(|entry| entry.sha256)
.collect();
if token_matches(token, &hashes) {
return next.run(request).await;
}
}
// Peer address only -- never the header value. Absent when there is no
// real socket (tests driving the router directly).
let peer = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.to_string())
.unwrap_or_else(|| "unknown peer".to_string());
tracing::warn!("rejected request from {peer}: missing or invalid bearer token");
tokio::time::sleep(REJECT_DELAY).await;
(StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use axum::Router;
use axum::body::Body;
use axum::routing::get;
use tower::ServiceExt;
use wg_app_link::enroll::{generate_token, token_hash_hex};
use crate::config::TokenEntry;
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
let manager = Arc::new(
SessionManager::new(
dir.join("config.ron"),
dir.join("sessions"),
dir.join("models"),
)
.expect("manager"),
);
manager
.set_tokens(vec![TokenEntry {
name: "phone".to_string(),
sha256: token_hash_hex(token),
}])
.expect("set token");
manager
}
fn guarded_router(manager: Arc<SessionManager>) -> Router {
Router::new()
.route("/probe", get(|| async { "ok" }))
.fallback(|| async { StatusCode::NOT_FOUND })
.layer(axum::middleware::from_fn_with_state(manager, require_token))
}
fn request(path: &str, auth: Option<&str>) -> Request {
let mut builder = axum::http::Request::builder().uri(path);
if let Some(auth) = auth {
builder = builder.header(header::AUTHORIZATION, auth);
}
builder.body(Body::empty()).expect("request")
}
/// One test rather than separate gating and logging tests,
/// deliberately: tracing caches callsite interest process-wide, so a
/// test that hits the rejection path with no subscriber installed can
/// poison the interest cache for the one that captures logs. Keeping
/// every exercise of the middleware under the capturing subscriber
/// makes the log assertions deterministic.
#[tokio::test]
async fn gates_every_route_and_never_logs_the_token() {
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Capture {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
type Writer = Capture;
fn make_writer(&'a self) -> Capture {
self.clone()
}
}
let capture = Capture::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(capture.clone())
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let dir = tempfile::tempdir().expect("tempdir");
let token = generate_token();
let router = guarded_router(manager_with_token(dir.path(), &token));
// No header, wrong token, wrong scheme: 401 everywhere, including
// paths that don't exist -- a scanner learns nothing.
for (path, auth) in [
("/probe", None),
("/probe", Some("Bearer wrong".to_string())),
("/probe", Some(format!("Basic {token}"))),
("/no-such-route", None),
] {
let response = router
.clone()
.oneshot(request(path, auth.as_deref()))
.await
.expect("response");
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"{path} {auth:?}"
);
}
let ok = router
.clone()
.oneshot(request("/probe", Some(&format!("Bearer {token}"))))
.await
.expect("response");
assert_eq!(ok.status(), StatusCode::OK);
// The tripwire that keeps a future logging change (e.g. logging
// request headers) from silently leaking credentials.
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
assert!(
!logged.contains(&token),
"the bearer token leaked into the logs: {logged}"
);
// The rejections themselves do get logged (that's the point).
assert!(logged.contains("missing or invalid bearer token"));
}
}