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,140 @@
|
||||
//! The server's persistent state: the enrolled token hashes and the
|
||||
//! sessions that exist.
|
||||
//!
|
||||
//! Written whole and atomically (temp file + rename) rather than appended
|
||||
//! to: it is small, and a half-written config would take the server down on
|
||||
//! next start with no obvious way to recover from a phone. Every mutation
|
||||
//! funnels through `SessionManager` (the registry pattern), so in-memory
|
||||
//! and on-disk state can't come apart.
|
||||
//!
|
||||
//! Transcripts do NOT live here -- each session's events are an append-only
|
||||
//! JSONL file in its own directory (see `session::transcript`); this file
|
||||
//! holds only the metadata needed to list and respawn sessions.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct Config {
|
||||
/// Enrolled device tokens, hashes only -- a leaked config doesn't leak
|
||||
/// the credential. A list (of one, today) so per-device tokens with
|
||||
/// individual revocation are a config entry later, not a migration.
|
||||
pub tokens: Vec<TokenEntry>,
|
||||
pub sessions: Vec<SessionConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenEntry {
|
||||
/// Which device this token belongs to, for the human rotating it.
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the token. A plain hash is enough: the token is 256
|
||||
/// bits from the OS CSPRNG, so there is nothing to dictionary-attack
|
||||
/// and no stretching needed.
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Which driver a session runs. Phase 2 adds `Claude`, phase 4 adds `Pi`;
|
||||
/// a new kind is a new driver behind the same trait, never a branch in
|
||||
/// shared code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SessionKind {
|
||||
/// The phase-1 fake: echoes messages back as streamed events. Proves
|
||||
/// the whole pipe (spawn, SSE, transcript cursors, questions) with no
|
||||
/// AI involved, and stays useful as a connectivity check.
|
||||
Echo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionConfig {
|
||||
/// Stable identifier; names the session's directory and its routes.
|
||||
pub id: String,
|
||||
pub kind: SessionKind,
|
||||
pub title: String,
|
||||
/// Config name of the SSH host to run on; absent means local. Host
|
||||
/// configs arrive in phase 5.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Working directory the session's process runs in.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Claude permission mode chosen at spawn (default/plan/acceptEdits/
|
||||
/// bypassPermissions). Meaningless for other kinds; kept as a string
|
||||
/// because it is passed through to the CLI, not interpreted here.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
/// Epoch seconds when the session was spawned.
|
||||
pub created: f64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => serde_json::from_str(&text)
|
||||
.with_context(|| format!("{} is not valid config JSON", path.display())),
|
||||
// A first run has no config -- the normal starting state; a
|
||||
// token is generated and saved on that first start.
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
|
||||
Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self).context("serialize config")?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_the_config_file() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("config.json");
|
||||
|
||||
// A missing file is the ordinary first-run state, not an error.
|
||||
let first_run = Config::load(&path).expect("load");
|
||||
assert!(first_run.tokens.is_empty());
|
||||
assert!(first_run.sessions.is_empty());
|
||||
|
||||
let config = Config {
|
||||
tokens: vec![TokenEntry {
|
||||
name: "phone".to_string(),
|
||||
sha256: "ab".repeat(32),
|
||||
}],
|
||||
sessions: vec![SessionConfig {
|
||||
id: "abc123".to_string(),
|
||||
kind: SessionKind::Echo,
|
||||
title: "test".to_string(),
|
||||
host: None,
|
||||
model: None,
|
||||
cwd: None,
|
||||
permission_mode: None,
|
||||
created: 1234.5,
|
||||
}],
|
||||
};
|
||||
config.save(&path).expect("save");
|
||||
|
||||
let loaded = Config::load(&path).expect("reload");
|
||||
assert_eq!(loaded.tokens[0].name, "phone");
|
||||
assert_eq!(loaded.sessions[0].id, "abc123");
|
||||
assert_eq!(loaded.sessions[0].kind, SessionKind::Echo);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user