//! NGINX Manager backend (port of the NGINX part of `sessions/sessionManager.js`): //! fixed inspection command, allow-listed config paths, validated save with backup and //! `nginx -t` rollback, explicit-confirmation reload. Uses the legacy `_execSudo` path as //! in 0.1.0 (root direct, otherwise `sudo -n` or `sudo -S` with the password on stdin). use crate::core::error::{CoreError, CoreResult}; use crate::core::ssh::exec::{ExecResult, MAX_COMMAND_OUTPUT}; use crate::core::ssh::platform::detect_platform; use crate::core::ssh::session::{shell_quote, SessionManager, SessionRecord}; use base64::Engine; use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Value}; use std::sync::Arc; use std::time::Duration; pub const NGINX_INSPECT_COMMAND: &str = include_str!("scripts/nginx_inspect.sh"); const NGINX_PATH: &str = "PATH=/usr/sbin:/usr/local/sbin:/sbin:$PATH; export PATH; "; fn nginx_command(command: &str) -> String { format!("{NGINX_PATH}{command}") } pub const MAX_NGINX_CONFIG_BYTES: usize = 512 * 1024; static CONFIG_PATH: Lazy = Lazy::new(|| { Regex::new(r"^/(?:etc/nginx/(?:nginx\.conf|(?:conf\.d|snippets|modules-(?:available|enabled)|stream-conf\.d)/[A-Za-z0-9][A-Za-z0-9._-]{0,127}|sites-(?:available|enabled)/[A-Za-z0-9][A-Za-z0-9._-]{0,127})|usr/local/etc/nginx/(?:nginx\.conf|conf\.d/[A-Za-z0-9][A-Za-z0-9._-]{0,127})|opt/homebrew/etc/nginx/(?:nginx\.conf|servers/[A-Za-z0-9][A-Za-z0-9._-]{0,127}))$").unwrap() }); fn now() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } pub fn normalize_config_path(value: &str) -> CoreResult { let path = value.trim(); if !CONFIG_PATH.is_match(path) || path.contains(".chj-backup-") { return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration path is outside the managed directories.")); } Ok(path.to_string()) } pub fn normalize_sudo_password(value: Option<&Value>) -> CoreResult { let password = value.and_then(Value::as_str).unwrap_or(""); if password.encode_utf16().count() > 1024 || password.contains(['\0', '\r', '\n']) { return Err(CoreError::new("SUDO_PASSWORD_INVALID", "Invalid sudo password.")); } Ok(password.to_string()) } pub fn parse_inspection_output(stdout: &str) -> Value { let mut result = json!({ "installed": false, "binary": "", "version": "", "configPath": "", "serviceState": "unknown", "configs": [] }); let mut configs: Vec = Vec::new(); for line in stdout.split('\n').map(|l| l.trim_end_matches('\r')) { if let Some(rest) = line.strip_prefix("config=") { let Some(separator) = rest.find('|') else { continue }; let kind = if &rest[..separator] == "symlink" { "symlink" } else { "file" }; if let Ok(path) = normalize_config_path(&rest[separator + 1..]) { if !configs.iter().any(|c| c["path"] == path) { configs.push(json!({ "path": path, "kind": kind, "writable": kind == "file" })); } } continue; } let Some(index) = line.find('=').filter(|i| *i >= 1) else { continue }; let key = &line[..index]; let value: String = line[index + 1..].chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect::().trim().chars().take(512).collect(); match key { "installed" => result["installed"] = json!(value == "1"), "binary" | "version" | "configPath" | "serviceState" => result[key] = json!(value), _ => {} } } configs.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str())); result["configs"] = Value::Array(configs); result } async fn exec_sudo(manager: &SessionManager, record: &Arc, command: &str, sudo_password: &str, timeout: Duration) -> CoreResult { let root = record.username == "root"; let prefix = if root { "sh -c " } else if !sudo_password.is_empty() { "sudo -S -p '' -- sh -c " } else { "sudo -n -- sh -c " }; let mut channel = manager .exec_channel(record, &format!("{prefix}{}", shell_quote(&nginx_command(command))), timeout, MAX_COMMAND_OUTPUT) .await .map_err(|e| if e.code == "REMOTE_COMMAND_FAILED" { CoreError::new("USER_ACTION_FAILED", e.message) } else { e })?; if !root && !sudo_password.is_empty() { channel.write(format!("{sudo_password}\n").as_bytes()).await; } channel.eof().await; let result = channel.finish().await.map_err(|e| if e.code == "REMOTE_OUTPUT_TOO_LARGE" { CoreError::new(e.code, "The user-management response is too large.") } else { e })?; if result.code != Some(0) { let message = if result.stderr.trim().is_empty() { format!("User command exited with {}.", result.code.map(|c| c.to_string()).unwrap_or_else(|| "undefined".into())) } else { result.stderr.trim().to_string() }; return Err(CoreError::new("USER_ACTION_FAILED", message).with("remoteExitCode", result.code.map(Value::from).unwrap_or(Value::Null))); } Ok(result) } async fn assert_posix(manager: &SessionManager, record: &Arc, feature: &str) -> CoreResult<()> { let platform = detect_platform(manager, record, false).await?; if platform["os"] == "windows" { return Err(CoreError::new( "FEATURE_PLATFORM_UNSUPPORTED", format!("{feature} is available only on Linux/POSIX servers; this server runs {}.", platform["caption"].as_str().filter(|c| !c.is_empty()).unwrap_or("Windows")), )); } Ok(()) } pub async fn inspect(manager: &SessionManager, session_id: &str) -> CoreResult { let record = manager.require_connected(session_id)?; assert_posix(manager, &record, "NGINX management").await?; let output = manager.exec_fixed_default(&record, &nginx_command(NGINX_INSPECT_COMMAND), Duration::from_secs(10)).await?; let mut result = json!({ "sessionId": record.session_id }); if let (Some(target), Value::Object(parsed)) = (result.as_object_mut(), parse_inspection_output(&output.stdout)) { target.extend(parsed); target.insert("collectedAt".into(), json!(now())); } Ok(result) } pub async fn read_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult { let record = manager.require_connected(session_id)?; let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?; let password = normalize_sudo_password(payload.get("sudoPassword"))?; let command = format!( "target={}; [ -f \"$target\" ] || {{ echo 'NGINX configuration file was not found.' >&2; exit 41; }}; size=$(wc -c < \"$target\"); [ \"$size\" -le {MAX_NGINX_CONFIG_BYTES} ] || {{ echo 'NGINX configuration exceeds the 512 KiB editor limit.' >&2; exit 42; }}; cat -- \"$target\"", shell_quote(&path) ); let output = if password.is_empty() { manager.exec_fixed_default(&record, &command, Duration::from_secs(10)).await? } else { exec_sudo(manager, &record, &command, &password, Duration::from_secs(10)).await? }; Ok(json!({ "path": path, "text": output.stdout, "size": output.stdout.len() })) } pub async fn dump_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult { let record = manager.require_connected(session_id)?; let password = normalize_sudo_password(payload.get("sudoPassword"))?; let output = exec_sudo(manager, &record, "nginx -T 2>&1", &password, Duration::from_secs(20)).await?; Ok(json!({ "text": output.stdout, "generatedAt": now() })) } pub async fn test_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult { let record = manager.require_connected(session_id)?; let password = normalize_sudo_password(payload.get("sudoPassword"))?; let command = "set +e; output=$(nginx -t 2>&1); code=$?; printf '%s\\n' \"$output\"; printf '\\036CHJ_EXIT=%s\\n' \"$code\"; exit 0"; let output = exec_sudo(manager, &record, command, &password, Duration::from_secs(20)).await?; let marker = Regex::new(r"\x1eCHJ_EXIT=(\d+)\s*$").unwrap(); let code = marker.captures(&output.stdout).and_then(|c| c[1].parse::().ok()).unwrap_or(1); Ok(json!({ "ok": code == 0, "output": marker.replace(&output.stdout, "").trim(), "testedAt": now() })) } pub async fn save_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult { let record = manager.require_connected(session_id)?; let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?; let password = normalize_sudo_password(payload.get("sudoPassword"))?; let text = match payload.get("text") { Some(Value::String(s)) => s.clone(), Some(Value::Null) | None => String::new(), Some(other) => other.to_string(), }; let bytes = text.len(); if bytes == 0 || bytes > MAX_NGINX_CONFIG_BYTES || text.contains('\0') { return Err(CoreError::new("NGINX_CONFIG_INVALID", "NGINX configuration must be UTF-8 text between 1 byte and 512 KiB.")); } let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); let command = format!( "target={}; data={}; [ -f \"$target\" ] || {{ echo 'NGINX configuration file was not found.' >&2; exit 41; }}; [ ! -L \"$target\" ] || {{ echo 'Edit the corresponding sites-available file instead of replacing an enabled symlink.' >&2; exit 42; }}; backup=\"$target.chj-backup-$(date +%Y%m%dT%H%M%S)\"; temporary=$(mktemp \"$target.chj-tmp.XXXXXX\") || exit 43; cleanup(){{ [ -z \"$temporary\" ] || rm -f -- \"$temporary\"; }}; trap cleanup EXIT HUP INT TERM; if ! printf '%s' \"$data\" | base64 --decode > \"$temporary\" 2>/dev/null; then printf '%s' \"$data\" | base64 -d > \"$temporary\" || exit 44; fi; chmod --reference=\"$target\" \"$temporary\"; chown --reference=\"$target\" \"$temporary\"; cp -p -- \"$target\" \"$backup\"; mv -f -- \"$temporary\" \"$target\"; temporary=; test_output=$(nginx -t 2>&1); test_code=$?; if [ \"$test_code\" -ne 0 ]; then cp -p -- \"$backup\" \"$target\"; printf '%s\\nConfiguration was rolled back from %s.\\n' \"$test_output\" \"$backup\" >&2; exit 45; fi; printf '%s\\n\\036CHJ_BACKUP=%s\\n' \"$test_output\" \"$backup\"", shell_quote(&path), shell_quote(&encoded) ); let output = exec_sudo(manager, &record, &command, &password, Duration::from_secs(30)).await?; let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap(); let backup = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default(); manager.logger.warn("Remote NGINX configuration saved after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "backupPath": backup })); Ok(json!({ "path": path, "backupPath": backup, "size": bytes, "testOutput": marker.replace(&output.stdout, "").trim(), "savedAt": now() })) } pub async fn reload(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult { let record = manager.require_connected(session_id)?; let password = normalize_sudo_password(payload.get("sudoPassword"))?; if payload.get("confirm") != Some(&Value::Bool(true)) { return Err(CoreError::new("NGINX_RELOAD_CONFIRMATION_REQUIRED", "Reloading NGINX requires explicit confirmation.")); } let command = "test_output=$(nginx -t 2>&1) || { printf '%s\\n' \"$test_output\" >&2; exit 41; }; if command -v systemctl >/dev/null 2>&1; then systemctl reload nginx; elif command -v service >/dev/null 2>&1; then service nginx reload; else nginx -s reload; fi; printf '%s\\nNGINX configuration reloaded gracefully.\\n' \"$test_output\""; let output = exec_sudo(manager, &record, command, &password, Duration::from_secs(30)).await?; manager.logger.warn("Remote NGINX graceful reload completed.", json!({ "sessionId": record.session_id, "host": record.host })); Ok(json!({ "ok": true, "output": output.stdout.trim(), "reloadedAt": now() })) } #[cfg(test)] mod tests { use super::*; #[cfg(unix)] #[test] fn lookup_includes_sbin_for_non_root_shells() { let output = std::process::Command::new("sh").env("PATH", "/bin:/usr/bin") .args(["-c", &nginx_command("printf '%s' \"$PATH\"")]).output().unwrap(); assert!(output.status.success()); assert!(String::from_utf8(output.stdout).unwrap().starts_with("/usr/sbin:/usr/local/sbin:/sbin:")); } #[test] fn path_allowlist() { assert!(normalize_config_path("/etc/nginx/sites-available/default").is_ok()); assert!(normalize_config_path("/etc/nginx/../passwd").is_err()); assert!(normalize_config_path("/etc/nginx/conf.d/a.conf.chj-backup-1").is_err()); assert!(normalize_config_path("/etc/shadow").is_err()); } #[test] fn inspection_parsing() { let parsed = parse_inspection_output("installed=1\nbinary=/usr/sbin/nginx\nversion=nginx/1.26\nconfig=symlink|/etc/nginx/sites-enabled/default\nconfig=file|/etc/nginx/nginx.conf\nconfig=file|/tmp/evil\n"); assert_eq!(parsed["installed"], true); assert_eq!(parsed["configs"].as_array().unwrap().len(), 2); assert_eq!(parsed["configs"][0]["path"], "/etc/nginx/nginx.conf"); assert_eq!(parsed["configs"][1]["writable"], false); } }