diff --git a/runner/src/main.rs b/runner/src/main.rs index 2049b60..8d79230 100644 --- a/runner/src/main.rs +++ b/runner/src/main.rs @@ -39,9 +39,18 @@ use crate::tls::CertificateAuthority; fn main() { match std::env::args().nth(1).as_deref() { - Some("init") => cmd_init(), + Some("init") => { + let cloud = std::env::args().any(|arg| arg == "--cloud"); + if cloud { + cmd_init_cloud(); + } else { + cmd_init(); + } + } Some("add-client") => cmd_add_client(), + Some("add-profile") => cmd_add_profile(), Some("rotate-ca") => cmd_rotate_ca(), + Some("rotate-token") => cmd_rotate_token(), Some("hash-token") => cmd_hash_token(), _ => cmd_serve(), } @@ -109,6 +118,193 @@ require_mtls = false eprintln!("To add a client: relay-runner add-client "); } +// --------------------------------------------------------------------------- +// init --cloud — create cloud-ready config with data dir and database +// --------------------------------------------------------------------------- + +#[allow(clippy::items_after_statements)] +fn cmd_init_cloud() { + let state_dir = tls::default_state_dir(); + eprintln!( + "Initializing Relay runner (cloud mode) in {}", + state_dir.display() + ); + + if let Err(e) = std::fs::create_dir_all(&state_dir) { + eprintln!("Error creating state directory: {e}"); + std::process::exit(1); + } + + let ca = match CertificateAuthority::load_or_generate(&state_dir) { + Ok(ca) => ca, + Err(e) => { + eprintln!("Error generating CA: {e}"); + std::process::exit(1); + } + }; + + if let Err(e) = ca.issue_server_cert(&state_dir) { + eprintln!("Error generating server certificate: {e}"); + std::process::exit(1); + } + + let token = generate_secure_token(); + let token_hash = match AuthInterceptor::hash_token(&token) { + Ok(hash) => hash, + Err(e) => { + eprintln!("Error hashing token: {e}"); + std::process::exit(1); + } + }; + + let data_dir = config::Config::default().data_dir; + for subdir in &["projects", "workspaces", "sessions"] { + let path = data_dir.join(subdir); + if let Err(e) = std::fs::create_dir_all(&path) { + eprintln!("Error creating {}: {e}", path.display()); + std::process::exit(1); + } + } + eprintln!("Created data directory structure at {}", data_dir.display()); + + let db_path = data_dir.join("relay.db"); + let db_url = format!("sqlite:{}", db_path.display()); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build tokio runtime"); + + if let Err(e) = rt.block_on(async { + use sqlx::sqlite::{ + SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous, + }; + use std::str::FromStr; + + let opts = SqliteConnectOptions::from_str(&db_url) + .map_err(|e| format!("invalid db url: {e}"))? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .map_err(|e| e.to_string())?; + + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|e| e.to_string()) + }) { + eprintln!("Error initializing database: {e}"); + std::process::exit(1); + } + eprintln!("Database initialized at {}", db_path.display()); + + let config_path = state_dir.join("config.toml"); + let config_written = !config_path.exists(); + if config_written { + let default_config = format!( + r#"# Relay runner cloud-mode configuration + +port = 50051 +rest_port = 8080 +data_dir = "{data_dir}" +max_sessions = 50 +scrollback_lines = 10000 + +[tls] +cert_path = "{state}/server.crt" +key_path = "{state}/server.key" +ca_cert_path = "{state}/ca.crt" +require_mtls = false + +[auth] +token_hash = "{token_hash}" +require_mtls = false + +[docker] +socket_path = "/var/run/docker.sock" +allowed_images = [] +exec_timeout_secs = 30 + +[git] +clone_timeout_secs = 300 +allowed_hosts = [] + +[mdns] +enabled = true +instance_name = "Relay Runner" + +# Predefined profiles for container sessions +# [[profiles]] +# name = "claude-default" +# image = "ubuntu:24.04" +# env_overrides = {{ "NODE_ENV" = "production" }} +"#, + data_dir = data_dir.display(), + state = state_dir.display(), + token_hash = token_hash, + ); + + if let Err(e) = std::fs::write(&config_path, default_config) { + eprintln!("Error writing config: {e}"); + std::process::exit(1); + } + eprintln!("Config written to {}", config_path.display()); + } + + let fingerprint = + CertificateAuthority::fingerprint(ca.ca_cert_pem()).unwrap_or_else(|_| "unknown".into()); + let code = CertificateAuthority::verification_code(ca.ca_cert_pem()) + .unwrap_or_else(|_| "??????".into()); + + eprintln!(); + eprintln!("CA fingerprint: {fingerprint}"); + eprintln!("Verification code: {code}"); + + if config_written { + eprintln!(); + eprintln!("Bearer token (save securely):"); + println!("{token}"); + eprintln!(); + eprintln!("Share the verification code with clients for TOFU verification."); + eprintln!("To add a client: relay-runner add-client "); + eprintln!("To rotate the token: relay-runner rotate-token"); + } else { + eprintln!(); + eprintln!( + "Config already exists — token not printed (it would not match the stored hash)." + ); + eprintln!("Use 'relay-runner rotate-token' to generate a new token."); + } +} + +fn generate_secure_token() -> String { + use std::io::Read; + + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut token = String::with_capacity(32); + + let mut bytes = [0u8; 32]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut bytes)) + .unwrap_or_else(|e| { + eprintln!("Error: could not read from /dev/urandom: {e}"); + std::process::exit(1); + }); + + for &byte in &bytes { + let idx = (byte as usize) % CHARSET.len(); + token.push(CHARSET[idx] as char); + } + + token +} + // --------------------------------------------------------------------------- // add-client — generate a client certificate // --------------------------------------------------------------------------- @@ -204,6 +400,124 @@ fn cmd_hash_token() { } } +// --------------------------------------------------------------------------- +// add-profile — add a session profile to config.toml +// --------------------------------------------------------------------------- + +fn cmd_add_profile() { + let mut args = std::env::args(); + args.next(); + args.next(); + + let Some(name) = args.next() else { + eprintln!("Usage: relay-runner add-profile --image "); + std::process::exit(1); + }; + + let mut image: Option = None; + let args_vec: Vec = args.collect(); + let mut i = 0; + while i < args_vec.len() { + match args_vec[i].as_str() { + "--image" if i + 1 < args_vec.len() => { + image = Some(args_vec[i + 1].clone()); + i += 2; + } + _ => i += 1, + } + } + + let Some(image) = image else { + eprintln!("Error: --image is required"); + std::process::exit(1); + }; + + let state_dir = tls::default_state_dir(); + let config_path = state_dir.join("config.toml"); + + let Ok(mut config_content) = std::fs::read_to_string(&config_path) else { + eprintln!("Error: config.toml not found. Run 'relay-runner init --cloud' first."); + std::process::exit(1); + }; + + // Validate name and image don't contain characters that would break TOML + if name.contains('"') || name.contains('\\') || name.contains('\n') { + eprintln!("Error: profile name contains invalid characters"); + std::process::exit(1); + } + if image.contains('"') || image.contains('\\') || image.contains('\n') { + eprintln!("Error: image name contains invalid characters"); + std::process::exit(1); + } + + let profile_entry = format!("\n[[profiles]]\nname = \"{name}\"\nimage = \"{image}\"\n"); + config_content.push_str(&profile_entry); + + if let Err(e) = std::fs::write(&config_path, config_content) { + eprintln!("Error writing config: {e}"); + std::process::exit(1); + } + + eprintln!("Profile '{name}' added to config.toml"); +} + +// --------------------------------------------------------------------------- +// rotate-token — generate new token and update config +// --------------------------------------------------------------------------- + +fn cmd_rotate_token() { + let state_dir = tls::default_state_dir(); + let config_path = state_dir.join("config.toml"); + + let Ok(config_content) = std::fs::read_to_string(&config_path) else { + eprintln!("Error: config.toml not found. Run 'relay-runner init --cloud' first."); + std::process::exit(1); + }; + + let new_token = generate_secure_token(); + let new_hash = match AuthInterceptor::hash_token(&new_token) { + Ok(hash) => hash, + Err(e) => { + eprintln!("Error hashing new token: {e}"); + std::process::exit(1); + } + }; + + let updated_content = if let Some(auth_section_start) = config_content.find("[auth]") { + let after_auth = &config_content[auth_section_start..]; + if let Some(token_hash_line_pos) = after_auth.find("token_hash = ") { + let line_start = auth_section_start + token_hash_line_pos; + let line_end = after_auth[token_hash_line_pos..] + .find('\n') + .map_or(config_content.len(), |pos| line_start + pos); + + let new_line = format!("token_hash = \"{new_hash}\""); + format!( + "{}{}{}", + &config_content[..line_start], + new_line, + &config_content[line_end..] + ) + } else { + eprintln!("Error: [auth] section found but no token_hash line"); + std::process::exit(1); + } + } else { + eprintln!("Error: [auth] section not found in config"); + std::process::exit(1); + }; + + if let Err(e) = std::fs::write(&config_path, updated_content) { + eprintln!("Error writing config: {e}"); + std::process::exit(1); + } + + eprintln!("Token rotated successfully"); + eprintln!(); + eprintln!("New bearer token (save securely):"); + println!("{new_token}"); +} + // --------------------------------------------------------------------------- // serve — start the gRPC server (default command) // ---------------------------------------------------------------------------