Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 315 additions & 1 deletion runner/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand Down Expand Up @@ -109,6 +118,193 @@ require_mtls = false
eprintln!("To add a client: relay-runner add-client <name>");
}

// ---------------------------------------------------------------------------
// 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);
}
};
Comment thread
kirich1409 marked this conversation as resolved.

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());
Comment thread
kirich1409 marked this conversation as resolved.

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())
Comment thread
kirich1409 marked this conversation as resolved.
}) {
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" }}
"#,
Comment thread
kirich1409 marked this conversation as resolved.
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 <name>");
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
Comment thread
kirich1409 marked this conversation as resolved.
}

// ---------------------------------------------------------------------------
// add-client — generate a client certificate
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 <name> --image <image>");
std::process::exit(1);
};

let mut image: Option<String> = None;
let args_vec: Vec<String> = 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");
Comment thread
kirich1409 marked this conversation as resolved.
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);
};

Comment thread
kirich1409 marked this conversation as resolved.
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)
// ---------------------------------------------------------------------------
Expand Down
Loading