pub mod agent;
pub mod api;
pub mod auth;
pub mod containers;
pub mod web;
pub mod ws;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use bytes::Bytes;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
type Body = Full<Bytes>;
pub struct RemoteState {
pub data_dir: PathBuf,
}
fn pid_file(data_dir: &Path) -> PathBuf {
data_dir.join("pid")
}
fn read_pid(data_dir: &Path) -> Option<u32> {
std::fs::read_to_string(pid_file(data_dir))
.ok()
.and_then(|s| s.trim().parse().ok())
}
fn is_process_running(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
pub async fn start(listen: &str, data_dir: PathBuf) -> Result<()> {
if let Some(pid) = read_pid(&data_dir)
&& is_process_running(pid)
{
eprintln!("[remote] warning: daemon already running (pid {pid}), showing QR code");
eprintln!();
let port: u16 = listen
.rsplit(':')
.next()
.and_then(|p| p.parse().ok())
.unwrap_or(19420);
print_pair(&data_dir, port)?;
return Ok(());
}
std::fs::create_dir_all(&data_dir)?;
let pid = std::process::id();
std::fs::write(pid_file(&data_dir), pid.to_string())?;
let result = run(listen, data_dir.clone()).await;
let _ = std::fs::remove_file(pid_file(&data_dir));
result
}
pub fn stop() -> Result<()> {
let data_dir = auth::data_dir();
match read_pid(&data_dir) {
Some(pid) if is_process_running(pid) => {
eprintln!("[remote] stopping daemon (pid {pid})");
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
let _ = std::fs::remove_file(pid_file(&data_dir));
Ok(())
}
Some(_) => {
let _ = std::fs::remove_file(pid_file(&data_dir));
eprintln!("[remote] not running (cleaned up stale pid file)");
Ok(())
}
None => {
eprintln!("[remote] not running");
Ok(())
}
}
}
async fn run(listen: &str, data_dir: PathBuf) -> Result<()> {
let _pairing_token = auth::load_or_generate_pairing_token(&data_dir)?;
let state = Arc::new(RemoteState {
data_dir: data_dir.clone(),
});
let listener = TcpListener::bind(listen).await?;
let local_addr = listener.local_addr()?;
let port = local_addr.port();
eprintln!("[remote] listening on http://{local_addr}");
print_pair(&data_dir, port)?;
loop {
let (stream, addr) = listener.accept().await?;
let state = state.clone();
tokio::spawn(async move {
let io = TokioIo::new(stream);
let state = state.clone();
let service = service_fn(move |req| {
let state = state.clone();
async move { handle_request(req, state).await }
});
#[allow(clippy::collapsible_if)]
if let Err(e) = http1::Builder::new()
.serve_connection(io, service)
.with_upgrades()
.await
{
if !e.to_string().contains("error shutting down connection") {
eprintln!("[remote] connection error from {addr}: {e}");
}
}
});
}
}
async fn handle_request(
req: Request<Incoming>,
state: Arc<RemoteState>,
) -> Result<Response<Body>, hyper::Error> {
match route(req, state).await {
Ok(resp) => Ok(resp),
Err(e) => {
eprintln!("[remote] handler error: {e}");
Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(format!("{{\"error\":\"{e}\"}}"))))
.unwrap())
}
}
}
async fn route(req: Request<Incoming>, state: Arc<RemoteState>) -> Result<Response<Body>> {
let path = req.uri().path().to_string();
if path == "/" || path == "/index.html" {
return Ok(web::serve_app());
}
let token = extract_bearer_token(&req).map(String::from).or_else(|| {
req.uri().query().and_then(|q| {
q.split('&')
.find_map(|p| p.strip_prefix("token=").map(String::from))
})
});
let auth_result = match token.as_deref() {
Some(t) => auth::validate_token(&state.data_dir, t),
None => None,
};
if path == "/api/pair" {
match auth_result.as_deref() {
Some("pairing") => return api::handle(req, &state).await,
_ => {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(
r#"{"error":"invalid or missing pairing token"}"#,
)))
.unwrap());
}
}
}
if auth_result.is_none() {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(
r#"{"error":"invalid or missing authorization token"}"#,
)))
.unwrap());
}
if path.starts_with("/ws/") {
ws::handle(req, state).await
} else if path.starts_with("/api/") {
api::handle(req, &state).await
} else {
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Full::new(Bytes::from("not found")))
.unwrap())
}
}
fn extract_bearer_token(req: &Request<Incoming>) -> Option<&str> {
req.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
}
pub fn print_pair(data_dir: &Path, port: u16) -> Result<()> {
let token = auth::load_or_generate_pairing_token(data_dir)?;
let ip = auth::local_ip().unwrap_or_else(|| "localhost".to_string());
let url = format!("http://{ip}:{port}/#{token}");
auth::print_qr(&url);
Ok(())
}
pub fn remote_status(data_dir: &Path) {
match read_pid(data_dir) {
Some(pid) if is_process_running(pid) => {
println!("Daemon: running (pid {pid})");
}
Some(_) => {
println!("Daemon: not running (stale pid file)");
let _ = std::fs::remove_file(pid_file(data_dir));
}
None => {
println!("Daemon: not running");
}
}
let devices = auth::load_devices(data_dir);
if devices.is_empty() {
println!("\nNo paired devices. Run `kap remote start` to pair.");
} else {
println!("\n{:<14} {:<20} {:<26}", "ID", "NAME", "PAIRED");
for d in &devices {
println!("{:<14} {:<20} {:<26}", d.id, d.name, d.paired_at);
}
}
}
pub fn revoke(data_dir: &Path, device_id: &str) -> Result<()> {
let removed = auth::revoke_device(data_dir, device_id)?;
if removed {
println!("Revoked device {device_id}");
} else {
println!("No device found with ID {device_id}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn extract_bearer(req: &Request<Full<Bytes>>) -> Option<String> {
req.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(String::from)
}
#[test]
fn extract_bearer_works() {
let req = Request::builder()
.header("Authorization", "Bearer my-token-123")
.body(Full::new(Bytes::new()))
.unwrap();
assert_eq!(extract_bearer(&req).as_deref(), Some("my-token-123"));
}
#[test]
fn extract_bearer_missing_header() {
let req = Request::builder().body(Full::new(Bytes::new())).unwrap();
assert!(extract_bearer(&req).is_none());
}
#[test]
fn extract_bearer_wrong_scheme() {
let req = Request::builder()
.header("Authorization", "Basic abc123")
.body(Full::new(Bytes::new()))
.unwrap();
assert!(extract_bearer(&req).is_none());
}
#[test]
fn extract_bearer_empty_token() {
let req = Request::builder()
.header("Authorization", "Bearer ")
.body(Full::new(Bytes::new()))
.unwrap();
assert_eq!(extract_bearer(&req).as_deref(), Some(""));
}
fn tempdir(suffix: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("kap-remote-{}-{suffix}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn pid_file_created_in_nonexistent_dir() {
let dir = tempdir("pid-nested");
let data_dir = dir.join("nested").join("remote");
assert!(!data_dir.exists());
std::fs::create_dir_all(&data_dir).unwrap();
std::fs::write(pid_file(&data_dir), "12345").unwrap();
assert_eq!(read_pid(&data_dir), Some(12345));
}
#[test]
fn read_pid_returns_none_for_missing_dir() {
let dir = tempdir("pid-missing");
let data_dir = dir.join("does-not-exist");
assert_eq!(read_pid(&data_dir), None);
}
}