mod http;
mod report;
use crate::cli;
use crate::fleet;
use crate::loader::Loader;
use crate::pricing::Plan;
use crate::session::Session;
use crate::watch::Watch;
use http::{EventStream, Request};
use std::collections::HashMap;
use std::io::Write;
use std::net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const DEFAULT_PORT: u16 = 7777;
const PORT_SEARCH: u16 = 16;
const MAX_CONNECTIONS: usize = 32;
const FULL_WALK: Duration = Duration::from_secs(30);
const SSE_KEEPALIVE: Duration = Duration::from_secs(20);
const DASHBOARD_HTML: &str = include_str!("assets/dashboard.html");
const REPORT_HTML: &str = include_str!("assets/report.html");
const COMMON_CSS: &str = include_str!("assets/common.css");
struct Shared {
token: String,
plan: Plan,
latest: Mutex<Arc<Snapshot>>,
updated: Condvar,
store: crate::cache::Store,
}
struct Snapshot {
version: u64,
json: String,
sessions: Vec<Session>,
host_errors: Vec<(String, String)>,
}
#[derive(Default)]
struct Remotes {
rows: HashMap<String, Vec<Session>>,
errors: HashMap<String, String>,
}
pub const HELP: &str = "\
cctop serve — the session table in a browser, read-only
USAGE:
cctop serve [OPTIONS]
OPTIONS:
--bind <ADDR> Address to listen on [default: 127.0.0.1]. Anything other
than a loopback address puts the page on your network, which
is announced on stderr when it happens
--port <PORT> Port to listen on [default: 7777]. Without this flag a busy
port is stepped past; with it, a busy port is an error
--no-token Serve without an access token. Every process and user on the
machine can then read your sessions
--plan <PLAN> Billing plan for cost figures: retail, max, or included
[default: retail]
--delay <SECS> Seconds between refreshes [default: 2]
--host <HOST> Also serve the sessions on another machine, over ssh.
Repeatable; same syntax as `cctop --host`
-h, --help Print this help
The page is read-only: it starts nothing, stops nothing, and types at nothing.
";
pub fn run(argv: &[String]) -> anyhow::Result<i32> {
let mut bind = "127.0.0.1".to_string();
let mut port = DEFAULT_PORT;
let mut port_given = false;
let mut no_token = false;
let mut plan = Plan::Retail;
let mut delay = Duration::from_secs(2);
let mut hosts: Vec<String> = Vec::new();
let mut it = argv.iter();
while let Some(flag) = it.next() {
let mut value = || {
it.next()
.cloned()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match flag.as_str() {
"-h" | "--help" => {
print!("{HELP}");
return Ok(0);
}
"--bind" => bind = value()?,
"--port" => {
port = value()?
.parse()
.map_err(|_| anyhow::anyhow!("--port takes a number from 1 to 65535"))?;
port_given = true;
}
"--no-token" => no_token = true,
"--plan" => {
let given = value()?;
plan = Plan::parse(&given).ok_or_else(|| {
anyhow::anyhow!("unsupported plan '{given}'; use retail, max or included")
})?;
}
"--delay" => {
let secs: f64 = value()?
.parse()
.map_err(|_| anyhow::anyhow!("--delay takes a number of seconds"))?;
if !secs.is_finite() || !(0.5..3600.0).contains(&secs) {
anyhow::bail!("--delay must be between 0.5 and 3600 seconds");
}
delay = Duration::from_secs_f64(secs);
}
"--host" => hosts.push(value()?),
other => anyhow::bail!("unknown option '{other}'\n\n{HELP}"),
}
}
let listener = listen(&bind, port, port_given)?;
let addr = listener.local_addr()?;
let token = if no_token { String::new() } else { new_token() };
announce(&addr, &bind, &token, no_token);
let shared = Arc::new(Shared {
token,
plan,
latest: Mutex::new(Arc::new(Snapshot {
version: 0,
json: "[]".to_string(),
sessions: Vec::new(),
host_errors: Vec::new(),
})),
updated: Condvar::new(),
store: crate::cache::Store::new(),
});
let remotes = Arc::new(Mutex::new(Remotes::default()));
for host in fleet::Host::collect(&hosts) {
spawn_host_poller(host, Arc::clone(&remotes));
}
spawn_refresher(Arc::clone(&shared), Arc::clone(&remotes), plan, delay);
let live = Arc::new(AtomicUsize::new(0));
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
if live.load(Ordering::Relaxed) >= MAX_CONNECTIONS {
http::respond_error(&mut stream, None, 503, "too many open connections");
continue;
}
let slot = Connection::take(&live);
let shared = Arc::clone(&shared);
let _ = std::thread::Builder::new()
.name("cctop-serve".into())
.spawn(move || {
let _slot = slot;
serve_connection(&shared, &mut stream);
});
}
Ok(0)
}
struct Connection(Arc<AtomicUsize>);
impl Connection {
fn take(live: &Arc<AtomicUsize>) -> Connection {
live.fetch_add(1, Ordering::Relaxed);
Connection(Arc::clone(live))
}
}
impl Drop for Connection {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
fn listen(bind: &str, port: u16, port_given: bool) -> anyhow::Result<TcpListener> {
let last = match port_given {
true => port,
false => port.saturating_add(PORT_SEARCH),
};
let mut last_error = None;
for candidate in port..=last {
let addr = (bind, candidate)
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("could not resolve --bind {bind}: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("--bind {bind} resolved to no address"))?;
match TcpListener::bind(addr) {
Ok(listener) => return Ok(listener),
Err(e) => last_error = Some(e),
}
}
let why = last_error.map_or_else(|| "no port to try".to_string(), |e| e.to_string());
match port_given {
true => anyhow::bail!("could not listen on {bind}:{port}: {why}"),
false => anyhow::bail!(
"could not listen on {bind}, ports {port} to {last}: {why}\n\
Use --port to name a free one."
),
}
}
fn announce(addr: &SocketAddr, bind: &str, token: &str, no_token: bool) {
let query = match token.is_empty() {
true => String::new(),
false => format!("?t={token}"),
};
if addr.ip().is_unspecified() {
eprintln!(
"cctop: serving on every interface, port {} — open \
http://<this machine>:{}/{query}",
addr.port(),
addr.port()
);
} else {
let host = match addr.ip().is_loopback() {
true => "127.0.0.1".to_string(),
false => bind.to_string(),
};
eprintln!("cctop: serving on http://{host}:{}/{query}", addr.port());
}
if !addr.ip().is_loopback() {
eprintln!(
"cctop: this is reachable from your network — anyone who can reach \
that address can read every session on this machine."
);
}
if no_token {
eprintln!(
"cctop: --no-token, so nothing is checked — every process and user \
on this machine can read your sessions too."
);
}
let _ = std::io::stderr().flush();
}
fn new_token() -> String {
match read_random(TOKEN_BYTES) {
Some(bytes) => bytes.iter().map(|b| format!("{b:02x}")).collect(),
None => hashed_token(),
}
}
const TOKEN_BYTES: usize = 16;
fn hashed_token() -> String {
use std::hash::{BuildHasher, Hasher, RandomState};
let mut out = String::with_capacity(TOKEN_BYTES * 2);
for salt in 0..(TOKEN_BYTES as u64 / 8) {
let mut hasher = RandomState::new().build_hasher();
hasher.write_i64(crate::util::now_ms());
hasher.write_u32(std::process::id());
hasher.write_u64(salt);
out.push_str(&format!("{:016x}", hasher.finish()));
}
out
}
fn read_random(want: usize) -> Option<Vec<u8>> {
use std::io::Read;
let mut file = std::fs::File::open("/dev/urandom").ok()?;
let mut buf = vec![0u8; want];
file.read_exact(&mut buf).ok()?;
Some(buf)
}
fn token_matches(expected: &str, given: &str) -> bool {
if expected.len() != given.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in expected.bytes().zip(given.bytes()) {
diff |= a ^ b;
}
diff == 0
}
fn spawn_host_poller(host: fleet::Host, remotes: Arc<Mutex<Remotes>>) {
std::thread::spawn(move || {
loop {
let snapshot = host.poll();
if let Ok(mut remotes) = remotes.lock() {
match snapshot {
fleet::Snapshot::Rows(rows) => {
remotes.errors.remove(&host.target);
remotes.rows.insert(host.target.clone(), rows);
}
fleet::Snapshot::Failed(why) => {
remotes.errors.insert(host.target.clone(), why);
}
}
}
std::thread::sleep(fleet::POLL);
}
});
}
fn spawn_refresher(shared: Arc<Shared>, remotes: Arc<Mutex<Remotes>>, plan: Plan, delay: Duration) {
std::thread::spawn(move || {
crate::pricing::refresh_pricing_blocking();
let mut loader = Loader::new();
let watch = Watch::start();
let mut rows = loader.load(plan);
let mut walked = Instant::now();
let mut version = 0u64;
publish(&shared, &remotes, &rows, plan, &loader, &mut version);
loop {
std::thread::sleep(delay);
let appeared = watch.as_ref().is_some_and(|w| {
w.took_structural_change()
|| w.awaiting_discovery(|path| {
rows.iter().any(|s| s.data_file.as_deref() == Some(path))
})
});
if appeared || walked.elapsed() >= FULL_WALK {
rows = loader.load(plan);
walked = Instant::now();
} else {
loader.refresh_live(plan, &mut rows);
}
publish(&shared, &remotes, &rows, plan, &loader, &mut version);
}
});
}
fn publish(
shared: &Shared,
remotes: &Mutex<Remotes>,
local: &[Session],
plan: Plan,
loader: &Loader,
version: &mut u64,
) {
let (mut sessions, host_errors) = match remotes.lock() {
Ok(remotes) => {
let mut merged = local.to_vec();
for rows in remotes.rows.values() {
merged.extend(rows.iter().cloned());
}
let mut errors: Vec<(String, String)> = remotes
.errors
.iter()
.map(|(h, why)| (h.clone(), why.clone()))
.collect();
errors.sort();
(merged, errors)
}
Err(_) => (local.to_vec(), Vec::new()),
};
sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active));
let document = cli::json_sessions(&sessions, plan, loader);
let json = serde_json::to_string(&document).unwrap_or_else(|_| "[]".to_string());
*version += 1;
let snapshot = Arc::new(Snapshot {
version: *version,
json,
sessions,
host_errors,
});
if let Ok(mut latest) = shared.latest.lock() {
*latest = snapshot;
}
shared.updated.notify_all();
}
fn serve_connection(shared: &Shared, stream: &mut TcpStream) {
let request = match Request::parse(stream) {
Ok(request) => request,
Err((status, why)) => return http::respond_error(stream, None, status, why),
};
if !shared.token.is_empty() && !token_matches(&shared.token, request.token()) {
return http::respond_error(
stream,
Some(&request),
403,
"missing or wrong access token — open the link cctop printed",
);
}
let path = request.path.clone();
match path.as_str() {
"/" => page(shared, stream, &request, DASHBOARD_HTML),
"/api/sessions" => {
let snapshot = current(shared);
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
snapshot.json.as_bytes(),
);
}
"/api/hosts" => {
let snapshot = current(shared);
let body = serde_json::to_string(&snapshot.host_errors).unwrap_or_default();
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
);
}
"/api/events" => events(shared, stream, &request),
_ if path.starts_with("/session/") => page(shared, stream, &request, REPORT_HTML),
_ if path.starts_with("/api/report/") => {
api_report(shared, stream, &request, &path["/api/report/".len()..]);
}
_ => http::respond_error(stream, Some(&request), 404, "no such page"),
}
}
fn current(shared: &Shared) -> Arc<Snapshot> {
match shared.latest.lock() {
Ok(latest) => Arc::clone(&latest),
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
}
}
fn page(shared: &Shared, stream: &mut TcpStream, request: &Request, html: &str) {
let token = serde_json::to_string(&shared.token).unwrap_or_else(|_| "\"\"".to_string());
let back = match shared.token.is_empty() {
true => String::new(),
false => format!("?t={}", shared.token),
};
let home = serde_json::to_string(
&dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
)
.unwrap_or_else(|_| "\"\"".to_string());
let body = html
.replace("__CCTOP_CSS__", COMMON_CSS)
.replace("\"__CCTOP_TOKEN__\"", &token)
.replace("\"__CCTOP_HOME__\"", &home)
.replace("__CCTOP_BACK__", &back)
.replace("__CCTOP_VERSION__", env!("CARGO_PKG_VERSION"));
http::respond(
stream,
Some(request),
200,
"text/html; charset=utf-8",
body.as_bytes(),
);
}
fn events(shared: &Shared, stream: &mut TcpStream, _request: &Request) {
let Ok(mut sse) = EventStream::open(stream) else {
return;
};
let mut sent = 0u64;
loop {
let snapshot = {
let Ok(latest) = shared.latest.lock() else {
return;
};
let (latest, _) = match shared
.updated
.wait_timeout_while(latest, SSE_KEEPALIVE, |s| s.version <= sent)
{
Ok(pair) => pair,
Err(_) => return,
};
Arc::clone(&latest)
};
if snapshot.version <= sent {
if sse.keepalive().is_err() {
return;
}
continue;
}
if sse.send("sessions", &snapshot.json).is_err() {
return;
}
sent = snapshot.version;
}
}
fn api_report(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(
stream,
Some(request),
404,
"no session with that id, or the prefix matches more than one",
);
};
if let Some(remote) = &session.remote {
return http::respond_error(
stream,
Some(request),
404,
&format!(
"this session is on {} — run cctop serve there to report on it",
remote.host
),
);
}
let data = shared.store.session_data_fresh(session);
let built = report::build(session, &data, shared.plan);
match serde_json::to_string(&built) {
Ok(body) => http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
),
Err(e) => http::respond_error(
stream,
Some(request),
503,
&format!("could not render the report: {e}"),
),
}
}
fn find<'a>(sessions: &'a [Session], id: &str) -> Option<&'a Session> {
if id.is_empty() {
return None;
}
if let Some(exact) = sessions.iter().find(|s| s.session_id == id) {
return Some(exact);
}
let mut matches = sessions.iter().filter(|s| s.session_id.starts_with(id));
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
#[cfg(test)]
mod tests {
use super::*;
fn session(id: &str) -> Session {
Session::new(crate::pricing::Provider::Claude, id.into())
}
#[test]
fn a_token_matches_only_itself() {
let token = new_token();
assert!(token_matches(&token, &token));
assert!(!token_matches(&token, ""));
assert!(!token_matches(&token, &token[..token.len() - 1]));
let mut wrong = token.clone();
wrong.pop();
wrong.push(if token.ends_with('a') { 'b' } else { 'a' });
assert!(!token_matches(&token, &wrong));
}
#[test]
fn the_fallback_token_is_the_same_shape_as_the_real_one() {
let token = hashed_token();
assert_eq!(token.len(), TOKEN_BYTES * 2);
assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(hashed_token(), hashed_token());
assert!(token_matches(&token, &token));
}
#[test]
fn tokens_differ_between_runs() {
assert_ne!(new_token(), new_token());
assert_eq!(new_token().len(), TOKEN_BYTES * 2);
}
#[test]
fn sessions_resolve_by_id_and_by_unambiguous_prefix() {
let rows = vec![session("abc123"), session("abd999"), session("zz")];
assert_eq!(find(&rows, "abc123").unwrap().session_id, "abc123");
assert_eq!(find(&rows, "abc").unwrap().session_id, "abc123");
assert_eq!(find(&rows, "zz").unwrap().session_id, "zz");
}
#[test]
fn an_ambiguous_prefix_resolves_to_nothing() {
let rows = vec![session("abc123"), session("abd999")];
assert!(find(&rows, "ab").is_none());
assert!(find(&rows, "").is_none());
assert!(find(&rows, "nope").is_none());
}
#[test]
fn the_pages_carry_the_placeholders_the_server_substitutes() {
for html in [DASHBOARD_HTML, REPORT_HTML] {
assert!(html.contains("\"__CCTOP_TOKEN__\""));
assert!(html.contains("__CCTOP_CSS__"));
assert!(html.contains("__CCTOP_VERSION__"));
}
assert!(REPORT_HTML.contains("__CCTOP_BACK__"));
assert!(DASHBOARD_HTML.contains("\"__CCTOP_HOME__\""));
assert!(!COMMON_CSS.contains("</style>"));
}
#[test]
fn a_token_is_safe_to_paste_into_a_url_unescaped() {
let token = new_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token {token} would need escaping in a URL"
);
}
#[test]
fn a_named_port_is_not_stepped_past() {
let held = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let taken = held.local_addr().unwrap().port();
assert!(listen("127.0.0.1", taken, true).is_err());
let stepped = listen("127.0.0.1", taken, false).expect("the search finds a free port");
assert_ne!(stepped.local_addr().unwrap().port(), taken);
}
}