use crate::api::models::*;
use crate::api::{AppState, RouteRing};
use crate::commands::service::load_xbp_config;
use crate::commands::{
install_package as install_package_cmd, pm2_delete, pm2_save, pm2_stop, run_config, run_ports,
run_redeploy, run_redeploy_service, run_service_command as run_service_cmd, run_setup,
};
use crate::strategies::get_all_services;
use crate::utils::command_exists;
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use once_cell::sync::Lazy;
use prometheus::{Encoder, IntCounterVec, Registry, TextEncoder};
use reqwest;
use reqwest::header::{HeaderName as ReqHeaderName, HeaderValue as ReqHeaderValue};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::Instant;
use tokio::process::Command;
use tracing::{error, info};
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
static PROXY_REQUESTS: Lazy<IntCounterVec> = Lazy::new(|| {
IntCounterVec::new(
prometheus::Opts::new("xbp_proxy_requests_total", "Total proxied requests"),
&["domain", "target"],
)
.expect("proxy counter")
});
static PROXY_FAILURES: Lazy<IntCounterVec> = Lazy::new(|| {
IntCounterVec::new(
prometheus::Opts::new("xbp_proxy_failures_total", "Proxy failures"),
&["domain", "target"],
)
.expect("proxy failure counter")
});
const MAX_EXEC_ARGS: usize = 32;
const MAX_EXEC_ARG_LEN: usize = 1024;
const MAX_EXEC_OUTPUT_BYTES: usize = 64 * 1024;
fn ensure_metrics_registered() {
REGISTRY.register(Box::new(PROXY_REQUESTS.clone())).ok();
REGISTRY.register(Box::new(PROXY_FAILURES.clone())).ok();
}
pub async fn health() -> impl Responder {
HttpResponse::Ok().json(HealthResponse {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
pub async fn exec_command(payload: web::Json<ExecCommandRequest>) -> impl Responder {
let command = payload.command.trim().to_ascii_lowercase();
if !is_valid_exec_command_name(&command) {
return HttpResponse::BadRequest().json(ErrorResponse {
error: "Invalid command name".to_string(),
});
}
if !is_allowed_exec_command(&command) {
return HttpResponse::Forbidden().json(ErrorResponse {
error: format!("Command '{}' is not allowed via API", command),
});
}
if let Err(reason) = validate_exec_args(&payload.args) {
return HttpResponse::BadRequest().json(ErrorResponse { error: reason });
}
let executable = match std::env::current_exe() {
Ok(path) => path,
Err(e) => {
return HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to resolve xbp executable: {}", e),
})
}
};
info!(
"Executing API command '{}' with {} arg(s)",
command,
payload.args.len()
);
let started = Instant::now();
let output = Command::new(executable)
.arg(&command)
.args(&payload.args)
.env_remove("PORT_XBP_API")
.env_remove("XBP_API_BIND")
.output()
.await;
match output {
Ok(output) => {
let (stdout, truncated_stdout) = truncate_command_output(&output.stdout);
let (stderr, truncated_stderr) = truncate_command_output(&output.stderr);
HttpResponse::Ok().json(ExecCommandResponse {
success: output.status.success(),
command,
args: payload.args.clone(),
exit_code: output.status.code().unwrap_or(-1),
stdout,
stderr,
duration_ms: started.elapsed().as_millis(),
truncated_stdout,
truncated_stderr,
})
}
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to execute command: {}", e),
}),
}
}
pub async fn list_routes(state: web::Data<AppState>) -> impl Responder {
let routes = state.routes.read().await;
let items: Vec<RouteEntry> = routes
.iter()
.map(|(domain, ring)| RouteEntry {
domain: domain.clone(),
targets: ring.targets.clone(),
conditions: ring.conditions.clone(),
})
.collect();
HttpResponse::Ok().json(RoutesResponse { routes: items })
}
pub async fn create_route(
state: web::Data<AppState>,
payload: web::Json<CreateRouteRequest>,
) -> impl Responder {
ensure_metrics_registered();
if payload.targets.is_empty() {
return HttpResponse::BadRequest().json(ErrorResponse {
error: "At least one target is required".into(),
});
}
let entry = RouteEntry {
domain: payload.domain.clone(),
targets: payload.targets.clone(),
conditions: payload.conditions.clone(),
};
let mut routes = state.routes.write().await;
routes.insert(payload.domain.clone(), RouteRing::new(entry.clone()));
HttpResponse::Ok().json(CreateRouteResponse {
success: true,
domain: payload.domain.clone(),
target_count: payload.targets.len(),
})
}
pub async fn delete_route(state: web::Data<AppState>, path: web::Path<String>) -> impl Responder {
let domain = path.into_inner();
let mut routes = state.routes.write().await;
if routes.remove(&domain).is_some() {
HttpResponse::Ok().json(serde_json::json!({ "success": true }))
} else {
HttpResponse::NotFound().json(ErrorResponse {
error: format!("Route for {} not found", domain),
})
}
}
pub async fn list_ports() -> impl Responder {
match get_ports_data(None).await {
Ok(ports) => HttpResponse::Ok().json(PortsResponse { ports }),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn get_port(path: web::Path<u16>) -> impl Responder {
let port = path.into_inner();
match get_ports_data(Some(port)).await {
Ok(ports) => HttpResponse::Ok().json(PortsResponse { ports }),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn kill_port(path: web::Path<u16>) -> impl Responder {
let port = path.into_inner();
let args = vec!["-p".to_string(), port.to_string(), "--kill".to_string()];
match run_ports(&args, false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Killed processes on port {}", port)})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
async fn get_ports_data(port_filter: Option<u16>) -> Result<Vec<PortInfo>, String> {
use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use std::collections::BTreeMap;
let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
let proto_flags = ProtocolFlags::TCP;
let sockets = get_sockets_info(af_flags, proto_flags)
.map_err(|e| format!("Failed to get sockets info: {}", e))?;
let mut port_map: BTreeMap<u16, Vec<PortInfo>> = BTreeMap::new();
for socket in sockets {
if let ProtocolSocketInfo::Tcp(ref tcp_info) = socket.protocol_socket_info {
if let Some(filter) = port_filter {
if tcp_info.local_port != filter {
continue;
}
}
let pid = if !socket.associated_pids.is_empty() {
Some(socket.associated_pids[0].to_string())
} else {
None
};
let port_info = PortInfo {
port: tcp_info.local_port,
pid,
local_addr: tcp_info.local_addr.to_string(),
remote_addr: tcp_info.remote_addr.to_string(),
state: format!("{:?}", tcp_info.state),
process: "-".to_string(),
};
port_map
.entry(tcp_info.local_port)
.or_default()
.push(port_info);
}
}
let mut result = Vec::new();
for ports in port_map.values() {
result.extend(ports.iter().cloned());
}
Ok(result)
}
pub async fn list_systemctl() -> impl Responder {
match get_systemctl_data(None).await {
Ok(services) => HttpResponse::Ok().json(SystemctlResponse { services }),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn get_systemctl_service(path: web::Path<String>) -> impl Responder {
let service_name = path.into_inner();
match get_systemctl_data(Some(&service_name)).await {
Ok(services) => HttpResponse::Ok().json(SystemctlResponse { services }),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn systemctl_action(path: web::Path<(String, String)>) -> impl Responder {
let (service_name, action) = path.into_inner();
let allowed = ["start", "stop", "restart", "enable", "disable"];
if !allowed.contains(&action.as_str()) {
return HttpResponse::BadRequest().json(ErrorResponse {
error: format!("Unsupported systemctl action: {}", action),
});
}
match apply_systemctl_action(&service_name, &action).await {
Ok(message) => HttpResponse::Ok().json(serde_json::json!({
"success": true,
"service": service_name,
"action": action,
"message": message
})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
async fn get_systemctl_data(filter: Option<&str>) -> Result<Vec<SystemctlService>, String> {
if !cfg!(target_os = "linux") || !command_exists("systemctl") {
return Err(
"systemctl is only available on Linux hosts where systemd is installed.".to_string(),
);
}
let mut cmd = Command::new("systemctl");
cmd.arg("list-units");
cmd.arg("--type=service");
cmd.arg("--no-pager");
cmd.arg("--no-legend");
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to run systemctl: {}", e))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut services = Vec::new();
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
let name = parts[0].to_string();
if let Some(filter_name) = filter {
if !name.contains(filter_name) {
continue;
}
}
let status = parts[2].to_string();
let active = status == "active";
let enabled = parts[3] == "enabled";
services.push(SystemctlService {
name,
status,
active,
enabled,
});
}
Ok(services)
}
async fn apply_systemctl_action(service: &str, action: &str) -> Result<String, String> {
if !cfg!(target_os = "linux") || !command_exists("systemctl") {
return Err(
"systemctl is only available on Linux hosts where systemd is installed.".to_string(),
);
}
let output = Command::new("systemctl")
.arg(action)
.arg(service)
.output()
.await
.map_err(|e| format!("Failed to run systemctl {}: {}", action, e))?;
if output.status.success() {
Ok(format!("systemctl {} {} succeeded", action, service))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"systemctl {} {} failed: {}",
action, service, stderr
))
}
}
pub async fn list_pm2() -> impl Responder {
match get_pm2_data().await {
Ok(processes) => HttpResponse::Ok().json(Pm2Response { processes }),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
async fn get_pm2_data() -> Result<Vec<Pm2Process>, String> {
let mut cmd = Command::new("pm2");
cmd.arg("jlist");
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to run pm2 jlist: {}", e))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let processes: Vec<serde_json::Value> =
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse pm2 output: {}", e))?;
let mut result = Vec::new();
for proc in processes {
let name = proc["name"].as_str().unwrap_or("unknown").to_string();
let pid = proc["pid"].as_u64().map(|p| p as u32);
let status = proc["pm2_env"]["status"]
.as_str()
.unwrap_or("unknown")
.to_string();
let cpu = proc["monit"]["cpu"].as_f64();
let memory = proc["monit"]["memory"]
.as_f64()
.map(|m| m / 1024.0 / 1024.0);
let uptime = proc["pm2_env"]["pm_uptime"].as_u64().map(|u| {
let seconds = u / 1000;
format!("{}s", seconds)
});
result.push(Pm2Process {
name,
pid,
status,
cpu,
memory,
uptime,
});
}
Ok(result)
}
pub async fn delete_pm2(path: web::Path<String>) -> impl Responder {
let name = path.into_inner();
match pm2_delete(&name, false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Deleted PM2 process: {}", name)})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn start_pm2(path: web::Path<String>) -> impl Responder {
let name = path.into_inner();
let mut cmd = Command::new("pm2");
cmd.arg("start").arg(&name);
match cmd.output().await {
Ok(output) => {
if output.status.success() {
HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Started PM2 process: {}", name)}))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("PM2 start failed: {}", stderr),
})
}
}
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to start PM2 process: {}", e),
}),
}
}
pub async fn stop_pm2(path: web::Path<String>) -> impl Responder {
let name = path.into_inner();
match pm2_stop(&name, false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Stopped PM2 process: {}", name)})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn restart_pm2(path: web::Path<String>) -> impl Responder {
let name = path.into_inner();
let mut cmd = Command::new("pm2");
cmd.arg("restart").arg(&name);
match cmd.output().await {
Ok(output) => {
if output.status.success() {
HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Restarted PM2 process: {}", name)}))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("PM2 restart failed: {}", stderr),
})
}
}
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to restart PM2 process: {}", e),
}),
}
}
pub async fn list_services() -> impl Responder {
match load_xbp_config().await {
Ok(config) => {
let services = get_all_services(&config);
let service_infos: Vec<ServiceInfo> = services
.iter()
.map(|s| ServiceInfo {
name: s.name.clone(),
target: s.target.clone(),
port: s.port,
branch: s.branch.clone(),
url: s.url.clone(),
})
.collect();
HttpResponse::Ok().json(ServicesResponse {
services: service_infos,
})
}
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn run_service_command(path: web::Path<(String, String)>) -> impl Responder {
let (name, command) = path.into_inner();
match run_service_cmd(&command, &name, false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Executed {} on service {}", command, name)})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn get_config() -> impl Responder {
match run_config(false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn get_logs() -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({"message": "Logs endpoint - use /logs?command=<command> for specific logs"}))
}
pub async fn install_package(path: web::Path<String>) -> impl Responder {
let package = path.into_inner();
match install_package_cmd(&package, false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Installed package: {}", package)})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn setup() -> impl Responder {
match run_setup(false).await {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"success": true, "message": "Setup completed"})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn redeploy() -> impl Responder {
match run_redeploy().await {
Ok(_) => HttpResponse::Ok()
.json(serde_json::json!({"success": true, "message": "Redeploy completed"})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn redeploy_service(path: web::Path<String>) -> impl Responder {
let service_name = path.into_inner();
match run_redeploy_service(&service_name, false).await {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": format!("Redeployed service: {}", service_name)})),
Err(e) => HttpResponse::InternalServerError().json(ErrorResponse { error: e }),
}
}
pub async fn download_and_run_binary(req: web::Json<BinaryDownloadRequest>) -> impl Responder {
let download_req = req.into_inner();
info!("Downloading binary from: {}", download_req.url);
let client = reqwest::Client::new();
let response = match client.get(&download_req.url).send().await {
Ok(resp) => resp,
Err(e) => {
error!("Failed to download binary: {}", e);
return HttpResponse::BadRequest().json(ErrorResponse {
error: format!("Failed to download binary: {}", e),
});
}
};
let bytes = match response.bytes().await {
Ok(b) => b,
Err(e) => {
error!("Failed to read binary data: {}", e);
return HttpResponse::BadRequest().json(ErrorResponse {
error: format!("Failed to read binary data: {}", e),
});
}
};
let binary_path = format!("/tmp/{}", download_req.name);
match fs::write(&binary_path, &bytes) {
Ok(_) => {
info!("Binary saved to: {}", binary_path);
}
Err(e) => {
error!("Failed to save binary: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to save binary: {}", e),
});
}
}
let chmod_output = Command::new("chmod")
.arg("+x")
.arg(&binary_path)
.output()
.await;
if let Err(e) = chmod_output {
error!("Failed to make binary executable: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to make binary executable: {}", e),
});
}
let mut pm2_cmd = Command::new("pm2");
pm2_cmd.arg("start");
pm2_cmd.arg(&binary_path);
pm2_cmd.arg("--name");
pm2_cmd.arg(&download_req.name);
if let Some(ref args) = download_req.args {
for arg in args {
pm2_cmd.arg(arg);
}
}
match pm2_cmd.output().await {
Ok(output) => {
if output.status.success() {
let _ = pm2_save(false).await;
HttpResponse::Ok().json(BinaryDownloadResponse {
success: true,
message: format!(
"Binary downloaded and started as PM2 process: {}",
download_req.name
),
pm2_name: Some(download_req.name),
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("PM2 start failed: {}", stderr);
HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("PM2 start failed: {}", stderr),
})
}
}
Err(e) => {
error!("Failed to start PM2 process: {}", e);
HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to start PM2 process: {}", e),
})
}
}
}
pub async fn download_openapi() -> impl Responder {
let openapi_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("openapi.yaml");
match fs::read(&openapi_path) {
Ok(bytes) => HttpResponse::Ok()
.insert_header(("Content-Type", "application/yaml"))
.insert_header((
"Content-Disposition",
"attachment; filename=\"openapi.yaml\"",
))
.body(bytes),
Err(_) => HttpResponse::NotFound().json(ErrorResponse {
error: "OpenAPI spec not found".to_string(),
}),
}
}
pub async fn proxy_route(
path: web::Path<(String, String)>,
req: HttpRequest,
body: web::Bytes,
state: web::Data<AppState>,
) -> impl Responder {
ensure_metrics_registered();
let (domain, tail) = path.into_inner();
let routes = state.routes.read().await;
let ring = match routes.get(&domain) {
Some(r) => r.clone(),
None => {
return HttpResponse::NotFound().json(ErrorResponse {
error: format!("No route configured for {}", domain),
})
}
};
if let Some(cond) = &ring.conditions {
if let Some(prefix) = &cond.path_prefix {
if !tail.starts_with(prefix) {
return HttpResponse::NotFound().json(ErrorResponse {
error: "Path prefix condition not met".into(),
});
}
}
if let Some(header) = &cond.header {
let mut parts = header.splitn(2, ':');
if let (Some(name), Some(expected)) = (parts.next(), parts.next()) {
if req
.headers()
.get(name.trim())
.and_then(|v| v.to_str().ok())
.map(|v| v != expected.trim())
.unwrap_or(true)
{
return HttpResponse::NotFound().json(ErrorResponse {
error: "Header condition not met".into(),
});
}
}
}
}
let target = select_target(&ring).cloned();
drop(routes);
let target = match target {
Some(t) => t,
None => {
return HttpResponse::ServiceUnavailable().json(ErrorResponse {
error: "No targets available".into(),
})
}
};
let url = format!("{}/{}", target.url.trim_end_matches('/'), tail);
let method = match reqwest::Method::from_bytes(req.method().as_str().as_bytes()) {
Ok(m) => m,
Err(_) => {
return HttpResponse::MethodNotAllowed().json(ErrorResponse {
error: format!("Unsupported method {}", req.method()),
})
}
};
let mut builder = state.client.request(method, &url);
for (name, value) in req.headers().iter() {
let name_lower = name.as_str().to_ascii_lowercase();
if matches!(
name_lower.as_str(),
"host" | "content-length" | "connection" | "upgrade" | "proxy-connection"
) {
continue;
}
if let (Ok(hname), Ok(hval)) = (
ReqHeaderName::from_bytes(name.as_str().as_bytes()),
ReqHeaderValue::from_bytes(value.as_bytes()),
) {
builder = builder.header(hname, hval);
}
}
let response = match builder.body(body.clone()).send().await {
Ok(res) => res,
Err(e) => {
PROXY_FAILURES
.with_label_values(&[&domain, &target.url])
.inc();
return HttpResponse::BadGateway().json(ErrorResponse {
error: format!("Proxy request failed: {}", e),
});
}
};
PROXY_REQUESTS
.with_label_values(&[&domain, &target.url])
.inc();
let status = actix_web::http::StatusCode::from_u16(response.status().as_u16())
.unwrap_or(actix_web::http::StatusCode::BAD_GATEWAY);
let mut resp_builder = HttpResponse::build(status);
for (name, value) in response.headers() {
if name == "connection" || name == "content-length" {
continue;
}
if let (Ok(hname), Ok(hval)) = (
actix_web::http::header::HeaderName::from_bytes(name.as_str().as_bytes()),
actix_web::http::header::HeaderValue::from_bytes(value.as_bytes()),
) {
resp_builder.insert_header((hname, hval));
}
}
match response.bytes().await {
Ok(bytes) => resp_builder.body(bytes),
Err(e) => HttpResponse::BadGateway().json(ErrorResponse {
error: format!("Failed to read proxied body: {}", e),
}),
}
}
pub async fn metrics() -> impl Responder {
ensure_metrics_registered();
let metric_families = REGISTRY.gather();
let mut buffer = Vec::new();
let encoder = TextEncoder::new();
if let Err(e) = encoder.encode(&metric_families, &mut buffer) {
return HttpResponse::InternalServerError().json(ErrorResponse {
error: format!("Failed to encode metrics: {}", e),
});
}
HttpResponse::Ok()
.content_type(encoder.format_type())
.body(buffer)
}
fn is_valid_exec_command_name(command: &str) -> bool {
!command.is_empty()
&& command.len() <= 64
&& command
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
fn is_allowed_exec_command(command: &str) -> bool {
matches!(
command,
"ports"
| "services"
| "service"
| "nginx"
| "diag"
| "monitor"
| "logs"
| "list"
| "config"
| "version"
| "tail"
| "start"
| "stop"
| "flush"
| "redeploy"
| "redeploy-v2"
| "snapshot"
| "resurrect"
| "env"
| "curl"
| "docker"
| "setup"
| "init"
)
}
fn validate_exec_args(args: &[String]) -> Result<(), String> {
if args.len() > MAX_EXEC_ARGS {
return Err(format!("Too many arguments (max {})", MAX_EXEC_ARGS));
}
for arg in args {
if arg.len() > MAX_EXEC_ARG_LEN {
return Err(format!(
"Argument exceeds max length of {} bytes",
MAX_EXEC_ARG_LEN
));
}
if arg.chars().any(|ch| ch == '\0' || ch.is_control()) {
return Err("Arguments may not contain control characters".to_string());
}
}
Ok(())
}
fn truncate_command_output(output: &[u8]) -> (String, bool) {
if output.len() <= MAX_EXEC_OUTPUT_BYTES {
return (String::from_utf8_lossy(output).to_string(), false);
}
let truncated = String::from_utf8_lossy(&output[..MAX_EXEC_OUTPUT_BYTES]).to_string();
(
format!(
"{}\n...[truncated {} bytes]",
truncated,
output.len() - MAX_EXEC_OUTPUT_BYTES
),
true,
)
}
fn select_target(ring: &RouteRing) -> Option<&RouteTarget> {
if ring.targets.is_empty() {
return None;
}
let idx = ring.cursor.fetch_add(1, Ordering::Relaxed);
Some(&ring.targets[idx % ring.targets.len()])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_robin_targets() {
let ring = RouteRing {
targets: vec![
RouteTarget {
url: "http://a".into(),
weight: 1,
},
RouteTarget {
url: "http://b".into(),
weight: 1,
},
],
conditions: None,
cursor: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
};
let t1 = select_target(&ring).unwrap().url.clone();
let t2 = select_target(&ring).unwrap().url.clone();
let t3 = select_target(&ring).unwrap().url.clone();
assert_eq!(t1, "http://a");
assert_eq!(t2, "http://b");
assert_eq!(t3, "http://a");
}
#[test]
fn exec_command_allowlist_is_enforced() {
assert!(is_allowed_exec_command("nginx"));
assert!(is_allowed_exec_command("service"));
assert!(!is_allowed_exec_command("api"));
assert!(!is_allowed_exec_command("generate"));
}
#[test]
fn exec_command_name_validation_rejects_invalid_tokens() {
assert!(is_valid_exec_command_name("redeploy-v2"));
assert!(!is_valid_exec_command_name("redeploy v2"));
assert!(!is_valid_exec_command_name("../setup"));
assert!(!is_valid_exec_command_name(""));
}
#[test]
fn exec_arg_validation_enforces_limits_and_chars() {
let too_many = vec!["a".to_string(); MAX_EXEC_ARGS + 1];
assert!(validate_exec_args(&too_many).is_err());
let bad_chars = vec!["line1\nline2".to_string()];
assert!(validate_exec_args(&bad_chars).is_err());
let valid = vec!["--json".to_string(), "service-name".to_string()];
assert!(validate_exec_args(&valid).is_ok());
}
#[test]
fn truncate_command_output_marks_truncation() {
let output = vec![b'a'; MAX_EXEC_OUTPUT_BYTES + 5];
let (text, truncated) = truncate_command_output(&output);
assert!(truncated);
assert!(text.contains("truncated 5 bytes"));
}
}