CH-J Toolsspráva serverov nad SSH
Menu
Uverejnený zdroj

CH-J Tools

Prechádzať adresáre a súbory pre konkrétne vydanie aplikácie.

Stiahnuť zdroj ZIP
CH-J / Server Manager 1704 súbory
0.1.2-beta Pridané: · Postavený:
nginx.rs Pridané: Dátum nezaznamenaný 68,8 KB · 1128 riadky
Kód Stiahnuť súbor
1//! NGINX Manager backend: allow-listed config paths and validated saves. The commands live in the matrix
2//! (`nginx.sh`) and run through the shared privileged executor.
4use crate::core::error::{CoreError, CoreResult};
5use crate::core::ssh::exec::MAX_COMMAND_OUTPUT;
6use crate::core::ssh::nginx_conf::{self as conf, Capabilities};
7use crate::core::ssh::platform::detect_platform;
8use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions, RunResult};
9use crate::core::ssh::session::{SessionManager, SessionRecord};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14use std::collections::{BTreeMap, BTreeSet};
15use std::sync::Arc;
16use std::time::Duration;
18pub const MAX_NGINX_CONFIG_BYTES: usize = 512 * 1024;
19/// `nginx -T` of a server with many sites is much larger than one file.
20const MAX_SITES_OUTPUT: usize = 8 * 1024 * 1024;
21const MAX_LOG_LINES: u64 = 2000;
22const MAX_PROBES: usize = 32;
23static SHA256: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9a-f]{64}$").unwrap());
24static BACKUP: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.chj-backup-[0-9]{8}T[0-9]{6}(-[0-9]{1,4})?$").unwrap());
25static LOG_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,255}$").unwrap());
26static EMAIL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._+-]{1,64}@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$").unwrap());
27static SITES_AVAILABLE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/etc/nginx/sites-available/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$").unwrap());
28static SITES_ENABLED: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/etc/nginx/sites-enabled/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$").unwrap());
29static FRAGMENT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/etc/nginx/(conf|http)\.d/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$").unwrap());
30const DISABLED_SUFFIX: &str = ".chj-disabled";
31/// A real file from sites-enabled while it is turned off; the plain name in sites-available may hold something else.
32const ENABLED_COPY_SUFFIX: &str = ".chj-enabled-copy";
33const CONTROL_API_VERSION: (u32, u32, u32) = (1, 31, 5);
34// Everything NGINX can load from its own directory, also below subdirectories such as proxy-routes.
35static CONFIG_PATH: Lazy<Regex> = Lazy::new(|| {
36 Regex::new(r"^/(?:etc/nginx/(?:[A-Za-z0-9][A-Za-z0-9._-]{0,127}/){0,4}[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()
37});
38// Keys and password files can live under /etc/nginx; they never open in an editor.
39static SECRET_FILE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(\.(key|pem|crt|cer|csr|p12|pfx|der)|htpasswd[^/]*|\.passwd)$").unwrap());
41fn now() -> String {
42 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
45pub fn normalize_config_path(value: &str) -> CoreResult<String> {
46 let path = value.trim();
47 if !CONFIG_PATH.is_match(path) || path.contains(".chj-backup-") || path.contains(".chj-tmp.") || SECRET_FILE.is_match(path) {
48 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration path is outside the managed directories."));
49 }
50 Ok(path.to_string())
53pub fn normalize_sudo_password(value: Option<&Value>) -> CoreResult<String> {
54 let password = value.and_then(Value::as_str).unwrap_or("");
55 if password.encode_utf16().count() > 1024 || password.contains(['\0', '\r', '\n']) {
56 return Err(CoreError::new("SUDO_PASSWORD_INVALID", "Invalid sudo password."));
57 }
58 Ok(password.to_string())
61pub fn parse_inspection_output(stdout: &str) -> Value {
62 let mut result = json!({ "installed": false, "binary": "", "version": "", "configPath": "", "serviceState": "unknown", "configs": [] });
63 let mut configs: Vec<Value> = Vec::new();
64 let mut modules = BTreeSet::new();
65 for line in stdout.split('\n').map(|l| l.trim_end_matches('\r')) {
66 if let Some(rest) = line.strip_prefix("config=") {
67 let mut fields = rest.split('|');
68 let (Some(kind), Some(path)) = (fields.next(), fields.next()) else { continue };
69 let kind = if kind == "symlink" { "symlink" } else { "file" };
70 if let Ok(path) = normalize_config_path(path) {
71 if !configs.iter().any(|c| c["path"] == path) {
72 // The target decides where a symlink is edited: it may be outside the managed directories.
73 let target = fields.next().filter(|_| kind == "symlink").unwrap_or("");
74 let editable = normalize_config_path(target).ok();
75 configs.push(json!({ "path": path, "kind": kind, "writable": kind == "file", "target": target, "editablePath": editable }));
76 }
77 }
78 continue;
79 }
80 if let Some(module) = line.strip_prefix("module=") {
81 modules.insert(module.to_string());
82 continue;
83 }
84 let Some(index) = line.find('=').filter(|i| *i >= 1) else { continue };
85 let key = &line[..index];
86 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();
87 match key {
88 "installed" => result["installed"] = json!(value == "1"),
89 "binary" | "version" | "configPath" | "serviceState" => result[key] = json!(value),
90 _ => {}
91 }
92 }
93 configs.sort_by(|a, b| a["path"].as_str().cmp(&b["path"].as_str()));
94 result["configs"] = Value::Array(configs);
95 let version = conf::parse_version(result["version"].as_str().unwrap_or(""));
96 // The Control API is only reported. It has no authentication of its own, so CH-J never turns it on.
97 result["capabilities"] = json!({
98 "http2": modules.contains("http_v2_module"),
99 "http3": modules.contains("http_v3_module"),
100 "stream": modules.contains("stream"),
101 "controlApi": { "versionSupported": version >= CONTROL_API_VERSION, "buildFlag": modules.contains("control_api"), "enabled": false }
102 });
103 result
106fn operation(id: &str, args: Vec<String>, frames: Vec<Frame>, password: String, privileged: bool, timeout: Duration) -> RunOptions {
107 RunOptions {
108 use_cached_password: privileged,
109 operation: id.into(),
110 args,
111 frames,
112 privileged,
113 sudo_password: Some(password).filter(|password| !password.is_empty()),
114 purpose: id.into(),
115 error_prefix: "NGINX_".into(),
116 timeout,
117 output_limit: MAX_COMMAND_OUTPUT,
118 ..Default::default()
119 }
122/// Remembering the password for the session is the user's choice in the module toolbar.
123fn remembered(payload: &Value, mut options: RunOptions) -> RunOptions {
124 options.remember_password = payload.get("rememberSudo") == Some(&Value::Bool(true));
125 options
128fn sha256(text: &str) -> String {
129 Sha256::digest(text.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect()
132fn expected_hash(payload: &Value) -> CoreResult<String> {
133 let value = payload.get("expectedSha256").and_then(Value::as_str).unwrap_or("");
134 if !value.is_empty() && !SHA256.is_match(value) {
135 return Err(CoreError::new("NGINX_CONFIG_INVALID", "The expected file hash is not valid."));
136 }
137 Ok(value.to_string())
140fn require_confirm(payload: &Value, what: &str) -> CoreResult<()> {
141 if payload.get("confirm") != Some(&Value::Bool(true)) {
142 return Err(CoreError::new("NGINX_CONFIRMATION_REQUIRED", format!("{what} requires explicit confirmation.")));
143 }
144 Ok(())
147/// A helper that dies without reporting a code (killed, out of disk) must not look like success.
148async fn run(executor: &PrivilegedExecutor, session_id: &str, options: RunOptions) -> CoreResult<RunResult> {
149 let result = executor.run(session_id, options).await?;
150 if result.code != Some(0) {
151 return Err(CoreError::new("NGINX_HELPER_FAILED", "The NGINX helper stopped before finishing.").with("remoteExitCode", result.code.map(Value::from).unwrap_or(Value::Null)));
152 }
153 Ok(result)
156async fn assert_posix(manager: &SessionManager, record: &Arc<SessionRecord>, feature: &str) -> CoreResult<()> {
157 let platform = detect_platform(manager, record, false).await?;
158 if platform["os"] == "windows" {
159 return Err(CoreError::new(
160 "FEATURE_PLATFORM_UNSUPPORTED",
161 format!("{feature} is available only on Linux/POSIX servers; this server runs {}.", platform["caption"].as_str().filter(|c| !c.is_empty()).unwrap_or("Windows")),
162 ));
163 }
164 Ok(())
167pub async fn inspect(executor: &PrivilegedExecutor, session_id: &str) -> CoreResult<Value> {
168 let manager = executor.sessions();
169 let record = manager.require_connected(session_id)?;
170 assert_posix(manager, &record, "NGINX management").await?;
171 let output = manager.exec_operation(&record, "nginx.inspect", &[], Duration::from_secs(10), MAX_COMMAND_OUTPUT).await?;
172 let mut result = json!({ "sessionId": record.session_id });
173 if let (Some(target), Value::Object(parsed)) = (result.as_object_mut(), parse_inspection_output(&output.stdout)) {
174 target.extend(parsed);
175 target.insert("collectedAt".into(), json!(now()));
176 }
177 Ok(result)
180pub async fn read_config(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
181 let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
182 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
183 let privileged = !password.is_empty();
184 let args = vec![path.clone(), MAX_NGINX_CONFIG_BYTES.to_string()];
185 let output = run(executor, session_id, remembered(payload, operation("nginx.config.read", args, vec![], password, privileged, Duration::from_secs(10)))).await?;
186 Ok(json!({ "path": path, "sha256": sha256(&output.stdout), "text": output.stdout, "size": output.stdout.len() }))
189pub async fn dump_config(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
190 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
191 let output = run(executor, session_id, remembered(payload, operation("nginx.config.dump", vec![], vec![], password, true, Duration::from_secs(20)))).await?;
192 Ok(json!({ "text": output.stdout, "generatedAt": now() }))
195pub async fn test_config(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
196 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
197 let output = run(executor, session_id, remembered(payload, operation("nginx.config.test", vec![], vec![], password, true, Duration::from_secs(20)))).await?;
198 let marker = Regex::new(r"\x1eCHJ_EXIT=(\d+)\s*$").unwrap();
199 let code = marker.captures(&output.stdout).and_then(|c| c[1].parse::<i64>().ok()).unwrap_or(1);
200 Ok(json!({ "ok": code == 0, "output": marker.replace(&output.stdout, "").trim(), "testedAt": now() }))
203pub async fn save_config(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
204 let manager = executor.sessions();
205 let record = manager.require_connected(session_id)?;
206 let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
207 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
208 let text = match payload.get("text") {
209 Some(Value::String(s)) => s.clone(),
210 Some(Value::Null) | None => String::new(),
211 Some(other) => other.to_string(),
212 };
213 let bytes = text.len();
214 if bytes == 0 || bytes > MAX_NGINX_CONFIG_BYTES || text.contains('\0') {
215 return Err(CoreError::new("NGINX_CONFIG_INVALID", "NGINX configuration must be UTF-8 text between 1 byte and 512 KiB."));
216 }
217 let args = vec![path.clone(), expected_hash(payload)?];
218 let output = run(executor, session_id, remembered(payload, operation("nginx.config.save", args, vec![Frame::text("DATA", &text)], password, true, Duration::from_secs(30)))).await?;
219 let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();
220 let backup = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default();
221 manager.logger.warn("Remote NGINX configuration saved after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "backupPath": backup }));
222 Ok(json!({ "path": path, "backupPath": backup, "size": bytes, "sha256": sha256(&text), "testOutput": marker.replace(&output.stdout, "").trim(), "savedAt": now() }))
225pub async fn reload(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
226 let manager = executor.sessions();
227 let record = manager.require_connected(session_id)?;
228 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
229 if payload.get("confirm") != Some(&Value::Bool(true)) {
230 return Err(CoreError::new("NGINX_RELOAD_CONFIRMATION_REQUIRED", "Reloading NGINX requires explicit confirmation."));
231 }
232 let output = run(executor, session_id, remembered(payload, operation("nginx.reload", vec![], vec![], password, true, Duration::from_secs(30)))).await?;
233 manager.logger.warn("Remote NGINX graceful reload completed.", json!({ "sessionId": record.session_id, "host": record.host }));
234 let (text, listening) = split_listening(&output.stdout);
235 // The check is advisory: the reload itself already happened, so a failed inventory must not turn it into an error.
236 let missing = match (listening, load_inventory(executor, session_id, payload).await) {
237 (Some(ports), Ok(inventory)) => missing_ports(&inventory.sites, &ports),
238 _ => Vec::new(),
239 };
240 Ok(json!({ "ok": true, "output": text, "missingPorts": missing, "reloadedAt": now() }))
243/// Helper text without the port markers, and the listening ports unless the server has no tool to list them.
244fn split_listening(stdout: &str) -> (String, Option<BTreeSet<u64>>) {
245 let mut ports = BTreeSet::new();
246 let mut known = true;
247 let mut text = String::new();
248 for line in stdout.lines() {
249 if let Some(port) = line.strip_prefix("\x1eCHJ_PORT=") {
250 ports.extend(port.trim().parse::<u64>().ok());
251 } else if line.starts_with("\x1eCHJ_PORTS=") {
252 known = false;
253 } else {
254 text.push_str(line);
255 text.push('\n');
256 }
257 }
258 (text.trim().to_string(), known.then_some(ports))
261/// TCP ports of enabled sites nobody listens on.
262fn missing_ports(sites: &[Value], listening: &BTreeSet<u64>) -> Vec<u64> {
263 let mut missing = BTreeSet::new();
264 for site in sites.iter().filter(|site| site["active"] == true) {
265 for port in site["tcpPorts"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
266 if !listening.contains(&port) {
267 missing.insert(port);
268 }
269 }
270 }
271 missing.into_iter().collect()
274// ---------------------------------------------------------------------------------------------
275// Sites: inventory from `nginx -T`, generated sites, activation, history, certificates, logs.
276// ---------------------------------------------------------------------------------------------
278struct SitesOutput {
279 dump_ok: bool,
280 test_output: String,
281 /// Included file as printed by `nginx -T`, with its text.
282 active: Vec<(String, String)>,
283 inactive: Vec<(String, String)>,
284 links: BTreeMap<String, String>,
285 php_sockets: Vec<String>,
286 /// Symlinks in sites-enabled whose target is gone, with the target they name.
287 broken_links: Vec<(String, String)>,
288 /// SELinux mode and httpd_can_network_connect, on systems that have SELinux.
289 selinux: Option<(String, String)>,
292fn parse_sites_output(stdout: &str) -> SitesOutput {
293 let (dump, rest) = stdout.split_once("\x1eCHJ_EXIT=").unwrap_or((stdout, "1\n"));
294 let (code, rest) = rest.split_once('\n').unwrap_or((rest, ""));
295 let dump_ok = code.trim() == "0";
296 let mut result = SitesOutput { dump_ok, test_output: String::new(), active: Vec::new(), inactive: Vec::new(), links: BTreeMap::new(), php_sockets: Vec::new(), broken_links: Vec::new(), selinux: None };
297 let mut header = String::new();
298 for line in dump.split_inclusive('\n') {
299 let trimmed = line.trim_end();
300 if let Some(path) = trimmed.strip_prefix("# configuration file ").and_then(|tail| tail.strip_suffix(':')) {
301 result.active.push((path.to_string(), String::new()));
302 } else if let Some((_, text)) = result.active.last_mut() {
303 text.push_str(line);
304 } else {
305 header.push_str(line);
306 }
307 }
308 result.test_output = if dump_ok { header.trim().to_string() } else { dump.trim().to_string() };
309 let mut current: Option<(String, String)> = None;
310 for line in rest.split_inclusive('\n') {
311 if let Some(marker) = line.strip_prefix('\x1e') {
312 if let Some(file) = current.take() {
313 result.inactive.push(file);
314 }
315 let marker = marker.trim_end();
316 if let Some(path) = marker.strip_prefix("CHJ_FILE=") {
317 current = Some((path.to_string(), String::new()));
318 } else if let Some((link, target)) = marker.strip_prefix("CHJ_LINK=").and_then(|value| value.split_once('|')) {
319 result.links.insert(link.to_string(), target.to_string());
320 } else if let Some((link, target)) = marker.strip_prefix("CHJ_BROKEN=").and_then(|value| value.split_once('|')) {
321 result.broken_links.push((link.to_string(), target.to_string()));
322 } else if let Some(socket) = marker.strip_prefix("CHJ_PHP=") {
323 result.php_sockets.push(format!("unix:{socket}"));
324 } else if let Some((mode, connect)) = marker.strip_prefix("CHJ_SELINUX=").and_then(|value| value.split_once('|')) {
325 result.selinux = Some((mode.to_string(), connect.to_string()));
326 }
327 } else if let Some((_, text)) = current.as_mut() {
328 text.push_str(line);
329 }
330 }
331 result
334struct Inventory {
335 value: Value,
336 sites: Vec<Value>,
337 pools: Vec<Value>,
338 maps: Vec<Value>,
339 active_paths: BTreeSet<String>,
340 includes: Vec<String>,
341 logs: BTreeSet<String>,
344/// Where the server differs from the model (a site lives in sites-available, a symlink turns it on), with the fix
345/// the module offers. Nothing here changes anything: every fix waits for the user's confirmation.
346fn proposals(output: &SitesOutput, shadowed: &[Value]) -> Vec<Value> {
347 let mut found: Vec<Value> = output.broken_links.iter().filter(|(link, _)| SITES_ENABLED.is_match(link)).map(|(link, target)| json!({ "kind": "broken-link", "path": link, "target": target })).collect();
348 for (path, _) in &output.active {
349 if SITES_ENABLED.is_match(path) && !output.links.contains_key(path) && normalize_config_path(path).is_ok() {
350 let older = shadowed.iter().find(|file| file["loaded"] == path.as_str());
351 found.push(json!({ "kind": "adopt", "path": path, "available": format!("/etc/nginx/sites-available/{}", path.rsplit('/').next().unwrap_or("")), "older": older.map(|file| &file["path"]), "identical": older.map(|file| &file["identical"]) }));
352 }
353 }
354 found
357fn build_inventory(output: &SitesOutput) -> Inventory {
358 let mut sources: Vec<(String, String)> = Vec::new();
359 let mut seen = BTreeSet::new();
360 for (path, text) in output.active.iter().chain(&output.inactive) {
361 if seen.insert(path.clone()) {
362 sources.push((path.clone(), text.clone()));
363 }
364 }
365 let active: BTreeSet<String> = output.active.iter().map(|(path, _)| path.clone()).collect();
366 let (set, errors) = conf::ConfigSet::new(&sources);
367 let mut inventory = Inventory { value: Value::Null, sites: Vec::new(), pools: Vec::new(), maps: set.maps(&active), active_paths: active.clone(), includes: Vec::new(), logs: BTreeSet::new() };
368 let mut shadowed = Vec::new();
369 for (path, text) in &sources {
370 let is_active = active.contains(path);
371 // sites-enabled may hold real files: the file of the same name in sites-available is then not a further site.
372 if !is_active {
373 // While a real file is turned off, its copy stands for the site and the plain name is only an older file.
374 if seen.contains(&format!("{path}{ENABLED_COPY_SUFFIX}")) {
375 continue;
376 }
377 let loaded = path.strip_prefix("/etc/nginx/sites-available/").map(|name| format!("/etc/nginx/sites-enabled/{name}")).filter(|twin| active.contains(twin));
378 if let Some(twin) = loaded {
379 let twin_text = output.active.iter().find(|(candidate, _)| *candidate == twin).map_or("", |(_, text)| text.as_str());
380 shadowed.push(json!({ "path": path, "loaded": twin, "identical": twin_text.trim() == text.trim() }));
381 continue;
382 }
383 }
384 let edit_path = output.links.get(path).cloned().unwrap_or_else(|| path.clone());
385 let link = output.links.contains_key(path).then(|| path.clone());
386 let writable = normalize_config_path(&edit_path).is_ok();
387 let toggle = if path.ends_with(ENABLED_COPY_SUFFIX) || (SITES_ENABLED.is_match(path) && link.is_none()) { "plain" } else if SITES_ENABLED.is_match(path) && SITES_AVAILABLE.is_match(&edit_path) || SITES_AVAILABLE.is_match(path) { "link" } else if FRAGMENT.is_match(path) { "rename" } else { "none" };
388 let (sites, pools) = set.file_inventory(path, &active);
389 for mut site in sites {
390 site["active"] = json!(is_active);
391 site["editPath"] = json!(edit_path);
392 site["linkPath"] = json!(link);
393 site["writable"] = json!(writable);
394 site["toggle"] = json!(toggle);
395 site["adoptable"] = json!(is_active && link.is_none() && SITES_ENABLED.is_match(path) && writable);
396 inventory.sites.push(site);
397 }
398 if is_active {
399 inventory.pools.extend(pools);
400 if let Ok(nodes) = conf::parse(text) {
401 inventory.includes.extend(conf::http_includes(&nodes));
402 conf::log_paths(&nodes, &mut inventory.logs);
403 }
404 }
405 }
406 conf::assign_roles(&mut inventory.sites, &inventory.maps);
407 let proposals = proposals(output, &shadowed);
408 let layout = conf::detect_layout(&inventory.includes);
409 // A loaded file outside the managed directories cannot be read back through the editor, so its text comes along.
410 let included: Vec<Value> = output.active.iter().map(|(path, text)| {
411 let managed = normalize_config_path(path).is_ok() || SECRET_FILE.is_match(path);
412 json!({ "path": path, "managed": managed, "size": text.len(), "text": if managed { Value::Null } else { json!(text) } })
413 }).collect();
414 let error_values: Vec<Value> = errors.iter().map(|(path, message)| json!({ "path": path, "message": message })).collect();
415 inventory.value = json!({
416 "dumpOk": output.dump_ok,
417 "testOutput": output.test_output,
418 "sites": inventory.sites,
419 "upstreams": inventory.pools,
420 "routes": conf::routes(&inventory.sites, &inventory.maps),
421 "conflicts": conf::conflicts(&inventory.sites),
422 "phpSockets": output.php_sockets,
423 "selinux": output.selinux.as_ref().map(|(mode, connect)| json!({ "mode": mode, "networkConnect": connect == "on" })),
424 "parseErrors": error_values,
425 "logs": inventory.logs,
426 "includedFiles": included,
427 "shadowedFiles": shadowed,
428 "proposals": proposals,
429 "layout": layout.map(|layout| json!({ "kind": layout.kind, "directory": layout.directory, "enabled": layout.enabled })),
430 "collectedAt": now()
431 });
432 inventory
435async fn load_inventory(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Inventory> {
436 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
437 let mut options = remembered(payload, operation("nginx.sites.read", vec![MAX_NGINX_CONFIG_BYTES.to_string()], vec![], password, true, Duration::from_secs(30)));
438 options.output_limit = MAX_SITES_OUTPUT;
439 let output = run(executor, session_id, options).await?;
440 if !output.stdout.contains("\x1eCHJ_DONE") {
441 return Err(CoreError::new("NGINX_HELPER_FAILED", "The NGINX helper stopped before finishing."));
442 }
443 Ok(build_inventory(&parse_sites_output(&output.stdout)))
446pub async fn sites(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
447 Ok(load_inventory(executor, session_id, payload).await?.value)
450async fn capabilities(executor: &PrivilegedExecutor, session_id: &str) -> CoreResult<(Capabilities, Value)> {
451 let state = inspect(executor, session_id).await?;
452 let version = conf::parse_version(state["version"].as_str().unwrap_or(""));
453 Ok((Capabilities { version, http3: state["capabilities"]["http3"] == true }, state))
456struct SitePlan {
457 request: conf::SiteRequest,
458 text: String,
459 path: String,
460 link: String,
461 warnings: Vec<Value>,
464async fn plan(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<SitePlan> {
465 let site = payload.get("site").unwrap_or(&Value::Null);
466 let request = conf::SiteRequest::from_value(site)?;
467 let inventory = load_inventory(executor, session_id, payload).await?;
468 if inventory.value["dumpOk"] != true {
469 return Err(CoreError::new("NGINX_CONFIG_TEST_FAILED", format!("The current NGINX configuration does not pass its test, so a new site cannot be checked. {}", inventory.value["testOutput"].as_str().unwrap_or(""))));
470 }
471 let layout = conf::detect_layout(&inventory.includes).ok_or_else(|| CoreError::new("NGINX_LAYOUT_UNSUPPORTED", "nginx.conf does not include sites-enabled, conf.d or http.d, so a new site would not be loaded. Add such an include in the configuration editor first."))?;
472 let (caps, state) = capabilities(executor, session_id).await?;
473 if request.http3 && !caps.http3 {
474 return Err(conf_invalid("This NGINX build has no HTTP/3 module."));
475 }
476 let name = request.file_name();
477 // Switched off until the user turns it on: no symlink yet, or the .chj-disabled name where there are no symlinks.
478 let enabled_name = format!("{}/{}{}", layout.directory, name, layout.suffix);
479 let path = if layout.enabled.is_some() { enabled_name.clone() } else { format!("{enabled_name}{DISABLED_SUFFIX}") };
480 normalize_config_path(&path)?;
481 let link = layout.enabled.map(|directory| format!("{directory}/{name}")).unwrap_or_default();
482 let taken = |candidate: &str| inventory.sites.iter().any(|site| site["path"] == candidate || site["editPath"] == candidate) || state["configs"].as_array().is_some_and(|configs| configs.iter().any(|config| config["path"] == candidate));
483 if taken(&path) || (!link.is_empty() && taken(&link)) || taken(&enabled_name) {
484 return Err(CoreError::new("NGINX_SITE_EXISTS", format!("{path} already exists; existing configuration is never overwritten.")));
485 }
486 let rendered = if request.kind == "custom" { conf::render_template(&request)? } else { conf::render_site(&request, caps) };
487 let ports: Vec<u16> = if request.kind == "custom" {
488 conf::file_inventory(&path, &rendered).map_err(|message| conf_invalid(&message))?.0.iter().flat_map(|site| site["ports"].as_array().cloned().unwrap_or_default()).filter_map(|port| port.as_u64().and_then(|port| u16::try_from(port).ok())).collect()
489 } else {
490 request.ports()
491 };
492 for existing in inventory.sites.iter().filter(|site| site["active"] == true && site["kind"] == "http") {
493 let same_port = existing["ports"].as_array().is_some_and(|list| list.iter().filter_map(Value::as_u64).any(|port| ports.iter().any(|wanted| u64::from(*wanted) == port)));
494 let same_name = existing["serverNames"].as_array().is_some_and(|list| list.iter().filter_map(Value::as_str).any(|name| request.names.iter().any(|wanted| wanted.eq_ignore_ascii_case(name))));
495 if same_port && same_name {
496 return Err(CoreError::new("NGINX_SITE_CONFLICT", format!("{} already serves one of these names on the same port.", existing["path"].as_str().unwrap_or(""))));
497 }
498 }
499 let mut warnings = Vec::new();
500 if request.https && request.certificate.starts_with("/etc/letsencrypt/") {
501 warnings.push(json!("certificate-from-letsencrypt"));
502 }
503 if request.http3 {
504 warnings.push(json!("http3-needs-udp"));
505 }
506 // CH-J reports SELinux policy and never changes it.
507 if inventory.value["selinux"]["mode"] == "Enforcing" {
508 let tcp_php = request.kind == "php" && !request.php.starts_with("unix:");
509 if (matches!(request.kind.as_str(), "proxy" | "websocket" | "loadbalance") || tcp_php) && inventory.value["selinux"]["networkConnect"] != true {
510 warnings.push(json!("selinux-network"));
511 }
512 if !request.root.is_empty() && !request.root.starts_with("/var/www/") && !request.root.starts_with("/usr/share/nginx/") {
513 warnings.push(json!("selinux-root"));
514 }
515 if request.ports().iter().any(|port| ![80, 81, 443, 488, 8008, 8009, 8443, 9000].contains(port)) {
516 warnings.push(json!("selinux-port"));
517 }
518 }
519 Ok(SitePlan { request, text: rendered, path, link, warnings })
522fn conf_invalid(message: &str) -> CoreError {
523 CoreError::new("NGINX_SITE_INVALID", message)
526/// Shows what a new site would write. Nothing changes on the server.
527pub async fn plan_site(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
528 let plan = plan(executor, session_id, payload).await?;
529 Ok(json!({ "path": plan.path, "link": plan.link, "text": plan.text, "warnings": plan.warnings, "createRoot": plan.request.create_root, "root": plan.request.root }))
532/// New file only, switched off, tested with `nginx -t`; a failed test removes everything this call made. Turning it on is a separate step.
533pub async fn create_site(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
534 require_confirm(payload, "Creating a site")?;
535 let manager = executor.sessions();
536 let record = manager.require_connected(session_id)?;
537 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
538 let plan = plan(executor, session_id, payload).await?;
539 let root = if plan.request.create_root { plan.request.root.clone() } else { String::new() };
540 let acme = if plan.request.acme { conf::ACME_ROOT.to_string() } else { String::new() };
541 let args = vec![plan.path.clone(), root, acme];
542 let output = run(executor, session_id, remembered(payload, operation("nginx.site.create", args, vec![Frame::text("DATA", &plan.text)], password, true, Duration::from_secs(30)))).await?;
543 manager.logger.warn("Remote NGINX site created after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": plan.path }));
544 Ok(json!({ "path": plan.path, "link": plan.link, "enabled": false, "testOutput": output.stdout.trim(), "createdAt": now() }))
547/// Which helper call turns a site on or off. Paths are checked against the managed directories only.
548fn toggle_arguments(path: &str, link: &str, enabled: bool) -> CoreResult<(&'static str, Vec<String>)> {
549 if link.is_empty() && !enabled && SITES_ENABLED.is_match(path) {
550 let name = path.rsplit('/').next().unwrap_or("");
551 return Ok(("nginx.site.plain-off", vec![path.to_string(), format!("/etc/nginx/sites-available/{name}{ENABLED_COPY_SUFFIX}")]));
552 }
553 if let Some(plain) = path.strip_suffix(ENABLED_COPY_SUFFIX).filter(|_| SITES_AVAILABLE.is_match(path)) {
554 if !enabled {
555 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "This site is already turned off."));
556 }
557 let name = plain.rsplit('/').next().unwrap_or("");
558 return Ok(("nginx.site.plain-on", vec![path.to_string(), format!("/etc/nginx/sites-enabled/{name}")]));
559 }
560 if SITES_AVAILABLE.is_match(path) || SITES_ENABLED.is_match(link) {
561 if !SITES_AVAILABLE.is_match(path) || (!link.is_empty() && !SITES_ENABLED.is_match(link)) {
562 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "The site is outside the managed directories."));
563 }
564 let name = path.rsplit('/').next().unwrap_or("");
565 let link = if link.is_empty() { format!("/etc/nginx/sites-enabled/{name}") } else { link.to_string() };
566 return Ok(if enabled { ("nginx.site.enable-link", vec![path.to_string(), link]) } else { ("nginx.site.disable-link", vec![link]) });
567 }
568 if FRAGMENT.is_match(path) {
569 let plain = path.strip_suffix(DISABLED_SUFFIX).unwrap_or(path);
570 if !plain.ends_with(".conf") {
571 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "Only .conf files in this directory can be turned on and off."));
572 }
573 let disabled = format!("{plain}{DISABLED_SUFFIX}");
574 return Ok(if enabled { ("nginx.site.rename", vec![disabled, plain.to_string()]) } else { ("nginx.site.rename", vec![plain.to_string(), disabled]) });
575 }
576 Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "This site cannot be turned on and off here. Change it in the configuration editor."))
579pub async fn set_site_enabled(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
580 require_confirm(payload, "Changing a site's state")?;
581 let manager = executor.sessions();
582 let record = manager.require_connected(session_id)?;
583 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
584 let path = payload.get("path").and_then(Value::as_str).unwrap_or("").trim();
585 let link = payload.get("linkPath").and_then(Value::as_str).unwrap_or("").trim();
586 let enabled = payload.get("enabled") == Some(&Value::Bool(true));
587 let (id, args) = toggle_arguments(path, link, enabled)?;
588 let output = run(executor, session_id, remembered(payload, operation(id, args, vec![], password, true, Duration::from_secs(30)))).await?;
589 manager.logger.warn("Remote NGINX site state changed after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "enabled": enabled }));
590 Ok(json!({ "enabled": enabled, "testOutput": output.stdout.trim(), "changedAt": now() }))
593/// Moves a real file from sites-enabled to sites-available and puts a symlink in its place, so the site can be
594/// turned off and on. An older file of the same name in sites-available is replaced, with a backup.
595pub async fn adopt_site(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
596 require_confirm(payload, "Converting a site")?;
597 let manager = executor.sessions();
598 let record = manager.require_connected(session_id)?;
599 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
600 let enabled = payload.get("path").and_then(Value::as_str).unwrap_or("").trim();
601 if !SITES_ENABLED.is_match(enabled) || normalize_config_path(enabled).is_err() {
602 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "Only a file in sites-enabled can be converted."));
603 }
604 let available = format!("/etc/nginx/sites-available/{}", enabled.rsplit('/').next().unwrap_or(""));
605 let output = run(executor, session_id, remembered(payload, operation("nginx.site.adopt", vec![enabled.to_string(), available.clone()], vec![], password, true, Duration::from_secs(30)))).await?;
606 let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();
607 let backup = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default();
608 manager.logger.warn("Remote NGINX site converted to a symlink after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": enabled }));
609 Ok(json!({ "path": available, "link": enabled, "backupPath": backup, "testOutput": marker.replace(&output.stdout, "").trim() }))
612/// Removes a symlink whose target no longer exists. The helper refuses any other link.
613pub async fn drop_broken_link(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
614 require_confirm(payload, "Removing a symlink")?;
615 let manager = executor.sessions();
616 let record = manager.require_connected(session_id)?;
617 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
618 let link = payload.get("path").and_then(Value::as_str).unwrap_or("").trim();
619 if !SITES_ENABLED.is_match(link) {
620 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "Only a symlink in sites-enabled can be removed."));
621 }
622 let output = run(executor, session_id, remembered(payload, operation("nginx.site.drop-broken-link", vec![link.to_string()], vec![], password, true, Duration::from_secs(30)))).await?;
623 manager.logger.warn("Remote NGINX symlink with a missing target removed after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": link }));
624 Ok(json!({ "path": link, "testOutput": output.stdout.trim() }))
627/// Result of a graphical edit, for the diff shown before saving. Saving goes through `save_config` with the hash.
628pub async fn plan_edit(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
629 let read = read_config(executor, session_id, payload).await?;
630 let before = read["text"].as_str().unwrap_or("");
631 let ordinal = payload.get("ordinal").and_then(Value::as_u64).unwrap_or(0) as usize;
632 let (caps, _) = capabilities(executor, session_id).await?;
633 let after = conf::apply_edit(before, ordinal, payload.get("changes").unwrap_or(&Value::Null), caps)?;
634 Ok(json!({ "path": read["path"], "before": before, "after": after, "sha256": read["sha256"] }))
637fn backup_of(path: &str, backup: &str) -> CoreResult<String> {
638 let encoded = format!("/var/backups/chj-nginx/{}.chj-backup-", path.replace('/', "%"));
639 let sidecar = format!("{path}.chj-backup-");
640 let safe = BACKUP.is_match(backup) && !backup.contains("..") && !backup.contains(['\n', '\r', '\0', ' ', ';', '$', '`']);
641 if !safe || !(backup.starts_with(&encoded) || backup.starts_with(&sidecar)) {
642 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "This backup does not belong to the selected file."));
643 }
644 Ok(backup.to_string())
647fn parse_backups(stdout: &str) -> Vec<Value> {
648 let mut items: Vec<Value> = stdout
649 .lines()
650 .filter_map(|line| line.strip_prefix("\x1eCHJ_BACKUP="))
651 .filter_map(|line| {
652 let mut fields = line.rsplitn(3, '|');
653 let (modified, size, path) = (fields.next()?, fields.next()?, fields.next()?);
654 Some(json!({ "path": path, "size": size.parse::<u64>().ok()?, "modified": modified.parse::<i64>().ok()? }))
655 })
656 .collect();
657 items.sort_by_key(|item| std::cmp::Reverse(item["modified"].as_i64()));
658 items
661pub async fn backups(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
662 let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
663 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
664 let output = run(executor, session_id, remembered(payload, operation("nginx.backups.list", vec![path.clone()], vec![], password, true, Duration::from_secs(15)))).await?;
665 Ok(json!({ "path": path, "backups": parse_backups(&output.stdout) }))
668pub async fn read_backup(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
669 let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
670 let backup = backup_of(&path, payload.get("backup").and_then(Value::as_str).unwrap_or(""))?;
671 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
672 let args = vec![backup.clone(), MAX_NGINX_CONFIG_BYTES.to_string()];
673 let output = run(executor, session_id, remembered(payload, operation("nginx.backup.read", args, vec![], password, true, Duration::from_secs(10)))).await?;
674 Ok(json!({ "path": path, "backup": backup, "text": output.stdout }))
677/// The current state is backed up too, so a restore can itself be undone.
678pub async fn restore_backup(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
679 require_confirm(payload, "Restoring a backup")?;
680 let manager = executor.sessions();
681 let record = manager.require_connected(session_id)?;
682 let path = normalize_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
683 let backup = backup_of(&path, payload.get("backup").and_then(Value::as_str).unwrap_or(""))?;
684 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
685 let args = vec![path.clone(), backup.clone(), expected_hash(payload)?];
686 let output = run(executor, session_id, remembered(payload, operation("nginx.backup.restore", args, vec![], password, true, Duration::from_secs(30)))).await?;
687 let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();
688 let previous = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default();
689 manager.logger.warn("Remote NGINX configuration restored from a backup after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "backupPath": backup }));
690 Ok(json!({ "path": path, "backupPath": previous, "testOutput": marker.replace(&output.stdout, "").trim(), "restoredAt": now() }))
693fn parse_certificates(stdout: &str, today: chrono::DateTime<chrono::Utc>) -> Value {
694 let mut certificates: Vec<Value> = Vec::new();
695 let (mut certbot, mut renew, mut installer) = (false, "unknown".to_string(), "none".to_string());
696 for line in stdout.lines() {
697 if let Some(rest) = line.strip_prefix("\x1eCHJ_CERT=") {
698 let fields: Vec<&str> = rest.splitn(5, '|').collect();
699 let ends = fields.get(2).copied().unwrap_or("").trim();
700 // A certificate in use is usually also among the certbot ones.
701 if certificates.iter().any(|known| known["path"] == fields.first().copied().unwrap_or("")) {
702 continue;
703 }
704 let parsed = chrono::NaiveDateTime::parse_from_str(ends.trim_end_matches(" GMT").trim(), "%b %e %H:%M:%S %Y").ok();
705 certificates.push(json!({
706 "path": fields.first().copied().unwrap_or(""),
707 "state": fields.get(1).copied().unwrap_or("unknown"),
708 "notAfter": parsed.map(|date| date.and_utc().to_rfc3339()),
709 "daysLeft": parsed.map(|date| (date.and_utc() - today).num_days()),
710 "subject": fields.get(3).copied().unwrap_or(""),
711 "issuer": fields.get(4).copied().unwrap_or(""),
712 }));
713 } else if let Some(value) = line.strip_prefix("\x1eCHJ_CERTBOT=") {
714 certbot = value.trim() == "1";
715 } else if let Some(value) = line.strip_prefix("\x1eCHJ_RENEW=") {
716 renew = value.trim().to_string();
717 } else if let Some(value) = line.strip_prefix("\x1eCHJ_INSTALLER=") {
718 installer = value.trim().to_string();
719 }
720 }
721 json!({ "certificates": certificates, "certbotInstalled": certbot, "certbotInstaller": installer, "renewal": renew })
724/// Only paths named by the server's own configuration are inspected, and only the public certificate is read.
725pub async fn certificates(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
726 let inventory = load_inventory(executor, session_id, payload).await?;
727 let mut paths = BTreeSet::new();
728 for site in &inventory.sites {
729 if let Some(path) = site["certificate"].as_str().filter(|path| conf::check_path(path).is_ok()) {
730 paths.insert(path.to_string());
731 }
732 }
733 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
734 let output = run(executor, session_id, remembered(payload, operation("nginx.certificates.read", paths.into_iter().take(128).collect(), vec![], password, true, Duration::from_secs(30)))).await?;
735 let mut result = parse_certificates(&output.stdout, chrono::Utc::now());
736 for certificate in result["certificates"].as_array_mut().into_iter().flatten() {
737 let used_by: Vec<&Value> = inventory.sites.iter().filter(|site| site["certificate"] == certificate["path"]).map(|site| &site["serverNames"]).collect();
738 certificate["usedBy"] = json!(used_by);
739 }
740 Ok(result)
743/// Splits the helper's report from the package manager's own output.
744pub(crate) fn certbot_install_result(stdout: &str) -> Value {
745 let mut renewal = "unknown";
746 let mut lines = Vec::new();
747 for line in stdout.lines() {
748 match line.strip_prefix("\x1eCHJ_RENEW=") {
749 Some(value) => renewal = value.trim(),
750 None => lines.push(line),
751 }
752 }
753 json!({ "output": lines.join("\n").trim(), "renewal": renewal })
756/// Installs certbot from the package manager of the server. The user asked for it, and agreed separately to any added repository.
757pub async fn install_certbot(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
758 require_confirm(payload, "Installing certbot")?;
759 let args = if payload.get("allowEpel") == Some(&Value::Bool(true)) { vec!["epel".to_string()] } else { vec![] };
760 let manager = executor.sessions();
761 let record = manager.require_connected(session_id)?;
762 let output = run(executor, session_id, remembered(payload, operation("nginx.certbot.install", args, vec![], normalize_sudo_password(payload.get("sudoPassword"))?, true, Duration::from_secs(900)))).await?;
763 manager.logger.warn("certbot installed on the remote server.", json!({ "sessionId": record.session_id, "host": record.host }));
764 Ok(certbot_install_result(&output.stdout))
767/// Starts certbot with the webroot method. The user has seen the Let's Encrypt terms and asked for this certificate.
768pub async fn issue_certificate(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
769 require_confirm(payload, "Requesting a certificate")?;
770 if payload.get("agreeTos") != Some(&Value::Bool(true)) {
771 return Err(CoreError::new("NGINX_CONFIRMATION_REQUIRED", "The certificate authority's terms must be accepted first."));
772 }
773 let email = payload.get("email").and_then(Value::as_str).unwrap_or("").trim();
774 if !email.is_empty() && !EMAIL.is_match(email) {
775 return Err(conf_invalid("The e-mail address for certificate notices is not valid."));
776 }
777 let domains: Vec<String> = payload.get("domains").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(conf::check_host).collect::<CoreResult<_>>()?;
778 if domains.is_empty() || domains.len() > 32 || domains.iter().any(|domain| domain.starts_with('*')) {
779 return Err(conf_invalid("Enter 1 to 32 domain names. The webroot method cannot issue wildcard certificates."));
780 }
781 let manager = executor.sessions();
782 let record = manager.require_connected(session_id)?;
783 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
784 let mut args = vec![email.to_string()];
785 args.extend(domains.iter().cloned());
786 let output = run(executor, session_id, remembered(payload, operation("nginx.certbot.issue", args, vec![], password, true, Duration::from_secs(300)))).await?;
787 manager.logger.warn("Remote certificate requested with certbot.", json!({ "sessionId": record.session_id, "host": record.host, "domains": domains }));
788 let live = format!("/etc/letsencrypt/live/{}", domains[0]);
789 Ok(json!({ "output": output.stdout.trim(), "certificate": format!("{live}/fullchain.pem"), "certificateKey": format!("{live}/privkey.pem") }))
792/// The files a route change touches, before and after. Each file is read from the server, not from `nginx -T`, so that
793/// its hash and its exact bytes are what gets compared and saved.
794async fn route_plan(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<(Vec<Value>, Vec<Value>)> {
795 let inventory = load_inventory(executor, session_id, payload).await?;
796 if inventory.value["dumpOk"] != true {
797 return Err(CoreError::new("NGINX_CONFIG_TEST_FAILED", format!("The current NGINX configuration does not pass its test. {}", inventory.value["testOutput"].as_str().unwrap_or(""))));
798 }
799 let (actions, mut warnings) = conf::route_actions(&inventory.sites, &inventory.maps, &inventory.pools, payload.get("change").unwrap_or(&Value::Null))?;
800 let mut files: Vec<Value> = Vec::new();
801 let auth = json!({ "sudoPassword": payload.get("sudoPassword"), "rememberSudo": payload.get("rememberSudo") });
802 for action in &actions {
803 normalize_config_path(&action.file)?;
804 if !inventory.active_paths.contains(&action.file) {
805 return Err(CoreError::new("NGINX_CONFIG_CHANGED", "The file is no longer loaded; reload the list."));
806 }
807 let position = files.iter().position(|file| file["path"] == action.file.as_str());
808 let index = match position {
809 Some(index) => index,
810 None => {
811 let mut request = auth.clone();
812 request["path"] = json!(action.file);
813 let read = read_config(executor, session_id, &request).await?;
814 files.push(json!({ "path": action.file, "before": read["text"], "after": read["text"], "sha256": read["sha256"] }));
815 files.len() - 1
816 }
817 };
818 let after = conf::apply_route_action(files[index]["after"].as_str().unwrap_or(""), action)?;
819 files[index]["after"] = json!(after);
820 }
821 // Refusing an unreachable backend would block preparing a route before its server is up, so this only warns.
822 let targets: Vec<String> = actions.iter().filter_map(|action| action.value.clone()).filter(|value| !inventory.pools.iter().any(|pool| pool["name"] == value.as_str())).collect();
823 if !targets.is_empty() {
824 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
825 if let Ok(output) = run(executor, session_id, remembered(payload, operation("nginx.backends.probe", targets, vec![], password, true, Duration::from_secs(30)))).await {
826 for (target, _) in output.stdout.lines().filter_map(|line| line.strip_prefix("\x1eCHJ_PROBE=")).filter_map(|line| line.rsplit_once('|')).filter(|(_, state)| *state == "closed") {
827 warnings.push(json!({ "code": "backend-closed", "backend": target }));
828 }
829 }
830 }
831 Ok((files, warnings))
834/// Shows a route change as a diff. Nothing changes on the server.
835pub async fn plan_route(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
836 let (files, warnings) = route_plan(executor, session_id, payload).await?;
837 Ok(json!({ "files": files, "warnings": warnings }))
840/// One change over all touched files: every file or none, then `nginx -t`. The text is computed again here and must
841/// start from the same files the user saw; the reload stays a separate step.
842pub async fn apply_route(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
843 require_confirm(payload, "Changing a route")?;
844 let manager = executor.sessions();
845 let record = manager.require_connected(session_id)?;
846 let (files, _) = route_plan(executor, session_id, payload).await?;
847 if files.is_empty() {
848 return Err(CoreError::new("NGINX_EDIT_UNSUPPORTED", "There is nothing to change."));
849 }
850 let seen = payload.get("expected").and_then(Value::as_object).ok_or_else(|| CoreError::new("NGINX_CONFIG_INVALID", "The files that were shown are missing."))?;
851 let mut args = Vec::new();
852 let mut frames = Vec::new();
853 for file in &files {
854 let path = file["path"].as_str().unwrap_or("");
855 if seen.get(path) != Some(&file["sha256"]) {
856 return Err(CoreError::new("NGINX_CONFIG_CHANGED", format!("{path} changed after the preview; nothing was written.")));
857 }
858 args.push(path.to_string());
859 args.push(file["sha256"].as_str().unwrap_or("").to_string());
860 frames.push(Frame::text("DATA", file["after"].as_str().unwrap_or("")));
861 }
862 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
863 let output = run(executor, session_id, remembered(payload, operation("nginx.config.save-set", args, frames, password, true, Duration::from_secs(60)))).await?;
864 let mut saved = Vec::new();
865 let mut text = String::new();
866 for line in output.stdout.lines() {
867 match line.strip_prefix("\x1eCHJ_SAVED=").and_then(|value| value.split_once('|')) {
868 Some((path, backup)) => {
869 let sha = files.iter().find(|file| file["path"] == path).map(|file| sha256(file["after"].as_str().unwrap_or("")));
870 saved.push(json!({ "path": path, "backupPath": backup, "sha256": sha }));
871 }
872 None => {
873 text.push_str(line);
874 text.push('\n');
875 }
876 }
877 }
878 manager.logger.warn("Remote NGINX routes changed after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "files": saved }));
879 Ok(json!({ "saved": saved, "testOutput": text.trim() }))
882/// Which rule decides about a domain, where it is written, and whether its backend answers from this server.
883/// An open TCP port says nothing about the application behind it.
884pub async fn diagnose_domain(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
885 let host = conf::check_host(payload.get("host").and_then(Value::as_str).unwrap_or("").trim())?;
886 let inventory = load_inventory(executor, session_id, payload).await?;
887 let mut rules = conf::domain_rules(&host, &inventory.sites, &inventory.maps);
888 let site = conf::domain_site(&host, &inventory.sites).cloned();
889 let mut targets: Vec<String> = Vec::new();
890 for rule in &mut rules {
891 let backend = rule["backend"].as_str().unwrap_or("");
892 let pool = inventory.pools.iter().find(|pool| pool["name"] == backend);
893 let members: Vec<String> = match pool {
894 Some(pool) => pool["servers"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(),
895 None if backend.is_empty() => Vec::new(),
896 None => vec![backend.to_string()],
897 };
898 targets.extend(members.iter().filter(|member| conf::check_backend(member).is_ok()).cloned());
899 rule["members"] = json!(members);
900 }
901 if let Some(site) = &site {
902 for item in conf::backend_targets(std::slice::from_ref(site), &inventory.pools, &inventory.maps) {
903 if let Some(target) = item["target"].as_str().filter(|target| !targets.iter().any(|known| known == target)) {
904 targets.push(target.to_string());
905 }
906 }
907 }
908 targets.sort();
909 targets.dedup();
910 targets.truncate(MAX_PROBES);
911 let mut states = BTreeMap::new();
912 if !targets.is_empty() {
913 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
914 let output = run(executor, session_id, remembered(payload, operation("nginx.backends.probe", targets.clone(), vec![], password, true, Duration::from_secs(120)))).await?;
915 for (target, state) in output.stdout.lines().filter_map(|line| line.strip_prefix("\x1eCHJ_PROBE=")).filter_map(|line| line.rsplit_once('|')) {
916 states.insert(target.to_string(), state.to_string());
917 }
918 }
919 Ok(json!({ "host": host, "rules": rules, "site": site, "targets": targets, "states": states }))
922fn valid_log_path(path: &str) -> bool {
923 LOG_PATH.is_match(path) && !path.contains("..") && !path.contains("//")
926/// Logs under /var/log are readable directly; anywhere else the server's configuration must name the file.
927pub async fn read_log(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
928 let path = payload.get("path").and_then(Value::as_str).unwrap_or("").trim().to_string();
929 if !valid_log_path(&path) {
930 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "This log path is not valid."));
931 }
932 if !path.starts_with("/var/log/") {
933 let inventory = load_inventory(executor, session_id, payload).await?;
934 if !inventory.logs.contains(&path) {
935 return Err(CoreError::new("NGINX_CONFIG_PATH_INVALID", "The NGINX configuration does not name this log file."));
936 }
937 }
938 let lines = payload.get("lines").and_then(Value::as_u64).unwrap_or(200).clamp(1, MAX_LOG_LINES);
939 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
940 let output = run(executor, session_id, remembered(payload, operation("nginx.log.read", vec![path.clone(), lines.to_string()], vec![], password, true, Duration::from_secs(15)))).await?;
941 Ok(json!({ "path": path, "text": output.stdout, "readAt": now() }))
944/// Connects from the server itself, to the addresses its configuration names.
945pub async fn probe_backends(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
946 let inventory = load_inventory(executor, session_id, payload).await?;
947 let targets = conf::backend_targets(&inventory.sites, &inventory.pools, &inventory.maps);
948 let shown: Vec<Value> = targets.iter().take(MAX_PROBES).cloned().collect();
949 let args: Vec<String> = shown.iter().filter_map(|target| target["target"].as_str().map(str::to_string)).collect();
950 if args.is_empty() {
951 return Ok(json!({ "backends": [], "omitted": 0 }));
952 }
953 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
954 let output = run(executor, session_id, remembered(payload, operation("nginx.backends.probe", args, vec![], password, true, Duration::from_secs(150)))).await?;
955 let states: BTreeMap<&str, &str> = output.stdout.lines().filter_map(|line| line.strip_prefix("\x1eCHJ_PROBE=")).filter_map(|line| line.rsplit_once('|')).collect();
956 let backends: Vec<Value> = shown.iter().map(|target| json!({ "target": target["target"], "sites": target["sites"], "state": target["target"].as_str().and_then(|name| states.get(name)).copied().unwrap_or("unknown") })).collect();
957 Ok(json!({ "backends": backends, "omitted": targets.len().saturating_sub(MAX_PROBES) }))
960#[cfg(test)]
961mod tests {
962 use super::*;
964 #[test]
965 fn helpers_find_nginx_in_sbin() {
966 for name in ["nginx.sh", "nginx_inspect.sh"] {
967 assert!(crate::core::matrix::script(name).contains("PATH=/usr/sbin:/usr/local/sbin:/sbin:$PATH"), "{name}");
968 }
969 }
971 #[test]
972 fn path_allowlist() {
973 assert!(normalize_config_path("/etc/nginx/sites-available/default").is_ok());
974 assert!(normalize_config_path("/etc/nginx/../passwd").is_err());
975 assert!(normalize_config_path("/etc/nginx/conf.d/a.conf.chj-backup-1").is_err());
976 assert!(normalize_config_path("/etc/shadow").is_err());
977 assert!(normalize_config_path("/etc/nginx/proxy-routes/http.map").is_ok());
978 assert!(normalize_config_path("/etc/nginx/stream-proxy.conf").is_ok());
979 for secret in ["/etc/nginx/ssl/site.key", "/etc/nginx/certs/a.pem", "/etc/nginx/.htpasswd", "/etc/nginx/htpasswd", "/etc/nginx/auth/users.passwd", "/etc/nginx/._hidden"] {
980 assert!(normalize_config_path(secret).is_err(), "{secret}");
981 }
982 }
984 #[test]
985 fn inspection_parsing() {
986 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");
987 assert_eq!(parsed["installed"], true);
988 assert_eq!(parsed["configs"].as_array().unwrap().len(), 2);
989 assert_eq!(parsed["configs"][0]["path"], "/etc/nginx/nginx.conf");
990 assert_eq!(parsed["configs"][1]["writable"], false);
991 }
993 #[test]
994 fn symlink_edit_target_comes_from_the_link() {
995 let parsed = parse_inspection_output("version=nginx/1.24.0\nconfig=symlink|/etc/nginx/sites-enabled/default|/etc/nginx/sites-available/default\nconfig=symlink|/etc/nginx/modules-enabled/50-mod-stream.conf|/usr/share/nginx/modules-available/mod-stream.conf\n");
996 let configs = parsed["configs"].as_array().unwrap();
997 assert_eq!(configs[0]["target"], "/usr/share/nginx/modules-available/mod-stream.conf");
998 assert_eq!(configs[0]["editablePath"], Value::Null);
999 assert_eq!(configs[1]["editablePath"], "/etc/nginx/sites-available/default");
1002 #[test]
1003 fn control_api_is_reported_never_enabled() {
1004 let old = parse_inspection_output("version=nginx/1.24.0 (Ubuntu)\nmodule=http_v2_module\n");
1005 assert_eq!(old["capabilities"]["controlApi"]["versionSupported"], false);
1006 assert_eq!(old["capabilities"]["http2"], true);
1007 let new = parse_inspection_output("version=nginx/1.31.5\nmodule=control_api\nmodule=http_v3_module\n");
1008 assert_eq!(new["capabilities"]["controlApi"], json!({ "versionSupported": true, "buildFlag": true, "enabled": false }));
1009 assert_eq!(new["capabilities"]["http3"], true);
1012 const SITES: &str = "nginx: the configuration file /etc/nginx/nginx.conf syntax is ok\n# configuration file /etc/nginx/nginx.conf:\nevents {}\nhttp { include /etc/nginx/sites-enabled/*; access_log /var/log/nginx/access.log; }\n\n# configuration file /etc/nginx/sites-enabled/a:\nserver { listen 80; server_name a.example; root /var/www/a; }\n\n# configuration file /srv/custom.conf:\nserver { broken\n\x1eCHJ_EXIT=0\n\x1eCHJ_LINK=/etc/nginx/sites-enabled/a|/etc/nginx/sites-available/a\n\x1eCHJ_FILE=/etc/nginx/sites-available/b\nserver { listen 80; server_name a.example; }\n\n\x1eCHJ_END\n\x1eCHJ_PHP=/run/php/x.sock\n\x1eCHJ_SELINUX=Enforcing|off\n\x1eCHJ_DONE\n";
1014 #[test]
1015 fn inventory_marks_state_and_edit_path() {
1016 let inventory = build_inventory(&parse_sites_output(SITES));
1017 let value = &inventory.value;
1018 assert_eq!(value["dumpOk"], true);
1019 assert_eq!(value["layout"]["kind"], "sites");
1020 assert_eq!(value["phpSockets"][0], "unix:/run/php/x.sock");
1021 assert_eq!(value["selinux"], json!({ "mode": "Enforcing", "networkConnect": false }));
1022 assert_eq!(value["parseErrors"][0]["path"], "/srv/custom.conf");
1023 assert_eq!(value["logs"][0], "/var/log/nginx/access.log");
1024 let sites = value["sites"].as_array().unwrap();
1025 assert_eq!(sites.len(), 2);
1026 assert_eq!(sites[0]["active"], true);
1027 assert_eq!(sites[0]["editPath"], "/etc/nginx/sites-available/a");
1028 assert_eq!(sites[0]["linkPath"], "/etc/nginx/sites-enabled/a");
1029 assert_eq!(sites[0]["toggle"], "link");
1030 assert_eq!(sites[1]["active"], false);
1031 // A disabled site with the same name is not a conflict.
1032 assert_eq!(value["conflicts"].as_array().unwrap().len(), 0);
1035 /// sites-enabled holding real files: the older copies in sites-available are not further sites.
1036 #[test]
1037 fn real_files_in_sites_enabled_are_not_doubled() {
1038 let output = "# configuration file /etc/nginx/nginx.conf:\nhttp { include /etc/nginx/sites-enabled/*; }\n\n# configuration file /etc/nginx/sites-enabled/a.conf:\nserver { listen 80; server_name a.test; root /var/www/a; }\n\n# configuration file /opt/app/extra.conf:\n# outside\n\x1eCHJ_EXIT=0\n\x1eCHJ_FILE=/etc/nginx/sites-available/a.conf\nserver { listen 80; server_name a.test; root /var/www/old; }\n\n\x1eCHJ_FILE=/etc/nginx/sites-available/spare\nserver { listen 80; server_name spare.test; root /var/www/s; }\n\n\x1eCHJ_END\n\x1eCHJ_DONE\n";
1039 let value = build_inventory(&parse_sites_output(output)).value;
1040 let names: Vec<(&str, bool)> = value["sites"].as_array().unwrap().iter().map(|site| (site["serverNames"][0].as_str().unwrap(), site["active"].as_bool().unwrap())).collect();
1041 assert_eq!(names, [("a.test", true), ("spare.test", false)]);
1042 assert_eq!(value["sites"][0]["adoptable"], true);
1043 assert_eq!(value["proposals"], json!([{ "kind": "adopt", "path": "/etc/nginx/sites-enabled/a.conf", "available": "/etc/nginx/sites-available/a.conf", "older": "/etc/nginx/sites-available/a.conf", "identical": false }]));
1044 assert_eq!(value["sites"][1]["adoptable"], false);
1045 assert_eq!(value["sites"][0]["toggle"], "plain", "a real file can be turned off without converting it");
1046 assert_eq!(value["shadowedFiles"][0], json!({ "path": "/etc/nginx/sites-available/a.conf", "loaded": "/etc/nginx/sites-enabled/a.conf", "identical": false }));
1047 let outside = value["includedFiles"].as_array().unwrap().iter().find(|file| file["path"] == "/opt/app/extra.conf").unwrap();
1048 assert_eq!(outside["managed"], false);
1049 assert!(outside["text"].as_str().unwrap().contains("# outside"));
1050 assert_eq!(value["includedFiles"][0]["text"], Value::Null);
1053 #[test]
1054 fn a_broken_symlink_is_proposed_for_removal_and_a_good_one_is_not() {
1055 let value = build_inventory(&parse_sites_output("nginx: [emerg] open() failed\n\x1eCHJ_EXIT=1\n\x1eCHJ_LINK=/etc/nginx/sites-enabled/old|/etc/nginx/sites-available/old\n\x1eCHJ_BROKEN=/etc/nginx/sites-enabled/old|/etc/nginx/sites-available/old\n\x1eCHJ_LINK=/etc/nginx/sites-enabled/ok|/etc/nginx/sites-available/ok\n\x1eCHJ_END\n\x1eCHJ_DONE\n")).value;
1056 assert_eq!(value["proposals"], json!([{ "kind": "broken-link", "path": "/etc/nginx/sites-enabled/old", "target": "/etc/nginx/sites-available/old" }]));
1059 #[test]
1060 fn broken_configuration_is_reported_not_hidden() {
1061 let parsed = parse_sites_output("nginx: [emerg] unexpected end of file\nnginx: configuration file /etc/nginx/nginx.conf test failed\n\x1eCHJ_EXIT=1\n\x1eCHJ_END\n\x1eCHJ_DONE\n");
1062 assert!(!parsed.dump_ok);
1063 assert!(parsed.test_output.contains("emerg"));
1064 assert!(parsed.active.is_empty());
1067 #[test]
1068 fn toggling_stays_inside_managed_directories() {
1069 let (id, args) = toggle_arguments("/etc/nginx/sites-enabled/real.conf", "", false).unwrap();
1070 assert_eq!((id, args[1].as_str()), ("nginx.site.plain-off", "/etc/nginx/sites-available/real.conf.chj-enabled-copy"));
1071 let (id, args) = toggle_arguments("/etc/nginx/sites-available/real.conf.chj-enabled-copy", "", true).unwrap();
1072 assert_eq!((id, args[1].as_str()), ("nginx.site.plain-on", "/etc/nginx/sites-enabled/real.conf"));
1073 let (id, args) = toggle_arguments("/etc/nginx/sites-available/a", "", true).unwrap();
1074 assert_eq!((id, args[1].as_str()), ("nginx.site.enable-link", "/etc/nginx/sites-enabled/a"));
1075 let (id, args) = toggle_arguments("/etc/nginx/sites-available/a", "/etc/nginx/sites-enabled/b", false).unwrap();
1076 assert_eq!((id, args), ("nginx.site.disable-link", vec!["/etc/nginx/sites-enabled/b".to_string()]));
1077 let (_, args) = toggle_arguments("/etc/nginx/conf.d/a.conf", "", false).unwrap();
1078 assert_eq!(args[1], "/etc/nginx/conf.d/a.conf.chj-disabled");
1079 let (_, args) = toggle_arguments("/etc/nginx/http.d/a.conf.chj-disabled", "", true).unwrap();
1080 assert_eq!(args[1], "/etc/nginx/http.d/a.conf");
1081 for (path, link) in [("/etc/passwd", ""), ("/etc/nginx/nginx.conf", ""), ("/etc/nginx/conf.d/../../x.conf", ""), ("/etc/nginx/sites-available/a", "/tmp/x"), ("/etc/nginx/conf.d/a.txt", ""), ("/tmp/a", "/etc/nginx/sites-enabled/a")] {
1082 assert!(toggle_arguments(path, link, true).is_err(), "{path} {link}");
1086 #[test]
1087 fn backups_must_belong_to_the_file() {
1088 let path = "/etc/nginx/conf.d/a.conf";
1089 assert!(backup_of(path, "/var/backups/chj-nginx/%etc%nginx%conf.d%a.conf.chj-backup-20261101T120000").is_ok());
1090 assert!(backup_of(path, "/etc/nginx/conf.d/a.conf.chj-backup-20261101T120000-2").is_ok());
1091 for bad in ["/var/backups/chj-nginx/%etc%shadow.chj-backup-20261101T120000", "/etc/shadow", "/etc/nginx/conf.d/a.conf.chj-backup-20261101T120000;id", "/var/backups/chj-nginx/%etc%nginx%conf.d%a.conf.chj-backup-1/../../../shadow"] {
1092 assert!(backup_of(path, bad).is_err(), "{bad}");
1094 let items = parse_backups("\x1eCHJ_BACKUP=/a.chj-backup-1|10|100\n\x1eCHJ_BACKUP=/a.chj-backup-2|20|200\n\x1eCHJ_DONE\n");
1095 assert_eq!(items[0]["modified"], 200);
1098 #[test]
1099 fn certificate_expiry_is_counted_in_days() {
1100 let today = chrono::DateTime::parse_from_rfc3339("2026-11-01T00:00:00Z").unwrap().with_timezone(&chrono::Utc);
1101 let parsed = parse_certificates("\x1eCHJ_CERT=/c.pem|ok|Dec 1 00:00:00 2026 GMT|CN=a|CN=R3\n\x1eCHJ_CERT=/m.pem|missing\n\x1eCHJ_CERT=/c.pem|ok|Dec 1 00:00:00 2026 GMT|CN=a|CN=R3\n\x1eCHJ_CERTBOT=1\n\x1eCHJ_RENEW=enabled\n\x1eCHJ_DONE\n", today);
1102 assert_eq!(parsed["certificates"][0]["daysLeft"], 30);
1103 assert_eq!(parsed["certificates"][0]["issuer"], "CN=R3");
1104 assert_eq!(parsed["certificates"][1]["state"], "missing");
1105 assert_eq!(parsed["certificates"].as_array().unwrap().len(), 2);
1106 assert_eq!(parsed["certbotInstalled"], true);
1107 assert_eq!(parsed["renewal"], "enabled");
1108 assert_eq!(certbot_install_result("Installing\n\x1eCHJ_RENEW=enabled\n"), json!({ "output": "Installing", "renewal": "enabled" }));
1111 #[test]
1112 fn reload_reports_ports_nobody_listens_on() {
1113 let (text, ports) = split_listening("syntax is ok\nreloaded\n\x1eCHJ_PORT=80\n\x1eCHJ_PORT=443\n");
1114 assert_eq!(text, "syntax is ok\nreloaded");
1115 let ports = ports.unwrap();
1116 let sites = vec![json!({ "active": true, "tcpPorts": [80, 8181] }), json!({ "active": false, "tcpPorts": [9999] }), json!({ "active": true, "tcpPorts": [] })];
1117 assert_eq!(missing_ports(&sites, &ports), vec![8181]);
1118 assert!(split_listening("x\n\x1eCHJ_PORTS=unknown\n").1.is_none());
1121 #[test]
1122 fn log_paths_are_plain_absolute_paths() {
1123 assert!(valid_log_path("/var/log/nginx/access.log"));
1124 for bad in ["/var/log/../shadow", "relative.log", "/var/log/a;b", "/var//log/a"] {
1125 assert!(!valid_log_path(bad), "{bad}");
SHA-256cb49d198e5b0312efabd6936832ebee5135b05ab6b30b051710dbaa95245a78a

CH-J Proprietary Software License 1.14 · Zdroj sa poskytuje v rámci CH-J Proprietary Software License 1.14. Jeho dostupnosť nemení licenčné podmienky ani neposkytuje dodatočné práva.

Archív SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Súbory