Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
1
//! NGINX Manager backend (port of the NGINX part of `sessions/sessionManager.js`):2
//! fixed inspection command, allow-listed config paths, validated save with backup and3
//! `nginx -t` rollback, explicit-confirmation reload. Uses the legacy `_execSudo` path as4
//! in 0.1.0 (root direct, otherwise `sudo -n` or `sudo -S` with the password on stdin).6
use crate::core::error::{CoreError, CoreResult};7
use crate::core::ssh::exec::{ExecResult, MAX_COMMAND_OUTPUT};8
use crate::core::ssh::platform::detect_platform;9
use crate::core::ssh::session::{shell_quote, SessionManager, SessionRecord};10
use base64::Engine;11
use once_cell::sync::Lazy;12
use regex::Regex;13
use serde_json::{json, Value};14
use std::sync::Arc;15
use std::time::Duration;17
pub const NGINX_INSPECT_COMMAND: &str = include_str!("scripts/nginx_inspect.sh");18
const NGINX_PATH: &str = "PATH=/usr/sbin:/usr/local/sbin:/sbin:$PATH; export PATH; ";20
fn nginx_command(command: &str) -> String {21
format!("{NGINX_PATH}{command}")22
}24
pub const MAX_NGINX_CONFIG_BYTES: usize = 512 * 1024;25
static CONFIG_PATH: Lazy<Regex> = Lazy::new(|| {26
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()27
});29
fn now() -> String {30
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)31
}33
pub fn normalize_config_path(value: &str) -> CoreResult<String> {34
let path = value.trim();35
if !CONFIG_PATH.is_match(path) || path.contains(".chj-backup-") {36
return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration path is outside the managed directories."));37
}38
Ok(path.to_string())39
}41
pub fn normalize_sudo_password(value: Option<&Value>) -> CoreResult<String> {42
let password = value.and_then(Value::as_str).unwrap_or("");43
if password.encode_utf16().count() > 1024 || password.contains(['\0', '\r', '\n']) {44
return Err(CoreError::new("SUDO_PASSWORD_INVALID", "Invalid sudo password."));45
}46
Ok(password.to_string())47
}49
pub fn parse_inspection_output(stdout: &str) -> Value {50
let mut result = json!({ "installed": false, "binary": "", "version": "", "configPath": "", "serviceState": "unknown", "configs": [] });51
let mut configs: Vec<Value> = Vec::new();52
for line in stdout.split('\n').map(|l| l.trim_end_matches('\r')) {53
if let Some(rest) = line.strip_prefix("config=") {54
let Some(separator) = rest.find('|') else { continue };55
let kind = if &rest[..separator] == "symlink" { "symlink" } else { "file" };56
if let Ok(path) = normalize_config_path(&rest[separator + 1..]) {57
if !configs.iter().any(|c| c["path"] == path) {58
configs.push(json!({ "path": path, "kind": kind, "writable": kind == "file" }));59
}60
}61
continue;62
}63
let Some(index) = line.find('=').filter(|i| *i >= 1) else { continue };64
let key = &line[..index];65
let value: String = line[index + 1..].chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect::<String>().trim().chars().take(512).collect();66
match key {67
"installed" => result["installed"] = json!(value == "1"),68
"binary" | "version" | "configPath" | "serviceState" => result[key] = json!(value),69
_ => {}70
}71
}72
configs.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str()));73
result["configs"] = Value::Array(configs);74
result75
}77
async fn exec_sudo(manager: &SessionManager, record: &Arc<SessionRecord>, command: &str, sudo_password: &str, timeout: Duration) -> CoreResult<ExecResult> {78
let root = record.username == "root";79
let prefix = if root { "sh -c " } else if !sudo_password.is_empty() { "sudo -S -p '' -- sh -c " } else { "sudo -n -- sh -c " };80
let mut channel = manager81
.exec_channel(record, &format!("{prefix}{}", shell_quote(&nginx_command(command))), timeout, MAX_COMMAND_OUTPUT)82
.await83
.map_err(|e| if e.code == "REMOTE_COMMAND_FAILED" { CoreError::new("USER_ACTION_FAILED", e.message) } else { e })?;84
if !root && !sudo_password.is_empty() {85
channel.write(format!("{sudo_password}\n").as_bytes()).await;86
}87
channel.eof().await;88
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 })?;89
if result.code != Some(0) {90
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() };91
return Err(CoreError::new("USER_ACTION_FAILED", message).with("remoteExitCode", result.code.map(Value::from).unwrap_or(Value::Null)));92
}93
Ok(result)94
}96
async fn assert_posix(manager: &SessionManager, record: &Arc<SessionRecord>, feature: &str) -> CoreResult<()> {97
let platform = detect_platform(manager, record, false).await?;98
if platform["os"] == "windows" {99
return Err(CoreError::new(100
"FEATURE_PLATFORM_UNSUPPORTED",101
format!("{feature} is available only on Linux/POSIX servers; this server runs {}.", platform["caption"].as_str().filter(|c| !c.is_empty()).unwrap_or("Windows")),102
));103
}104
Ok(())105
}107
pub async fn inspect(manager: &SessionManager, session_id: &str) -> CoreResult<Value> {108
let record = manager.require_connected(session_id)?;109
assert_posix(manager, &record, "NGINX management").await?;110
let output = manager.exec_fixed_default(&record, &nginx_command(NGINX_INSPECT_COMMAND), Duration::from_secs(10)).await?;111
let mut result = json!({ "sessionId": record.session_id });112
if let (Some(target), Value::Object(parsed)) = (result.as_object_mut(), parse_inspection_output(&output.stdout)) {113
target.extend(parsed);114
target.insert("collectedAt".into(), json!(now()));115
}116
Ok(result)117
}119
pub async fn read_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult<Value> {120
let record = manager.require_connected(session_id)?;121
let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;122
let password = normalize_sudo_password(payload.get("sudoPassword"))?;123
let command = format!(124
"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\"",125
shell_quote(&path)126
);127
let output = if password.is_empty() {128
manager.exec_fixed_default(&record, &command, Duration::from_secs(10)).await?129
} else {130
exec_sudo(manager, &record, &command, &password, Duration::from_secs(10)).await?131
};132
Ok(json!({ "path": path, "text": output.stdout, "size": output.stdout.len() }))133
}135
pub async fn dump_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult<Value> {136
let record = manager.require_connected(session_id)?;137
let password = normalize_sudo_password(payload.get("sudoPassword"))?;138
let output = exec_sudo(manager, &record, "nginx -T 2>&1", &password, Duration::from_secs(20)).await?;139
Ok(json!({ "text": output.stdout, "generatedAt": now() }))140
}142
pub async fn test_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult<Value> {143
let record = manager.require_connected(session_id)?;144
let password = normalize_sudo_password(payload.get("sudoPassword"))?;145
let command = "set +e; output=$(nginx -t 2>&1); code=$?; printf '%s\\n' \"$output\"; printf '\\036CHJ_EXIT=%s\\n' \"$code\"; exit 0";146
let output = exec_sudo(manager, &record, command, &password, Duration::from_secs(20)).await?;147
let marker = Regex::new(r"\x1eCHJ_EXIT=(\d+)\s*$").unwrap();148
let code = marker.captures(&output.stdout).and_then(|c| c[1].parse::<i64>().ok()).unwrap_or(1);149
Ok(json!({ "ok": code == 0, "output": marker.replace(&output.stdout, "").trim(), "testedAt": now() }))150
}152
pub async fn save_config(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult<Value> {153
let record = manager.require_connected(session_id)?;154
let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;155
let password = normalize_sudo_password(payload.get("sudoPassword"))?;156
let text = match payload.get("text") {157
Some(Value::String(s)) => s.clone(),158
Some(Value::Null) | None => String::new(),159
Some(other) => other.to_string(),160
};161
let bytes = text.len();162
if bytes == 0 || bytes > MAX_NGINX_CONFIG_BYTES || text.contains('\0') {163
return Err(CoreError::new("NGINX_CONFIG_INVALID", "NGINX configuration must be UTF-8 text between 1 byte and 512 KiB."));164
}165
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());166
let command = format!(167
"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\"",168
shell_quote(&path),169
shell_quote(&encoded)170
);171
let output = exec_sudo(manager, &record, &command, &password, Duration::from_secs(30)).await?;172
let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();173
let backup = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default();174
manager.logger.warn("Remote NGINX configuration saved after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "backupPath": backup }));175
Ok(json!({ "path": path, "backupPath": backup, "size": bytes, "testOutput": marker.replace(&output.stdout, "").trim(), "savedAt": now() }))176
}178
pub async fn reload(manager: &SessionManager, session_id: &str, payload: &Value) -> CoreResult<Value> {179
let record = manager.require_connected(session_id)?;180
let password = normalize_sudo_password(payload.get("sudoPassword"))?;181
if payload.get("confirm") != Some(&Value::Bool(true)) {182
return Err(CoreError::new("NGINX_RELOAD_CONFIRMATION_REQUIRED", "Reloading NGINX requires explicit confirmation."));183
}184
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\"";185
let output = exec_sudo(manager, &record, command, &password, Duration::from_secs(30)).await?;186
manager.logger.warn("Remote NGINX graceful reload completed.", json!({ "sessionId": record.session_id, "host": record.host }));187
Ok(json!({ "ok": true, "output": output.stdout.trim(), "reloadedAt": now() }))188
}190
#[cfg(test)]191
mod tests {192
use super::*;194
#[cfg(unix)]195
#[test]196
fn lookup_includes_sbin_for_non_root_shells() {197
let output = std::process::Command::new("sh").env("PATH", "/bin:/usr/bin")198
.args(["-c", &nginx_command("printf '%s' \"$PATH\"")]).output().unwrap();199
assert!(output.status.success());200
assert!(String::from_utf8(output.stdout).unwrap().starts_with("/usr/sbin:/usr/local/sbin:/sbin:"));201
}203
#[test]204
fn path_allowlist() {205
assert!(normalize_config_path("/etc/nginx/sites-available/default").is_ok());206
assert!(normalize_config_path("/etc/nginx/../passwd").is_err());207
assert!(normalize_config_path("/etc/nginx/conf.d/a.conf.chj-backup-1").is_err());208
assert!(normalize_config_path("/etc/shadow").is_err());209
}211
#[test]212
fn inspection_parsing() {213
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");214
assert_eq!(parsed["installed"], true);215
assert_eq!(parsed["configs"].as_array().unwrap().len(), 2);216
assert_eq!(parsed["configs"][0]["path"], "/etc/nginx/nginx.conf");217
assert_eq!(parsed["configs"][1]["writable"], false);218
}219
}SHA-256
49880886ed7e69117eae1a06b8b35907617f99da54d4d9ca051d2d23b5ec4a45CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df