use anyhow::{anyhow, bail, Context, Result}; use base64::{engine::general_purpose, Engine}; use chrono::{DateTime, NaiveDateTime, Utc}; use lightops_common::protocol::{DockerContainer, DockerImage, FileEntry, NginxSite, ServiceInfo}; use serde_json::{json, Value}; use std::{ fs, io::{Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, }; use tokio::process::Command; use tokio::time::{timeout, Duration}; const MAX_TEXT_FILE: u64 = 2 * 1024 * 1024; const NGINX_AVAILABLE: &str = "/etc/nginx/sites-available"; const NGINX_ENABLED: &str = "/etc/nginx/sites-enabled"; pub async fn handle(action: &str, params: Value) -> Result { if action.starts_with("app.") { return crate::app::handle(action, params).await; } match action { "system.snapshot" => system_snapshot().await, "file.roots" => file_roots(), "file.list" => file_list(path_param(¶ms, "path")?), "file.search" => file_search(params), "file.read" => file_read(path_param(¶ms, "path")?), "file.write" => file_write( path_param(¶ms, "path")?, string_param(¶ms, "content")?, ), "file.mkdir" => file_mkdir(path_param(¶ms, "path")?), "file.delete" => file_delete( path_param(¶ms, "path")?, params .get("recursive") .and_then(Value::as_bool) .unwrap_or(false), ), "file.rename" => file_rename(path_param(¶ms, "from")?, path_param(¶ms, "to")?), "file.upload.chunk" => file_upload_chunk(params), "file.download.chunk" => file_download_chunk(params), "file.chmod" => file_chmod(params), "log.tail_file" | "log.read_file" => log_tail(params).await, "service.list" => service_list().await, "service.status" => service_status(string_param(¶ms, "name")?).await, "service.start" => service_action("start", string_param(¶ms, "name")?).await, "service.stop" => service_action("stop", string_param(¶ms, "name")?).await, "service.restart" => service_action("restart", string_param(¶ms, "name")?).await, "service.enable" => service_action("enable", string_param(¶ms, "name")?).await, "service.disable" => service_action("disable", string_param(¶ms, "name")?).await, "nginx.status" => nginx_status().await, "nginx.sites" => nginx_sites(), "nginx.site.get" => nginx_site_get(string_param(¶ms, "name")?), "nginx.site.backups" => nginx_site_backups(string_param(¶ms, "name")?), "nginx.site.restore_backup" => { nginx_site_restore_backup( string_param(¶ms, "name")?, string_param(¶ms, "backup")?, ) .await } "nginx.site.create" => nginx_site_create(params).await, "nginx.site.update" => { nginx_site_update( string_param(¶ms, "name")?, string_param(¶ms, "content")?, ) .await } "nginx.site.enable" => nginx_site_enable(string_param(¶ms, "name")?).await, "nginx.site.disable" => nginx_site_disable(string_param(¶ms, "name")?), "nginx.test" => nginx_test().await, "nginx.reload" => nginx_reload().await, "nginx.ssl.status" => nginx_ssl_status().await, "nginx.ssl.issue" => nginx_ssl_issue(params).await, "nginx.ssl.renew" => nginx_ssl_renew().await, "nginx.ssl.auto_renew" => nginx_ssl_auto_renew().await, "docker.status" => docker_status().await, "docker.containers" => docker_containers().await, "docker.container.inspect" => docker_inspect(string_param(¶ms, "id")?).await, "docker.container.stats" => docker_stats(string_param(¶ms, "id")?).await, "docker.container.run" => docker_run(params).await, "docker.container.start" => docker_simple("start", string_param(¶ms, "id")?).await, "docker.container.stop" => docker_simple("stop", string_param(¶ms, "id")?).await, "docker.container.restart" => docker_simple("restart", string_param(¶ms, "id")?).await, "docker.container.delete" => docker_simple("rm", string_param(¶ms, "id")?).await, "docker.container.logs" => { docker_logs( string_param(¶ms, "id")?, params.get("tail").and_then(Value::as_u64).unwrap_or(200), ) .await } "docker.images" => docker_images().await, "docker.image.pull" => docker_pull(string_param(¶ms, "image")?).await, "docker.image.delete" => docker_rmi(string_param(¶ms, "id")?).await, "docker.volumes" => docker_volumes().await, "docker.volume.delete" => docker_volume_delete(string_param(¶ms, "name")?).await, "docker.compose.projects" => docker_compose_projects().await, "docker.compose.start" => { docker_compose_action("start", string_param(¶ms, "project")?).await } "docker.compose.stop" => { docker_compose_action("stop", string_param(¶ms, "project")?).await } "docker.compose.restart" => { docker_compose_action("restart", string_param(¶ms, "project")?).await } "docker.compose.logs" => { docker_compose_logs( string_param(¶ms, "project")?, params.get("tail").and_then(Value::as_u64).unwrap_or(200), ) .await } "docker.compose.preflight" => docker_compose_preflight(params).await, "docker.compose.deploy" => docker_compose_deploy(params).await, "docker.compose.update" => docker_compose_update(params).await, "docker.compose.down" => docker_compose_down(params).await, _ => bail!("不支持的操作"), } } #[allow(dead_code)] pub trait DockerProvider { async fn list_containers(&self) -> Result>; async fn start_container(&self, id: &str) -> Result<()>; async fn stop_container(&self, id: &str) -> Result<()>; async fn restart_container(&self, id: &str) -> Result<()>; async fn remove_container(&self, id: &str) -> Result<()>; } #[allow(dead_code)] pub struct DockerCliProvider; impl DockerProvider for DockerCliProvider { async fn list_containers(&self) -> Result> { let value = docker_containers().await?; Ok(serde_json::from_value(value["containers"].clone())?) } async fn start_container(&self, id: &str) -> Result<()> { docker_simple("start", id.to_string()).await.map(|_| ()) } async fn stop_container(&self, id: &str) -> Result<()> { docker_simple("stop", id.to_string()).await.map(|_| ()) } async fn restart_container(&self, id: &str) -> Result<()> { docker_simple("restart", id.to_string()).await.map(|_| ()) } async fn remove_container(&self, id: &str) -> Result<()> { docker_simple("rm", id.to_string()).await.map(|_| ()) } } async fn system_snapshot() -> Result { let processes = command_json_lines( Command::new("ps").args(["-eo", "pid,ppid,user,comm,%cpu,%mem", "--sort=-%cpu"]), 8, parse_process_line, 30, ) .await .unwrap_or_default(); let ports_result = match command_lines(Command::new("ss").args(["-H", "-tulpen"]), 8).await { Ok(lines) => Ok(lines), Err(_) => command_lines(Command::new("netstat").args(["-tulpen"]), 8).await, }; let ports = ports_result .unwrap_or_default() .into_iter() .take(80) .map(|line| json!({ "raw": line })) .collect::>(); let disks = command_json_lines(Command::new("df").args(["-hP"]), 8, parse_disk_line, 50) .await .unwrap_or_default(); let networks = command_lines(Command::new("ip").args(["-brief", "addr"]), 8) .await .unwrap_or_default() .into_iter() .map(|line| json!({ "raw": line })) .collect::>(); Ok(json!({ "processes": processes, "ports": ports, "disks": disks, "networks": networks })) } async fn command_lines(cmd: &mut Command, timeout_secs: u64) -> Result> { let output = run_command(cmd, timeout_secs).await?; let text = output_text(output)?; Ok(text .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(ToString::to_string) .collect()) } async fn command_json_lines( cmd: &mut Command, timeout_secs: u64, parser: F, limit: usize, ) -> Result> where F: Fn(&str) -> Option, { Ok(command_lines(cmd, timeout_secs) .await? .into_iter() .skip(1) .filter_map(|line| parser(&line)) .take(limit) .collect()) } fn parse_process_line(line: &str) -> Option { let parts = line.split_whitespace().collect::>(); if parts.len() < 6 { return None; } Some(json!({ "pid": parts[0], "ppid": parts[1], "user": parts[2], "name": parts[3], "cpu": parts[4], "memory": parts[5] })) } fn parse_disk_line(line: &str) -> Option { let parts = line.split_whitespace().collect::>(); if parts.len() < 6 { return None; } Some(json!({ "filesystem": parts[0], "size": parts[1], "used": parts[2], "available": parts[3], "usage": parts[4], "mount": parts[5] })) } fn string_param(params: &Value, name: &str) -> Result { params .get(name) .and_then(Value::as_str) .map(ToString::to_string) .ok_or_else(|| anyhow!("缺少参数:{name}")) } fn path_param(params: &Value, name: &str) -> Result { let value = string_param(params, name)?; if value.contains('\0') { bail!("路径无效"); } Ok(PathBuf::from(value)) } fn file_roots() -> Result { let mut roots = Vec::new(); #[cfg(windows)] { for letter in b'A'..=b'Z' { let path = format!("{}:\\", letter as char); if Path::new(&path).exists() { roots.push(json!({ "name": path, "path": path })); } } } #[cfg(not(windows))] { roots.push(json!({ "name": "/", "path": "/" })); } Ok(json!({ "roots": roots })) } fn file_list(path: PathBuf) -> Result { let entries = fs::read_dir(&path) .with_context(|| format!("read directory {}", path.display()))? .filter_map(|entry| { let entry = entry.ok()?; let meta = entry.metadata().ok()?; Some(FileEntry { name: entry.file_name().to_string_lossy().to_string(), path: entry.path().to_string_lossy().to_string(), is_dir: meta.is_dir(), size: meta.len(), modified: meta.modified().ok().and_then(|t| { t.duration_since(std::time::UNIX_EPOCH) .ok() .map(|d| d.as_secs().to_string()) }), readonly: meta.permissions().readonly(), }) }) .collect::>(); Ok(json!({ "path": path, "entries": entries })) } fn file_search(params: Value) -> Result { let root = path_param(¶ms, "path")?; let keyword = string_param(¶ms, "keyword")?; let keyword = keyword.trim().to_lowercase(); if keyword.is_empty() || keyword.len() > 120 || keyword.contains('\0') || keyword.contains('/') || keyword.contains('\\') { bail!("搜索关键词无效"); } let max_depth = params .get("max_depth") .and_then(Value::as_u64) .unwrap_or(5) .min(10) as usize; let limit = params .get("limit") .and_then(Value::as_u64) .unwrap_or(100) .min(500) as usize; let mut results = Vec::new(); search_dir(&root, &keyword, 0, max_depth, limit, &mut results)?; Ok(json!({ "path": root, "keyword": keyword, "entries": results })) } fn search_dir( path: &Path, keyword: &str, depth: usize, max_depth: usize, limit: usize, results: &mut Vec, ) -> Result<()> { if depth > max_depth || results.len() >= limit { return Ok(()); } let Ok(entries) = fs::read_dir(path) else { return Ok(()); }; for entry in entries.flatten() { if results.len() >= limit { break; } let name = entry.file_name().to_string_lossy().to_string(); let entry_path = entry.path(); let meta = entry.metadata().ok(); let is_dir = meta.as_ref().is_some_and(|value| value.is_dir()); if name.to_lowercase().contains(keyword) { results.push(json!({ "name": name, "path": entry_path, "is_dir": is_dir, "size": meta.as_ref().map(|value| value.len()).unwrap_or(0) })); } if is_dir { let _ = search_dir(&entry_path, keyword, depth + 1, max_depth, limit, results); } } Ok(()) } fn file_read(path: PathBuf) -> Result { let meta = fs::metadata(&path)?; if meta.len() > MAX_TEXT_FILE { bail!("文件过大,无法按文本读取"); } Ok(json!({ "path": path, "content": fs::read_to_string(path)? })) } fn file_write(path: PathBuf, content: String) -> Result { if content.len() as u64 > MAX_TEXT_FILE { bail!("写入内容过大"); } fs::write(&path, content)?; Ok(json!({ "ok": true, "path": path })) } fn file_mkdir(path: PathBuf) -> Result { fs::create_dir_all(&path)?; Ok(json!({ "ok": true, "path": path })) } fn file_delete(path: PathBuf, recursive: bool) -> Result { let meta = fs::metadata(&path)?; if meta.is_dir() { if recursive { fs::remove_dir_all(&path)?; } else { fs::remove_dir(&path)?; } } else { fs::remove_file(&path)?; } Ok(json!({ "ok": true })) } fn file_rename(from: PathBuf, to: PathBuf) -> Result { fs::rename(&from, &to)?; Ok(json!({ "ok": true })) } fn file_upload_chunk(params: Value) -> Result { let path = path_param(¶ms, "path")?; let offset = params.get("offset").and_then(Value::as_u64).unwrap_or(0); let data = string_param(¶ms, "data")?; let bytes = general_purpose::STANDARD .decode(data) .context("分片数据不是有效 base64")?; if bytes.len() > 1024 * 1024 { bail!("单个分片不能超过 1MB"); } if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut file = fs::OpenOptions::new() .create(true) .write(true) .truncate(offset == 0) .open(&path)?; file.seek(SeekFrom::Start(offset))?; file.write_all(&bytes)?; Ok(json!({ "ok": true, "path": path, "written": bytes.len(), "offset": offset })) } fn file_download_chunk(params: Value) -> Result { let path = path_param(¶ms, "path")?; let offset = params.get("offset").and_then(Value::as_u64).unwrap_or(0); let size = params .get("size") .and_then(Value::as_u64) .unwrap_or(512 * 1024) .min(1024 * 1024) as usize; let meta = fs::metadata(&path)?; if !meta.is_file() { bail!("只能下载普通文件"); } let mut file = fs::File::open(&path)?; file.seek(SeekFrom::Start(offset))?; let mut buf = vec![0u8; size]; let read = file.read(&mut buf)?; buf.truncate(read); Ok(json!({ "path": path, "offset": offset, "size": meta.len(), "read": read, "eof": offset + read as u64 >= meta.len(), "data": general_purpose::STANDARD.encode(buf) })) } fn file_chmod(params: Value) -> Result { let path = path_param(¶ms, "path")?; let mode = string_param(¶ms, "mode")?; if mode.len() > 4 || !mode.chars().all(|c| matches!(c, '0'..='7')) { bail!("权限模式无效,例如 755"); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let value = u32::from_str_radix(&mode, 8)?; let mut permissions = fs::metadata(&path)?.permissions(); permissions.set_mode(value); fs::set_permissions(&path, permissions)?; Ok(json!({ "ok": true, "path": path, "mode": mode })) } #[cfg(not(unix))] { let _ = path; bail!("当前系统不支持 chmod") } } async fn log_tail(params: Value) -> Result { let path = path_param(¶ms, "path")?; let lines = params .get("lines") .and_then(Value::as_u64) .unwrap_or(200) .min(2000); let output = run_command( Command::new("tail") .arg("-n") .arg(lines.to_string()) .arg(path), 8, ) .await?; command_value(output) } async fn service_list() -> Result { let output = run_command( Command::new("systemctl").args([ "list-units", "--type=service", "--all", "--no-pager", "--plain", ]), 10, ) .await?; let text = output_text(output)?; let services = text .lines() .filter(|line| line.ends_with(".service") || line.contains(".service ")) .filter_map(parse_service_line) .collect::>(); Ok(json!({ "services": services })) } fn parse_service_line(line: &str) -> Option { let parts = line.split_whitespace().collect::>(); if parts.len() < 4 || !parts[0].contains(".service") { return None; } Some(ServiceInfo { name: parts[0].to_string(), load: parts.get(1).unwrap_or(&"").to_string(), active: parts.get(2).unwrap_or(&"").to_string(), sub: parts.get(3).unwrap_or(&"").to_string(), description: parts.get(4..).unwrap_or(&[]).join(" "), enabled: None, }) } async fn service_status(name: String) -> Result { validate_unit(&name)?; command_value( run_command( Command::new("systemctl").args(["status", "--no-pager", &name]), 10, ) .await?, ) } async fn service_action(action: &str, name: String) -> Result { validate_unit(&name)?; command_value(run_command(Command::new("systemctl").args([action, &name]), 30).await?) } fn validate_unit(name: &str) -> Result<()> { if name.len() > 200 || !name .chars() .all(|c| c.is_ascii_alphanumeric() || ".@_-".contains(c)) { bail!("服务名称无效"); } Ok(()) } async fn nginx_status() -> Result { let output = run_command(Command::new("nginx").arg("-v"), 8).await; match output { Ok(output) => { Ok(json!({ "installed": output.status.success(), "version": stderr_text(&output) })) } Err(_) => Ok(json!({ "installed": false, "version": null })), } } fn nginx_sites() -> Result { let enabled = fs::read_dir(NGINX_ENABLED) .map(|it| { it.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string())) .collect::>() }) .unwrap_or_default(); let sites = fs::read_dir(NGINX_AVAILABLE)? .filter_map(|entry| { let entry = entry.ok()?; let name = entry.file_name().to_string_lossy().to_string(); Some(NginxSite { enabled: enabled.contains(&name), path: entry.path().to_string_lossy().to_string(), name, }) }) .collect::>(); Ok(json!({ "sites": sites })) } fn nginx_site_get(name: String) -> Result { validate_site_name(&name)?; let path = Path::new(NGINX_AVAILABLE).join(&name); Ok(json!({ "name": name, "content": fs::read_to_string(path)? })) } async fn nginx_site_create(params: Value) -> Result { let name = string_param(¶ms, "name")?; validate_site_name(&name)?; let server_name = params .get("server_name") .and_then(Value::as_str) .unwrap_or(&name); let mode = params .get("mode") .and_then(Value::as_str) .unwrap_or("static"); let content = match mode { "proxy" => { let upstream = params .get("upstream") .and_then(Value::as_str) .unwrap_or("http://127.0.0.1:3000"); nginx_proxy_config(server_name, upstream, ¶ms) } "spa" => { let root = params .get("root") .and_then(Value::as_str) .unwrap_or("/var/www/html"); nginx_spa_config(server_name, root) } "load_balance" => nginx_load_balance_config(server_name, ¶ms)?, "php" => { let root = params .get("root") .and_then(Value::as_str) .unwrap_or("/var/www/html"); let fastcgi_pass = params .get("fastcgi_pass") .and_then(Value::as_str) .unwrap_or("unix:/run/php/php8.2-fpm.sock"); nginx_php_config(server_name, root, fastcgi_pass) } _ => { let root = params .get("root") .and_then(Value::as_str) .unwrap_or("/var/www/html"); nginx_static_config(server_name, root) } }; nginx_site_update(name.clone(), content).await?; Ok(json!({ "ok": true, "name": name })) } fn nginx_site_backups(name: String) -> Result { validate_site_name(&name)?; let prefix = format!("{name}.bak."); let backups = fs::read_dir(NGINX_AVAILABLE) .map(|entries| { entries .filter_map(|entry| { let entry = entry.ok()?; let file_name = entry.file_name().to_string_lossy().to_string(); if !file_name.starts_with(&prefix) { return None; } let meta = entry.metadata().ok()?; Some(json!({ "name": file_name, "path": entry.path().to_string_lossy().to_string(), "size": meta.len(), "modified": meta.modified().ok().and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()).map(|duration| duration.as_secs().to_string()) })) }) .collect::>() }) .unwrap_or_default(); Ok(json!({ "name": name, "backups": backups })) } async fn nginx_site_restore_backup(name: String, backup: String) -> Result { validate_site_name(&name)?; validate_site_name(&backup)?; if !backup.starts_with(&format!("{name}.bak.")) { bail!("备份文件不属于该站点"); } let backup_path = Path::new(NGINX_AVAILABLE).join(&backup); if !backup_path.exists() { bail!("备份文件不存在"); } let target = Path::new(NGINX_AVAILABLE).join(&name); if target.exists() { let rollback = target.with_extension(format!("bak.{}", chrono_like_timestamp())); fs::copy(&target, rollback)?; } fs::copy(&backup_path, &target)?; match nginx_test().await { Ok(_) => Ok(json!({ "ok": true, "name": name, "backup": backup })), Err(err) => bail!("恢复后 Nginx 配置测试失败:{err}"), } } async fn nginx_site_update(name: String, content: String) -> Result { validate_site_name(&name)?; let path = Path::new(NGINX_AVAILABLE).join(&name); let mut backup_path = None; if path.exists() { let backup = path.with_extension(format!("bak.{}", chrono_like_timestamp())); fs::copy(&path, &backup)?; backup_path = Some(backup); } fs::write(&path, content)?; match nginx_test().await { Ok(_) => Ok(json!({ "ok": true })), Err(err) => { if let Some(backup) = backup_path { let _ = fs::copy(backup, &path); } else { let _ = fs::remove_file(&path); } bail!("Nginx 配置测试失败,已尝试回滚:{err}") } } } async fn nginx_site_enable(name: String) -> Result { validate_site_name(&name)?; let src = Path::new(NGINX_AVAILABLE).join(&name); let dst = Path::new(NGINX_ENABLED).join(&name); if !dst.exists() { #[cfg(unix)] std::os::unix::fs::symlink(&src, &dst)?; #[cfg(windows)] fs::copy(&src, &dst)?; } nginx_test().await?; Ok(json!({ "ok": true })) } fn nginx_site_disable(name: String) -> Result { validate_site_name(&name)?; let dst = Path::new(NGINX_ENABLED).join(&name); if dst.exists() { fs::remove_file(dst)?; } Ok(json!({ "ok": true })) } async fn nginx_test() -> Result { command_value(run_command(Command::new("nginx").arg("-t"), 15).await?) } async fn nginx_reload() -> Result { nginx_test().await?; command_value(run_command(Command::new("systemctl").args(["reload", "nginx"]), 20).await?) } async fn nginx_ssl_status() -> Result { let certbot = run_command(Command::new("certbot").arg("--version"), 8).await; let installed = certbot .as_ref() .map(|output| output.status.success()) .unwrap_or(false); let version = certbot .as_ref() .map(|output| { let stderr = stderr_text(output); if stderr.trim().is_empty() { stdout_text(output) } else { stderr } }) .unwrap_or_default(); let certbot_timer = run_command( Command::new("systemctl").args(["is-enabled", "certbot.timer"]), 8, ) .await; let lightops_timer = run_command( Command::new("systemctl").args(["is-enabled", "lightops-certbot-renew.timer"]), 8, ) .await; let auto_renew_enabled = certbot_timer .as_ref() .map(|output| output.status.success()) .unwrap_or(false) || lightops_timer .as_ref() .map(|output| output.status.success()) .unwrap_or(false); let auto_renew_provider = if certbot_timer .as_ref() .map(|output| output.status.success()) .unwrap_or(false) { "certbot.timer" } else if lightops_timer .as_ref() .map(|output| output.status.success()) .unwrap_or(false) { "lightops-certbot-renew.timer" } else { "none" }; let certs = list_letsencrypt_certs().await; Ok(json!({ "installed": installed, "version": version.trim(), "auto_renew_enabled": auto_renew_enabled, "auto_renew_provider": auto_renew_provider, "certs": certs })) } async fn nginx_ssl_issue(params: Value) -> Result { ensure_certbot_installed().await?; let domains = params .get("domains") .and_then(Value::as_array) .ok_or_else(|| anyhow!("缺少域名列表"))? .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|domain| !domain.is_empty()) .map(ToString::to_string) .collect::>(); if domains.is_empty() { bail!("至少需要一个域名"); } for domain in &domains { validate_domain(domain)?; } let email = params.get("email").and_then(Value::as_str).map(str::trim); if let Some(email) = email { validate_email(email)?; } let mut command = Command::new("certbot"); command .args(["--nginx", "--non-interactive", "--agree-tos", "--redirect"]) .arg("--keep-until-expiring"); if let Some(email) = email.filter(|value| !value.is_empty()) { command.args(["-m", email]); } else { command.arg("--register-unsafely-without-email"); } for domain in &domains { command.args(["-d", domain]); } let result = command_value(run_command(&mut command, 120).await?)?; let _ = nginx_reload().await; Ok(result) } async fn nginx_ssl_renew() -> Result { ensure_certbot_installed().await?; let result = command_value(run_command(Command::new("certbot").arg("renew"), 120).await?)?; let _ = nginx_reload().await; Ok(result) } async fn nginx_ssl_auto_renew() -> Result { ensure_certbot_installed().await?; let systemctl = run_command( Command::new("systemctl").args(["enable", "--now", "certbot.timer"]), 20, ) .await; match systemctl { Ok(output) if output.status.success() => command_value(output), _ => { install_lightops_certbot_timer()?; command_value(run_command(Command::new("systemctl").arg("daemon-reload"), 20).await?)?; command_value( run_command( Command::new("systemctl").args([ "enable", "--now", "lightops-certbot-renew.timer", ]), 20, ) .await?, ) } } } async fn ensure_certbot_installed() -> Result<()> { let output = run_command(Command::new("certbot").arg("--version"), 8).await; match output { Ok(output) if output.status.success() => Ok(()), _ => bail!("未检测到 certbot,请先在 Agent 主机安装 certbot 和 Nginx 插件"), } } fn install_lightops_certbot_timer() -> Result<()> { let service = r#"[Unit] Description=LightOps Certbot 自动续期 Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=/usr/bin/env certbot renew --quiet --deploy-hook "systemctl reload nginx" "#; let timer = r#"[Unit] Description=LightOps 每日检查 Certbot 证书续期 [Timer] OnCalendar=*-*-* 03:35:00 RandomizedDelaySec=1800 Persistent=true [Install] WantedBy=timers.target "#; fs::write( "/etc/systemd/system/lightops-certbot-renew.service", service, )?; fs::write("/etc/systemd/system/lightops-certbot-renew.timer", timer)?; Ok(()) } async fn list_letsencrypt_certs() -> Vec { let root = Path::new("/etc/letsencrypt/live"); let Ok(entries) = fs::read_dir(root) else { return Vec::new(); }; let mut certs = Vec::new(); for entry in entries { if let Some(cert) = async { let entry = entry.ok()?; let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with('.') { return None; } let fullchain = entry.path().join("fullchain.pem"); let (expires_at, days_remaining, status) = cert_expiry(&fullchain).await; Some(json!({ "name": name, "path": entry.path().to_string_lossy().to_string(), "fullchain": fullchain.to_string_lossy().to_string(), "privkey": entry.path().join("privkey.pem").to_string_lossy().to_string(), "expires_at": expires_at, "days_remaining": days_remaining, "status": status })) } .await { certs.push(cert); } } certs } async fn cert_expiry(path: &Path) -> (Option, Option, String) { let output = run_command( Command::new("openssl") .args(["x509", "-enddate", "-noout", "-in"]) .arg(path), 8, ) .await; let Ok(output) = output else { return (None, None, "unknown".into()); }; if !output.status.success() { return (None, None, "unknown".into()); } let text = stdout_text(&output); let raw = text.trim().strip_prefix("notAfter=").unwrap_or(text.trim()); let Ok(naive) = NaiveDateTime::parse_from_str(raw, "%b %e %H:%M:%S %Y GMT") else { return (Some(raw.to_string()), None, "unknown".into()); }; let expires = DateTime::::from_naive_utc_and_offset(naive, Utc); let days = (expires - Utc::now()).num_days(); let status = if days < 0 { "expired" } else if days <= 7 { "critical" } else if days <= 30 { "warning" } else { "valid" }; (Some(expires.to_rfc3339()), Some(days), status.into()) } fn nginx_static_config(server_name: &str, root: &str) -> String { format!( "server {{\n listen 80;\n server_name {server_name};\n\n root {root};\n index index.html index.htm;\n\n location / {{\n try_files $uri $uri/ =404;\n }}\n}}\n" ) } fn nginx_spa_config(server_name: &str, root: &str) -> String { format!( "server {{\n listen 80;\n server_name {server_name};\n\n root {root};\n index index.html;\n\n location / {{\n try_files $uri $uri/ /index.html;\n }}\n\n location ~* \\.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp|woff2?)$ {{\n try_files $uri =404;\n expires 7d;\n add_header Cache-Control \"public, max-age=604800\";\n }}\n}}\n" ) } fn nginx_php_config(server_name: &str, root: &str, fastcgi_pass: &str) -> String { format!( "server {{\n listen 80;\n server_name {server_name};\n\n root {root};\n index index.php index.html index.htm;\n\n location / {{\n try_files $uri $uri/ /index.php?$query_string;\n }}\n\n location ~ \\.php$ {{\n include snippets/fastcgi-php.conf;\n fastcgi_pass {fastcgi_pass};\n }}\n\n location ~ /\\.ht {{\n deny all;\n }}\n}}\n" ) } fn nginx_load_balance_config(server_name: &str, params: &Value) -> Result { let upstream_name = format!("lightops_{}", sanitize_upstream_name(server_name)); let upstreams = params .get("upstreams") .and_then(Value::as_array) .ok_or_else(|| anyhow!("缺少上游列表"))? .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .collect::>(); if upstreams.is_empty() { bail!("至少需要一个上游地址"); } let mut upstream_block = format!("upstream {upstream_name} {{\n"); for upstream in upstreams { validate_nginx_upstream(upstream)?; upstream_block.push_str(&format!(" server {upstream};\n")); } upstream_block.push_str(" keepalive 32;\n}\n\n"); let mut proxy_params = params.clone(); if let Some(obj) = proxy_params.as_object_mut() { obj.insert("websocket".into(), json!(true)); obj.insert("gzip".into(), json!(true)); } Ok(format!( "{upstream_block}{}", nginx_proxy_config( server_name, &format!("http://{upstream_name}"), &proxy_params ) )) } fn nginx_proxy_config(server_name: &str, upstream: &str, params: &Value) -> String { let websocket = params .get("websocket") .and_then(Value::as_bool) .unwrap_or(true); let gzip = params.get("gzip").and_then(Value::as_bool).unwrap_or(true); let cache_static = params .get("cache_static") .and_then(Value::as_bool) .unwrap_or(false); let force_https = params .get("force_https") .and_then(Value::as_bool) .unwrap_or(false); let client_max_body_size = params .get("client_max_body_size") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("64m"); let client_max_body_size = sanitize_nginx_size(client_max_body_size); let mut extra = String::new(); if force_https { extra.push_str( " if ($scheme = http) {\n return 301 https://$host$request_uri;\n }\n\n", ); } if gzip { extra.push_str(" gzip on;\n gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;\n\n"); } let websocket_headers = if websocket { " proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n" } else { "" }; let static_cache = if cache_static { "\n location ~* \\.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp|woff2?)$ {\n proxy_pass {upstream};\n expires 7d;\n add_header Cache-Control \"public, max-age=604800\";\n }\n" } else { "" }; format!( "server {{\n listen 80;\n server_name {server_name};\n client_max_body_size {client_max_body_size};\n\n{extra} location / {{\n proxy_pass {upstream};\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n{websocket_headers} proxy_read_timeout 300s;\n proxy_send_timeout 300s;\n }}\n{static_cache}}}\n" ) .replace("{upstream}", upstream) } fn sanitize_upstream_name(server_name: &str) -> String { let name = server_name .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' } }) .collect::(); name.trim_matches('_').chars().take(80).collect() } fn validate_nginx_upstream(value: &str) -> Result<()> { if value.len() > 200 || value.contains('\0') || value.contains(';') || value.contains('{') || value.contains('}') || value.contains('$') || value.contains('`') || value.contains(' ') { bail!("上游地址无效:{value}"); } Ok(()) } fn sanitize_nginx_size(value: &str) -> String { if value.len() <= 16 && value .chars() .all(|c| c.is_ascii_digit() || matches!(c, 'k' | 'K' | 'm' | 'M' | 'g' | 'G')) { value.to_string() } else { "64m".into() } } fn validate_site_name(name: &str) -> Result<()> { if name.len() > 180 || name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') { bail!("站点名称无效"); } Ok(()) } fn validate_domain(domain: &str) -> Result<()> { if domain.len() > 253 || domain.starts_with('-') || domain.ends_with('-') || domain.contains("..") || !domain .chars() .all(|c| c.is_ascii_alphanumeric() || ".-".contains(c)) { bail!("域名无效:{domain}"); } Ok(()) } fn validate_email(email: &str) -> Result<()> { if email.len() > 254 || email.contains('\0') || !email.contains('@') { bail!("邮箱无效"); } Ok(()) } fn chrono_like_timestamp() -> String { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() .to_string() } async fn docker_status() -> Result { let output = run_command( Command::new("docker") .arg("version") .arg("--format") .arg("{{json .Server.Version}}"), 8, ) .await; match output { Ok(output) => Ok( json!({ "installed": output.status.success(), "version": stdout_text(&output).trim() }), ), Err(_) => Ok(json!({ "installed": false, "version": null })), } } async fn docker_containers() -> Result { let output = run_command( Command::new("docker").args(["ps", "-a", "--format", "{{json .}}"]), 12, ) .await?; let text = output_text(output)?; let containers = text .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .map(|v| DockerContainer { id: v .get("ID") .and_then(Value::as_str) .unwrap_or_default() .to_string(), image: v .get("Image") .and_then(Value::as_str) .unwrap_or_default() .to_string(), command: v .get("Command") .and_then(Value::as_str) .unwrap_or_default() .to_string(), status: v .get("Status") .and_then(Value::as_str) .unwrap_or_default() .to_string(), names: v .get("Names") .and_then(Value::as_str) .unwrap_or_default() .to_string(), ports: v .get("Ports") .and_then(Value::as_str) .unwrap_or_default() .to_string(), }) .collect::>(); Ok(json!({ "containers": containers })) } async fn docker_inspect(id: String) -> Result { validate_docker_id(&id)?; let output = run_command(Command::new("docker").args(["inspect", &id]), 20).await?; let text = output_text(output)?; let parsed: Value = serde_json::from_str(&text).context("Docker inspect 输出不是有效 JSON")?; let detail = parsed .as_array() .and_then(|items| items.first()) .cloned() .unwrap_or(parsed); Ok(json!({ "id": id, "detail": detail })) } async fn docker_stats(id: String) -> Result { validate_docker_id(&id)?; let output = run_command( Command::new("docker").args(["stats", "--no-stream", "--format", "{{json .}}", &id]), 20, ) .await?; let text = output_text(output)?; let stats = text .lines() .find_map(|line| serde_json::from_str::(line).ok()) .unwrap_or_else(|| json!({})); Ok(json!({ "id": id, "stats": stats })) } async fn docker_images() -> Result { let output = run_command( Command::new("docker").args(["images", "--format", "{{json .}}"]), 12, ) .await?; let text = output_text(output)?; let images = text .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .map(|v| DockerImage { repository: v .get("Repository") .and_then(Value::as_str) .unwrap_or_default() .to_string(), tag: v .get("Tag") .and_then(Value::as_str) .unwrap_or_default() .to_string(), id: v .get("ID") .and_then(Value::as_str) .unwrap_or_default() .to_string(), size: v .get("Size") .and_then(Value::as_str) .unwrap_or_default() .to_string(), }) .collect::>(); Ok(json!({ "images": images })) } async fn docker_volumes() -> Result { let output = run_command( Command::new("docker").args(["volume", "ls", "--format", "{{json .}}"]), 12, ) .await?; let text = output_text(output)?; let volumes = text .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .collect::>(); Ok(json!({ "volumes": volumes })) } async fn docker_volume_delete(name: String) -> Result { validate_docker_volume_name(&name)?; command_value(run_command(Command::new("docker").args(["volume", "rm", &name]), 60).await?) } async fn docker_simple(action: &str, id: String) -> Result { validate_docker_id(&id)?; command_value(run_command(Command::new("docker").args([action, &id]), 30).await?) } async fn docker_logs(id: String, tail: u64) -> Result { validate_docker_id(&id)?; let tail = tail.min(2000).to_string(); command_value( run_command( Command::new("docker").args(["logs", "--tail", &tail, &id]), 30, ) .await?, ) } async fn docker_rmi(id: String) -> Result { validate_docker_id(&id)?; command_value(run_command(Command::new("docker").args(["rmi", &id]), 60).await?) } async fn docker_pull(image: String) -> Result { validate_docker_image(&image)?; command_value(run_command(Command::new("docker").args(["pull", &image]), 600).await?) } async fn docker_run(params: Value) -> Result { let image = string_param(¶ms, "image")?; validate_docker_image(&image)?; let mut cmd = Command::new("docker"); cmd.arg("run"); if params .get("detach") .and_then(Value::as_bool) .unwrap_or(true) { cmd.arg("-d"); } if let Some(name) = params.get("name").and_then(Value::as_str).map(str::trim) { if !name.is_empty() { validate_container_name(name)?; cmd.args(["--name", name]); } } if let Some(restart) = params.get("restart").and_then(Value::as_str) { validate_restart_policy(restart)?; if restart != "no" { cmd.args(["--restart", restart]); } } if let Some(items) = params.get("ports").and_then(Value::as_array) { for item in items { let host = item .get("host") .and_then(Value::as_u64) .ok_or_else(|| anyhow!("端口映射缺少宿主机端口"))?; let container = item .get("container") .and_then(Value::as_u64) .ok_or_else(|| anyhow!("端口映射缺少容器端口"))?; validate_port(host)?; validate_port(container)?; let protocol = item .get("protocol") .and_then(Value::as_str) .unwrap_or("tcp"); if !matches!(protocol, "tcp" | "udp") { bail!("端口协议只支持 tcp/udp"); } cmd.args(["-p", &format!("{host}:{container}/{protocol}")]); } } if let Some(items) = params.get("volumes").and_then(Value::as_array) { for item in items { let host = item .get("host") .and_then(Value::as_str) .ok_or_else(|| anyhow!("卷映射缺少宿主机路径"))?; let container = item .get("container") .and_then(Value::as_str) .ok_or_else(|| anyhow!("卷映射缺少容器路径"))?; validate_volume_path(host)?; validate_volume_path(container)?; let suffix = if item .get("readonly") .and_then(Value::as_bool) .unwrap_or(false) { ":ro" } else { "" }; cmd.args(["-v", &format!("{host}:{container}{suffix}")]); } } if let Some(env) = params.get("env").and_then(Value::as_object) { for (key, value) in env { validate_env_key(key)?; let value = value.as_str().unwrap_or_default(); if value.contains('\0') || value.len() > 2048 { bail!("环境变量值无效"); } cmd.args(["-e", &format!("{key}={value}")]); } } cmd.arg(&image); if let Some(command) = params.get("command").and_then(Value::as_str).map(str::trim) { if !command.is_empty() { validate_command_args(command)?; for part in command.split_whitespace() { cmd.arg(part); } } } command_value(run_command(&mut cmd, 120).await?) } async fn docker_compose_projects() -> Result { let output = run_command( Command::new("docker").args(["ps", "-a", "--format", "{{json .}}"]), 12, ) .await?; let text = output_text(output)?; let mut projects = serde_json::Map::new(); for line in text.lines() { let Ok(value) = serde_json::from_str::(line) else { continue; }; let labels = value .get("Labels") .and_then(Value::as_str) .unwrap_or_default(); let Some(project) = label_value(labels, "com.docker.compose.project") else { continue; }; let service = label_value(labels, "com.docker.compose.service").unwrap_or_default(); let status = value .get("Status") .and_then(Value::as_str) .unwrap_or_default(); let entry = projects.entry(project.clone()).or_insert_with(|| { json!({ "name": project, "services": [], "containers": [], "running": 0, "stopped": 0, "status": "Stopped" }) }); if let Some(obj) = entry.as_object_mut() { push_unique_json_string(obj, "services", &service); if let Some(id) = value.get("ID").and_then(Value::as_str) { push_unique_json_string(obj, "containers", id); } let key = if status.starts_with("Up") { "running" } else { "stopped" }; let next = obj.get(key).and_then(Value::as_u64).unwrap_or(0) + 1; obj.insert(key.into(), json!(next)); obj.insert( "status".into(), json!(if status.starts_with("Up") { "Running" } else { "Stopped" }), ); } } Ok(json!({ "projects": projects.into_iter().map(|(_, value)| value).collect::>() })) } async fn docker_compose_action(action: &str, project: String) -> Result { validate_compose_project(&project)?; let ids = compose_container_ids(&project).await?; if ids.is_empty() { bail!("未找到 Compose 项目容器"); } let mut cmd = Command::new("docker"); cmd.arg(action); for id in &ids { cmd.arg(id); } command_value(run_command(&mut cmd, 60).await?) } async fn docker_compose_logs(project: String, tail: u64) -> Result { validate_compose_project(&project)?; let ids = compose_container_ids(&project).await?; let tail = tail.min(2000).to_string(); let mut content = String::new(); for id in ids { let output = run_command( Command::new("docker").args(["logs", "--tail", &tail, &id]), 30, ) .await?; content.push_str(&format!("===== {id} =====\n")); content.push_str(&stdout_text(&output)); content.push_str(&stderr_text(&output)); if !content.ends_with('\n') { content.push('\n'); } } Ok(json!({ "ok": true, "stdout": content, "stderr": "" })) } async fn docker_compose_preflight(params: Value) -> Result { let project = string_param(¶ms, "project")?; validate_compose_project(&project)?; let work_dir = path_param(¶ms, "work_dir")?; let data_dir = path_param(¶ms, "data_dir")?; validate_absolute_path(&work_dir)?; validate_absolute_path(&data_dir)?; let docker = run_command(Command::new("docker").arg("info"), 15).await?; if !docker.status.success() { bail!("Docker 不可用:{}", stderr_text(&docker)); } let compose = run_command(Command::new("docker").args(["compose", "version"]), 15).await?; if !compose.status.success() { bail!("Docker Compose 不可用:{}", stderr_text(&compose)); } let ids = compose_container_ids(&project).await?; if !ids.is_empty() { bail!("Compose 项目名已存在:{project}"); } if work_dir.join("compose.yaml").exists() { bail!("工作目录已存在 compose.yaml,为避免覆盖请更换项目名或工作目录"); } let mut warnings = Vec::new(); if dir_has_entries(&work_dir)? { warnings.push(format!("工作目录 {} 非空", work_dir.display())); } if dir_has_entries(&data_dir)? { warnings.push(format!( "数据目录 {} 非空,安装会复用该目录", data_dir.display() )); } if let Some(ports) = params.get("ports").and_then(Value::as_array) { for port in ports { let port = port.as_u64().ok_or_else(|| anyhow!("端口必须是数字"))?; validate_port(port)?; if port_in_use(port as u16).await? { bail!("端口已被占用:{port}"); } } } Ok(json!({ "ok": true, "checks": { "docker": true, "compose": true, "project_available": true, "ports_available": true, "work_dir_safe": true }, "warnings": warnings })) } async fn docker_compose_deploy(params: Value) -> Result { let project = string_param(¶ms, "project")?; validate_compose_project(&project)?; let work_dir = path_param(¶ms, "work_dir")?; if !work_dir.is_absolute() { bail!("工作目录必须是绝对路径"); } let content = string_param(¶ms, "content")?; if content.is_empty() || content.len() > 256 * 1024 { bail!("Compose 内容为空或超过 256KB"); } fs::create_dir_all(&work_dir)?; let compose_path = work_dir.join("compose.yaml"); let backup_path = work_dir.join(format!( ".compose.yaml.lightops-bak-{}", chrono_like_timestamp() )); let had_old = compose_path.exists(); if had_old { fs::copy(&compose_path, &backup_path)?; } fs::write(&compose_path, content)?; let result = run_command( Command::new("docker") .arg("compose") .args(["-p", &project]) .arg("-f") .arg(&compose_path) .args(["up", "-d"]), 180, ) .await; match result { Ok(output) if output.status.success() => Ok(json!({ "ok": true, "stdout": stdout_text(&output), "stderr": stderr_text(&output), "work_dir": work_dir, "compose_file": compose_path })), Ok(output) => { if had_old { let _ = fs::copy(&backup_path, &compose_path); } else { let _ = fs::remove_file(&compose_path); } bail!("{}", stderr_text(&output)) } Err(err) => { if had_old { let _ = fs::copy(&backup_path, &compose_path); } else { let _ = fs::remove_file(&compose_path); } Err(err) } } } fn validate_absolute_path(path: &Path) -> Result<()> { if path.is_absolute() && path.to_string_lossy().len() <= 300 && !path.to_string_lossy().contains('\0') { Ok(()) } else { bail!("路径必须是安全的绝对路径") } } fn dir_has_entries(path: &Path) -> Result { if !path.exists() { return Ok(false); } if !path.is_dir() { bail!("路径不是目录:{}", path.display()); } Ok(fs::read_dir(path)?.next().is_some()) } async fn port_in_use(port: u16) -> Result { let output = run_command(Command::new("ss").args(["-H", "-tuln"]), 8).await; let text = match output { Ok(output) if output.status.success() => stdout_text(&output), _ => { let output = run_command(Command::new("netstat").args(["-tuln"]), 8).await?; if !output.status.success() { return Ok(false); } stdout_text(&output) } }; Ok(text.lines().any(|line| line_has_port(line, port))) } fn line_has_port(line: &str, port: u16) -> bool { let suffix = format!(":{port}"); line.split_whitespace() .any(|part| part.ends_with(&suffix) || part.contains(&format!("{suffix} "))) } async fn docker_compose_update(params: Value) -> Result { let (project, compose_path) = compose_file_params(¶ms)?; let pull = run_command( Command::new("docker") .arg("compose") .args(["-p", &project]) .arg("-f") .arg(&compose_path) .arg("pull"), 300, ) .await?; if !pull.status.success() { bail!("{}", stderr_text(&pull)); } let up = run_command( Command::new("docker") .arg("compose") .args(["-p", &project]) .arg("-f") .arg(&compose_path) .args(["up", "-d"]), 180, ) .await?; command_value_with_extra( up, json!({ "pull_stdout": stdout_text(&pull), "pull_stderr": stderr_text(&pull), "compose_file": compose_path }), ) } async fn docker_compose_down(params: Value) -> Result { let (project, compose_path) = compose_file_params(¶ms)?; command_value( run_command( Command::new("docker") .arg("compose") .args(["-p", &project]) .arg("-f") .arg(&compose_path) .arg("down"), 120, ) .await?, ) } fn compose_file_params(params: &Value) -> Result<(String, PathBuf)> { let project = string_param(params, "project")?; validate_compose_project(&project)?; let work_dir = path_param(params, "work_dir")?; if !work_dir.is_absolute() { bail!("工作目录必须是绝对路径"); } let compose_path = work_dir.join("compose.yaml"); if !compose_path.exists() { bail!("Compose 文件不存在"); } Ok((project, compose_path)) } async fn compose_container_ids(project: &str) -> Result> { let label = format!("label=com.docker.compose.project={project}"); let output = run_command( Command::new("docker").args(["ps", "-aq", "--filter", &label]), 12, ) .await?; output_text(output).map(|text| { text.lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(ToString::to_string) .collect() }) } fn label_value(labels: &str, key: &str) -> Option { labels.split(',').find_map(|item| { item.trim() .strip_prefix(&format!("{key}=")) .map(ToString::to_string) }) } fn push_unique_json_string(obj: &mut serde_json::Map, key: &str, value: &str) { if value.is_empty() { return; } let values = obj.entry(key.to_string()).or_insert_with(|| json!([])); if let Some(array) = values.as_array_mut() { if !array.iter().any(|item| item.as_str() == Some(value)) { array.push(json!(value)); } } } fn validate_docker_id(id: &str) -> Result<()> { if id.len() > 200 || !id .chars() .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) { bail!("Docker 标识无效"); } Ok(()) } fn validate_docker_image(image: &str) -> Result<()> { if image.is_empty() || image.len() > 255 || image.contains('\0') || image.starts_with('-') || image.contains("://") || !image .chars() .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) { bail!("Docker 镜像名称无效"); } Ok(()) } fn validate_container_name(name: &str) -> Result<()> { if name.len() > 128 || !name .chars() .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) { bail!("容器名称无效"); } Ok(()) } fn validate_restart_policy(policy: &str) -> Result<()> { if matches!(policy, "no" | "always" | "unless-stopped" | "on-failure") { Ok(()) } else { bail!("重启策略无效") } } fn validate_port(port: u64) -> Result<()> { if (1..=65535).contains(&port) { Ok(()) } else { bail!("端口无效") } } fn validate_volume_path(path: &str) -> Result<()> { if path.is_empty() || path.len() > 300 || path.contains('\0') || path.contains('\n') || path.contains('\r') { bail!("卷路径无效"); } Ok(()) } fn validate_docker_volume_name(name: &str) -> Result<()> { if name.is_empty() || name.len() > 255 || name.starts_with('-') || !name .chars() .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) { bail!("Docker volume 名称无效"); } Ok(()) } fn validate_env_key(key: &str) -> Result<()> { if key.is_empty() || key.len() > 80 || !key .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') { bail!("环境变量名无效,建议使用大写字母、数字和下划线"); } Ok(()) } fn validate_command_args(command: &str) -> Result<()> { if command.len() > 500 || command.contains('\0') || command.contains("&&") || command.contains("||") || command.contains(';') || command.contains('`') || command.contains("$(") { bail!("容器命令不安全"); } Ok(()) } fn validate_compose_project(project: &str) -> Result<()> { if project.is_empty() || project.len() > 128 || !project .chars() .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) { bail!("Compose 项目名称无效"); } Ok(()) } async fn run_command(cmd: &mut Command, timeout_secs: u64) -> Result { timeout(Duration::from_secs(timeout_secs), cmd.output()) .await .map_err(|_| anyhow!("命令执行超时"))? .map_err(Into::into) } fn command_value(output: std::process::Output) -> Result { let success = output.status.success(); let stdout = stdout_text(&output); let stderr = stderr_text(&output); if !success { bail!("{}", if stderr.is_empty() { stdout } else { stderr }); } Ok(json!({ "ok": true, "stdout": stdout, "stderr": stderr })) } fn command_value_with_extra(output: std::process::Output, extra: Value) -> Result { let mut value = command_value(output)?; if let (Some(value), Some(extra)) = (value.as_object_mut(), extra.as_object()) { for (key, item) in extra { value.insert(key.clone(), item.clone()); } } Ok(value) } fn output_text(output: std::process::Output) -> Result { if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else { bail!("{}", String::from_utf8_lossy(&output.stderr)); } } fn stdout_text(output: &std::process::Output) -> String { String::from_utf8_lossy(&output.stdout).to_string() } fn stderr_text(output: &std::process::Output) -> String { String::from_utf8_lossy(&output.stderr).to_string() }