CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
nginx.rs Hinzugefügt: Datum nicht erfasst 13,1 KB · 219 Zeilen
Code Datei herunterladen
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 and
3//! `nginx -t` rollback, explicit-confirmation reload. Uses the legacy `_execSudo` path as
4//! in 0.1.0 (root direct, otherwise `sudo -n` or `sudo -S` with the password on stdin).
6use crate::core::error::{CoreError, CoreResult};
7use crate::core::ssh::exec::{ExecResult, MAX_COMMAND_OUTPUT};
8use crate::core::ssh::platform::detect_platform;
9use crate::core::ssh::session::{shell_quote, SessionManager, SessionRecord};
10use base64::Engine;
11use once_cell::sync::Lazy;
12use regex::Regex;
13use serde_json::{json, Value};
14use std::sync::Arc;
15use std::time::Duration;
17pub const NGINX_INSPECT_COMMAND: &str = include_str!("scripts/nginx_inspect.sh");
18const NGINX_PATH: &str = "PATH=/usr/sbin:/usr/local/sbin:/sbin:$PATH; export PATH; ";
20fn nginx_command(command: &str) -> String {
21 format!("{NGINX_PATH}{command}")
24pub const MAX_NGINX_CONFIG_BYTES: usize = 512 * 1024;
25static 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});
29fn now() -> String {
30 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
33pub 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())
41pub 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())
49pub 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 result
77async 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 = manager
81 .exec_channel(record, &format!("{prefix}{}", shell_quote(&nginx_command(command))), timeout, MAX_COMMAND_OUTPUT)
82 .await
83 .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)
96async 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(())
107pub 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)
119pub 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() }))
135pub 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() }))
142pub 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() }))
152pub 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() }))
178pub 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() }))
190#[cfg(test)]
191mod 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 }
SHA-25649880886ed7e69117eae1a06b8b35907617f99da54d4d9ca051d2d23b5ec4a45

CH-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 Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien