CH-J Toolsġestjoni tas-server fuq SSH
Menu
Sors ippubblikat

CH-J Tools

direttorji browse u fajls għal rilaxx applikazzjoni speċifika.

Sors ta' tniżżil ZIP
CH-J / Server Manager 1704 fajls
0.1.2-beta Miżjud: · Buillt:
apache.rs Miżjud: Data mhux irreġistrata 56.7 KB · 887 linji
Kodiċi Fajl tat-tniżżil
1//! Apache Manager backend. The commands live in the matrix (`apache.sh`, `apache_inspect.sh`). Every change is a new file
2//! or a checked replacement: backup, configuration test, restore on failure. A reload is always a separate step.
4use crate::core::error::{CoreError, CoreResult};
5use crate::core::ssh::apache_conf as conf;
6use crate::core::ssh::exec::MAX_COMMAND_OUTPUT;
7use crate::core::ssh::nginx::normalize_sudo_password;
8use crate::core::ssh::platform::detect_platform;
9use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions, RunResult};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14use std::collections::{BTreeMap, BTreeSet};
15use std::time::Duration;
17/// A server with hundreds of rules has far more text than one file.
18const MAX_INVENTORY_OUTPUT: usize = 32 * 1024 * 1024;
19const MAX_LOG_LINES: u64 = 2000;
20const MAX_PROBES: usize = 32;
21const MAX_PATH_ARGS: usize = 256;
22const MAX_CONFIG_BYTES: usize = 1024 * 1024;
23const DISABLED_SUFFIX: &str = ".chj-disabled";
24/// A real file from sites-enabled while it is turned off; the plain name in sites-available may hold something else.
25const ENABLED_COPY_SUFFIX: &str = ".chj-enabled-copy";
26static SHA: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9a-f]{64}$").unwrap());
27static CONFIG_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+@-]{1,511}$").unwrap());
28static SECRET_FILE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(\.(key|pem|crt|cer|csr|p12|pfx|der|so|gz)|htpasswd[^/]*|\.passwd)$").unwrap());
29static BACKUP: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/var/backups/chj-apache/%[A-Za-z0-9._%+@-]+\.chj-backup-[0-9]{8}T[0-9]{6}(-[0-9]{1,4})?$").unwrap());
30static EMAIL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._+-]{1,64}@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$").unwrap());
31static MODULE_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9_-]{0,63}$").unwrap());
32static SAFE_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+@-]{1,255}$").unwrap());
33static BACKEND: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(unix:/[A-Za-z0-9._/-]{1,255}|([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\]):[0-9]{1,5})$").unwrap());
35fn now() -> String {
36 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
39fn operation(id: &str, args: Vec<String>, payload: &Value, timeout: Duration, limit: usize) -> CoreResult<RunOptions> {
40 let password = normalize_sudo_password(payload.get("sudoPassword"))?;
41 Ok(RunOptions {
42 operation: id.into(),
43 args,
44 privileged: true,
45 sudo_password: Some(password).filter(|password| !password.is_empty()),
46 use_cached_password: true,
47 remember_password: payload.get("rememberSudo") == Some(&Value::Bool(true)),
48 purpose: id.into(),
49 error_prefix: "APACHE_".into(),
50 timeout,
51 output_limit: limit,
52 ..Default::default()
53 })
56async fn run(executor: &PrivilegedExecutor, session_id: &str, options: RunOptions) -> CoreResult<RunResult> {
57 let result = executor.run(session_id, options).await?;
58 if result.code != Some(0) {
59 return Err(CoreError::new("APACHE_HELPER_FAILED", "The Apache helper stopped before finishing.").with("remoteExitCode", result.code.map(Value::from).unwrap_or(Value::Null)));
60 }
61 Ok(result)
64fn parse_inspection(stdout: &str) -> Value {
65 let mut result = json!({ "installed": false, "control": "", "binary": "", "version": "", "mpm": "", "service": "", "serviceState": "unknown" });
66 for line in stdout.lines() {
67 let Some((key, value)) = line.split_once('=') else { continue };
68 let value: String = value.chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect::<String>().trim().chars().take(256).collect();
69 match key {
70 "installed" => result["installed"] = json!(value == "1"),
71 "control" | "binary" | "version" | "mpm" | "service" | "serviceState" => result[key] = json!(value),
72 _ => {}
73 }
74 }
75 result
78pub async fn inspect(executor: &PrivilegedExecutor, session_id: &str) -> CoreResult<Value> {
79 let manager = executor.sessions();
80 let record = manager.require_connected(session_id)?;
81 let platform = detect_platform(manager, &record, false).await?;
82 if platform["os"] == "windows" {
83 return Err(CoreError::new("FEATURE_PLATFORM_UNSUPPORTED", "Apache management is available only on Linux/POSIX servers."));
84 }
85 let output = manager.exec_operation(&record, "apache.inspect", &[], Duration::from_secs(15), MAX_COMMAND_OUTPUT).await?;
86 let mut result = parse_inspection(&output.stdout);
87 result["sessionId"] = json!(record.session_id);
88 result["collectedAt"] = json!(now());
89 Ok(result)
92#[derive(Default)]
93struct RawInventory {
94 sections: BTreeMap<String, String>,
95 test_ok: bool,
96 files: Vec<(String, String, bool)>,
97 links: BTreeMap<String, String>,
98 skipped: Vec<(String, u64)>,
99 broken: Vec<(String, String)>,
100 main: String,
101 available_modules: Vec<String>,
102 php_sockets: Vec<String>,
103 processes: u64,
106/// Splits the helper's output. Markers start with a control character no configuration text starts a line with.
107fn parse_raw(stdout: &str) -> RawInventory {
108 let mut raw = RawInventory::default();
109 let mut section = String::new();
110 let mut file: Option<(String, String, bool)> = None;
111 for line in stdout.split_inclusive('\n') {
112 let Some(marker) = line.strip_prefix('\x1e') else {
113 match file.as_mut() {
114 Some((_, text, _)) => text.push_str(line),
115 None => raw.sections.entry(section.clone()).or_default().push_str(line),
116 }
117 continue;
118 };
119 raw.files.extend(file.take());
120 let marker = marker.trim_end();
121 if let Some(name) = marker.strip_prefix("CHJ_SECTION=") {
122 section = name.to_string();
123 } else if let Some(code) = marker.strip_prefix("CHJ_EXIT=") {
124 raw.test_ok = code == "0";
125 } else if let Some((path, loaded)) = marker.strip_prefix("CHJ_FILE=").and_then(|value| value.rsplit_once('|')) {
126 file = Some((path.to_string(), String::new(), loaded == "1"));
127 } else if let Some((path, target)) = marker.strip_prefix("CHJ_LINK=").and_then(|value| value.split_once('|')) {
128 raw.links.insert(path.to_string(), target.to_string());
129 } else if let Some((path, size)) = marker.strip_prefix("CHJ_SKIPPED=").and_then(|value| value.rsplit_once('|')) {
130 raw.skipped.push((path.to_string(), size.parse().unwrap_or(0)));
131 } else if let Some((path, target)) = marker.strip_prefix("CHJ_BROKEN=").and_then(|value| value.split_once('|')) {
132 raw.broken.push((path.to_string(), target.to_string()));
133 } else if let Some(path) = marker.strip_prefix("CHJ_MAIN=") {
134 raw.main = path.to_string();
135 } else if let Some(name) = marker.strip_prefix("CHJ_MODAVAIL=") {
136 raw.available_modules.push(name.to_string());
137 } else if let Some(socket) = marker.strip_prefix("CHJ_PHP=") {
138 raw.php_sockets.push(format!("unix:{socket}"));
139 } else if let Some(count) = marker.strip_prefix("CHJ_PROCESSES=") {
140 raw.processes = count.trim().parse().unwrap_or(0);
141 }
142 }
143 raw.files.extend(file.take());
144 raw
147fn facts_from(raw: &RawInventory) -> conf::ServerFacts {
148 let mut facts = conf::ServerFacts::default();
149 let section = |name: &str| raw.sections.get(name).map_or("", String::as_str);
150 conf::parse_build(section("version"), &mut facts);
151 conf::parse_run_config(section("runcfg"), &mut facts);
152 if !raw.main.is_empty() {
153 facts.main_config = raw.main.clone();
154 }
155 if facts.server_root.is_empty() {
156 facts.server_root = facts.httpd_root.clone();
157 }
158 for line in section("env").lines() {
159 if let Some((key, value)) = line.split_once('=') {
160 facts.env.insert(key.to_string(), value.to_string());
161 }
162 }
163 facts.modules = conf::parse_modules(section("modules"));
164 facts.includes = conf::parse_includes(section("includes"));
165 facts.vhosts = conf::parse_vhost_report(section("vhosts"));
166 facts.test_ok = raw.test_ok;
167 facts.test_output = section("test").trim().to_string();
168 facts
171/// A loaded real file in sites-enabled and a file of the same name in sites-available: a valid layout, only a difference is worth a note.
172fn shadow_files(raw: &RawInventory) -> Vec<Value> {
173 let mut found = Vec::new();
174 for (path, text, loaded) in &raw.files {
175 let Some((parent, name)) = path.rsplit_once('/') else { continue };
176 if *loaded || !parent.ends_with("-available") {
177 continue;
178 }
179 let twin = format!("{}-enabled/{name}", parent.trim_end_matches("-available"));
180 if raw.links.contains_key(&twin) {
181 continue;
182 }
183 if let Some((_, twin_text, true)) = raw.files.iter().find(|(candidate, _, _)| *candidate == twin) {
184 found.push(json!({ "path": path, "loaded": twin, "identical": twin_text.trim() == text.trim() }));
185 }
186 }
187 found
190/// Everything derived from the text, before asking the server about the paths it names.
191struct Derived {
192 facts: conf::ServerFacts,
193 evaluation: conf::Evaluation,
194 sites: Vec<Value>,
195 wide: Value,
196 logs: BTreeSet<String>,
197 errors: Vec<(String, String)>,
198 shadows: Vec<Value>,
201fn derive(raw: &RawInventory) -> Derived {
202 let facts = facts_from(raw);
203 let sources: Vec<(String, String)> = raw.files.iter().map(|(path, text, _)| (path.clone(), text.clone())).collect();
204 let config = conf::Config::new(sources, facts.clone());
205 let evaluation = config.evaluate();
206 let shadows = shadow_files(raw);
207 let mut sites = conf::collect_sites(&evaluation.items, true, &facts.main_document_root);
208 // Files Apache does not load may still describe a site: an available one, or one of a nonstandard layout.
209 let included: BTreeSet<&String> = evaluation.included.iter().collect();
210 let shadowed: BTreeSet<&str> = shadows.iter().filter_map(|shadow| shadow["path"].as_str()).collect();
211 let copies: BTreeSet<String> = raw.files.iter().filter_map(|(path, _, _)| path.strip_suffix(ENABLED_COPY_SUFFIX).map(str::to_string)).collect();
212 let linked_targets: BTreeSet<&str> = raw.links.iter().filter(|(path, _)| included.contains(path)).map(|(_, target)| target.as_str()).collect();
213 // Backups next to the configuration are common and are not sites waiting to be turned on.
214 let spare = |path: &str| [".bak", ".orig", ".old", "~", ".save", ".rpmnew", ".rpmsave", ".dist", ".disabled-copy"].iter().any(|suffix| path.ends_with(suffix)) || path.contains(".dpkg-") || path.contains(".chj-tmp") || path.contains(".chj-backup") || path.contains(".chj-orig");
215 let inactive: Vec<String> = config.file_paths().filter(|path| !included.contains(path) && !shadowed.contains(path.as_str()) && !linked_targets.contains(path.as_str()) && !spare(path) && !copies.contains(path.as_str())).cloned().collect();
216 for path in inactive {
217 let items = config.evaluate_inactive(&path, &evaluation);
218 sites.extend(conf::collect_sites(&items, false, ""));
219 }
220 conf::assign_roles(&mut sites);
221 let wide = conf::server_wide(&evaluation.items);
222 let logs = conf::log_paths(&evaluation.items, &facts.server_root);
223 Derived { errors: config.errors.clone(), facts, evaluation, sites, wide, logs, shadows }
226fn paths_to_check(derived: &Derived) -> Vec<String> {
227 let mut paths = BTreeSet::new();
228 for site in derived.sites.iter().filter(|site| site["enabled"] == true) {
229 for key in ["documentRoot", "certificate", "certificateKey"] {
230 if let Some(path) = site[key].as_str() {
231 paths.insert(path.to_string());
232 }
233 }
234 if let Some(socket) = site["phpHandler"]["target"].as_str().and_then(|target| target.split("unix:").nth(1)) {
235 paths.insert(socket.split('|').next().unwrap_or(socket).to_string());
236 }
237 }
238 paths.into_iter().filter(|path| SAFE_PATH.is_match(path) && !path.contains("..")).take(MAX_PATH_ARGS).collect()
241fn parse_paths(stdout: &str) -> BTreeMap<String, String> {
242 stdout.lines().filter_map(|line| line.strip_prefix("\x1eCHJ_PATH=")).filter_map(|line| line.rsplit_once('|')).map(|(path, kind)| (path.to_string(), kind.to_string())).collect()
245fn parse_certificates(stdout: &str, today: chrono::DateTime<chrono::Utc>) -> Value {
246 let mut certificates: Vec<Value> = Vec::new();
247 let (mut certbot, mut renew, mut installer) = (false, "unknown".to_string(), "none".to_string());
248 for line in stdout.lines() {
249 if let Some(rest) = line.strip_prefix("\x1eCHJ_CERT=") {
250 let fields: Vec<&str> = rest.splitn(5, '|').collect();
251 if certificates.iter().any(|known| known["path"] == fields.first().copied().unwrap_or("")) {
252 continue;
253 }
254 let parsed = chrono::NaiveDateTime::parse_from_str(fields.get(2).copied().unwrap_or("").trim().trim_end_matches(" GMT").trim(), "%b %e %H:%M:%S %Y").ok();
255 certificates.push(json!({
256 "path": fields.first().copied().unwrap_or(""),
257 "state": fields.get(1).copied().unwrap_or("unknown"),
258 "notAfter": parsed.map(|date| date.and_utc().to_rfc3339()),
259 "daysLeft": parsed.map(|date| (date.and_utc() - today).num_days()),
260 "subject": fields.get(3).copied().unwrap_or(""),
261 "issuer": fields.get(4).copied().unwrap_or(""),
262 }));
263 } else if let Some(value) = line.strip_prefix("\x1eCHJ_CERTBOT=") {
264 certbot = value.trim() == "1";
265 } else if let Some(value) = line.strip_prefix("\x1eCHJ_RENEW=") {
266 renew = value.trim().to_string();
267 } else if let Some(value) = line.strip_prefix("\x1eCHJ_INSTALLER=") {
268 installer = value.trim().to_string();
269 }
270 }
271 json!({ "certificates": certificates, "certbotInstalled": certbot, "certbotInstaller": installer, "renewal": renew })
274/// Fixes that are not a change of text. A real file in sites-enabled is a valid layout, so this stays a choice.
275fn proposals(raw: &RawInventory, shadows: &[Value]) -> Vec<Value> {
276 let mut found: Vec<Value> = raw.broken.iter().map(|(link, target)| json!({ "kind": "broken-link", "path": link, "target": target })).collect();
277 for (path, _, loaded) in &raw.files {
278 if *loaded && path.contains("/sites-enabled/") && !raw.links.contains_key(path) && path.ends_with(".conf") {
279 let older = shadows.iter().find(|file| file["loaded"] == path.as_str());
280 found.push(json!({ "kind": "adopt", "path": path, "available": path.replacen("/sites-enabled/", "/sites-available/", 1), "older": older.map(|file| &file["path"]), "identical": older.map(|file| &file["identical"]) }));
281 }
282 }
283 found
286fn inventory_value(raw: &RawInventory, derived: &Derived, paths: &BTreeMap<String, String>, certificates: &Value, htaccess: &[(String, String)]) -> Value {
287 let certificate_list: Vec<Value> = certificates["certificates"].as_array().cloned().unwrap_or_default();
288 let findings = conf::findings(&conf::CheckInput {
289 sites: &derived.sites,
290 wide: &derived.wide,
291 facts: &derived.facts,
292 evaluation: &derived.evaluation,
293 config_errors: &derived.errors,
294 paths,
295 certificates: &certificate_list,
296 htaccess,
297 broken_links: &raw.broken,
298 shadows: &derived.shadows,
299 });
300 let order: BTreeMap<&String, usize> = derived.evaluation.included.iter().enumerate().map(|(index, path)| (path, index)).collect();
301 let errors: BTreeMap<&String, &String> = derived.errors.iter().map(|(path, message)| (path, message)).collect();
302 let files: Vec<Value> = raw.files.iter().map(|(path, text, _)| json!({
303 "path": path, "text": text, "size": text.len(),
304 "loaded": order.contains_key(path), "order": order.get(path),
305 "linkTarget": raw.links.get(path), "parseError": errors.get(path),
306 })).collect();
307 let loaded_modules: BTreeSet<&String> = derived.facts.modules.iter().map(|(name, _)| name).collect();
308 let modules: Vec<Value> = derived.facts.modules.iter().map(|(name, kind)| json!({ "name": name, "kind": kind, "loaded": true })).collect();
309 let available: Vec<&String> = raw.available_modules.iter().filter(|name| !loaded_modules.contains(&format!("{name}_module"))).collect();
310 let reported: BTreeMap<(String, usize), &conf::ReportedVhost> = derived.facts.vhosts.iter().map(|vhost| ((vhost.file.clone(), vhost.line), vhost)).collect();
311 let mut sites = derived.sites.clone();
312 for site in &mut sites {
313 let key = (site["file"].as_str().unwrap_or("").to_string(), site["line"].as_u64().unwrap_or(0) as usize);
314 site["isDefault"] = json!(reported.get(&key).is_some_and(|vhost| vhost.is_default));
315 site["confirmedByApache"] = json!(reported.contains_key(&key));
316 let file = site["file"].as_str().unwrap_or("").to_string();
317 let linked = raw.links.get(&file);
318 site["editPath"] = json!(linked.unwrap_or(&file));
319 site["linkPath"] = json!(linked.map(|_| file.clone()));
320 site["editable"] = json!(site["fromMacro"] != true && site["conditions"].as_array().is_none_or(Vec::is_empty) && plain_config_path(linked.unwrap_or(&file)).is_ok());
321 site["canToggle"] = json!(file != raw.main && file.rsplit('/').next().is_some_and(|name| name.ends_with(".conf") || name.ends_with(DISABLED_SUFFIX) || name.ends_with(ENABLED_COPY_SUFFIX)));
322 }
323 let htaccess_files: Vec<Value> = htaccess.iter().map(|(path, text)| json!({ "path": path, "text": text, "size": text.len(), "findingIgnored": findings.iter().any(|item| item["code"] == "htaccess-ignored" && item["file"] == path.as_str()) })).collect();
324 json!({
325 "version": derived.facts.version_text, "mpm": derived.facts.mpm,
326 "serverRoot": derived.facts.server_root, "mainConfig": derived.facts.main_config, "mainDocumentRoot": derived.facts.main_document_root,
327 "testOk": derived.facts.test_ok, "testOutput": derived.facts.test_output,
328 "includesReported": derived.facts.includes.is_some(), "vhostsReported": derived.facts.vhosts.len(),
329 "sites": sites, "server": derived.wide,
330 "modules": modules, "availableModules": available,
331 "files": files, "skippedFiles": raw.skipped.iter().map(|(path, size)| json!({ "path": path, "size": size })).collect::<Vec<_>>(),
332 "phpSockets": raw.php_sockets, "processes": raw.processes,
333 "logs": derived.logs, "htaccess": htaccess_files,
334 "certificates": certificates, "paths": paths,
335 "findings": findings, "collectedAt": now(),
336 "layout": conf::detect_layout(&derived.evaluation.include_patterns).map(|layout| json!({ "kind": layout.kind, "directory": layout.directory, "enabled": layout.enabled })),
337 "canManageModules": !raw.available_modules.is_empty(),
338 "proposals": proposals(raw, &derived.shadows),
339 })
342/// The whole read: configuration, then the paths, certificates and .htaccess files it names.
343pub async fn inventory(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
344 let output = run(executor, session_id, operation("apache.inventory.read", vec![], payload, Duration::from_secs(60), MAX_INVENTORY_OUTPUT)?).await?;
345 if !output.stdout.contains("\x1eCHJ_DONE") {
346 return Err(CoreError::new("APACHE_HELPER_FAILED", "The Apache helper stopped before finishing."));
347 }
348 let raw = parse_raw(&output.stdout);
349 let derived = derive(&raw);
350 let check = paths_to_check(&derived);
351 let paths = if check.is_empty() { BTreeMap::new() } else { parse_paths(&run(executor, session_id, operation("apache.paths.check", check, payload, Duration::from_secs(20), MAX_COMMAND_OUTPUT)?).await?.stdout) };
352 let certificate_paths: Vec<String> = derived.sites.iter().filter_map(|site| site["certificate"].as_str()).filter(|path| SAFE_PATH.is_match(path) && !path.contains("..")).map(str::to_string).collect::<BTreeSet<_>>().into_iter().take(128).collect();
353 let certificates = parse_certificates(&run(executor, session_id, operation("apache.certificates.read", certificate_paths, payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?).await?.stdout, chrono::Utc::now());
354 let roots: Vec<String> = derived.sites.iter().filter(|site| site["enabled"] == true).filter_map(|site| site["documentRoot"].as_str()).chain(std::iter::once(derived.facts.main_document_root.as_str())).filter(|path| SAFE_PATH.is_match(path) && !path.contains("..")).map(str::to_string).collect::<BTreeSet<_>>().into_iter().take(64).collect();
355 let htaccess = if roots.is_empty() { Vec::new() } else {
356 let found = parse_raw(&run(executor, session_id, operation("apache.htaccess.scan", roots, payload, Duration::from_secs(30), MAX_INVENTORY_OUTPUT)?).await?.stdout);
357 found.files.into_iter().map(|(path, text, _)| (path, text)).collect()
358 };
359 Ok(inventory_value(&raw, &derived, &paths, &certificates, &htaccess))
362fn sha256(text: &str) -> String {
363 Sha256::digest(text.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect()
366fn expected_hash(payload: &Value) -> CoreResult<String> {
367 let value = payload.get("expectedSha256").and_then(Value::as_str).unwrap_or("");
368 if !value.is_empty() && !SHA.is_match(value) {
369 return Err(CoreError::new("APACHE_CONFIG_INVALID", "The expected file hash is not valid."));
370 }
371 Ok(value.to_string())
374fn require_confirm(payload: &Value, what: &str) -> CoreResult<()> {
375 if payload.get("confirm") != Some(&Value::Bool(true)) {
376 return Err(CoreError::new("APACHE_CONFIRMATION_REQUIRED", format!("{what} requires explicit confirmation.")));
377 }
378 Ok(())
381fn plain_config_path(path: &str) -> CoreResult<String> {
382 let path = path.trim();
383 if !CONFIG_NAME.is_match(path) || path.contains("..") || path.contains("//") || path.contains(".chj-tmp.") || path.contains(".chj-backup-") || SECRET_FILE.is_match(path) {
384 return Err(CoreError::new("APACHE_PATH_INVALID", "This configuration path is not valid."));
385 }
386 Ok(path.to_string())
389async fn load_raw(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<RawInventory> {
390 let output = run(executor, session_id, operation("apache.inventory.read", vec![], payload, Duration::from_secs(60), MAX_INVENTORY_OUTPUT)?).await?;
391 if !output.stdout.contains("\x1eCHJ_DONE") {
392 return Err(CoreError::new("APACHE_HELPER_FAILED", "The Apache helper stopped before finishing."));
393 }
394 Ok(parse_raw(&output.stdout))
397/// Only a file the inventory lists can be read or written here. A server may keep its configuration anywhere, so the
398/// list comes from the server instead of a fixed directory, and a symlink is edited at its target.
399async fn known_file(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<String> {
400 let path = plain_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
401 let raw = load_raw(executor, session_id, payload).await?;
402 if !raw.files.iter().any(|(known, _, _)| *known == path) {
403 return Err(CoreError::new("APACHE_PATH_INVALID", "This file is not part of the Apache configuration."));
404 }
405 Ok(path)
408pub async fn read_file(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
409 let path = known_file(executor, session_id, payload).await?;
410 let output = run(executor, session_id, operation("apache.config.read", vec![path.clone(), MAX_CONFIG_BYTES.to_string()], payload, Duration::from_secs(15), MAX_INVENTORY_OUTPUT)?).await?;
411 Ok(json!({ "path": path, "sha256": sha256(&output.stdout), "text": output.stdout }))
414pub async fn save_file(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
415 require_confirm(payload, "Saving a configuration file")?;
416 let manager = executor.sessions();
417 let record = manager.require_connected(session_id)?;
418 let path = known_file(executor, session_id, payload).await?;
419 let text = payload.get("text").and_then(Value::as_str).unwrap_or("");
420 if text.is_empty() || text.len() > MAX_CONFIG_BYTES || text.contains('\0') {
421 return Err(CoreError::new("APACHE_CONFIG_INVALID", "A configuration file must be text of 1 byte to 1 MiB."));
422 }
423 let args = vec![path.clone(), expected_hash(payload)?];
424 let mut options = operation("apache.config.save", args, payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?;
425 options.frames = vec![Frame::text("DATA", text)];
426 let output = run(executor, session_id, options).await?;
427 let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();
428 let backup = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default();
429 manager.logger.warn("Remote Apache configuration saved after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "backupPath": backup }));
430 Ok(json!({ "path": path, "backupPath": backup, "sha256": sha256(text), "testOutput": marker.replace(&output.stdout, "").trim() }))
433fn backup_of(path: &str, backup: &str) -> CoreResult<String> {
434 if !BACKUP.is_match(backup) || !backup.starts_with(&format!("/var/backups/chj-apache/{}.chj-backup-", path.replace('/', "%"))) {
435 return Err(CoreError::new("APACHE_PATH_INVALID", "This backup does not belong to the selected file."));
436 }
437 Ok(backup.to_string())
440pub async fn backups(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
441 let path = plain_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
442 let output = run(executor, session_id, operation("apache.backups.list", vec![path.clone()], payload, Duration::from_secs(15), MAX_COMMAND_OUTPUT)?).await?;
443 let mut items: Vec<Value> = output.stdout.lines().filter_map(|line| line.strip_prefix("\x1eCHJ_BACKUP=")).filter_map(|line| {
444 let mut fields = line.rsplitn(3, '|');
445 let (modified, size, file) = (fields.next()?, fields.next()?, fields.next()?);
446 Some(json!({ "path": file, "size": size.parse::<u64>().ok()?, "modified": modified.parse::<i64>().ok()? }))
447 }).collect();
448 items.sort_by_key(|item| std::cmp::Reverse(item["modified"].as_i64()));
449 Ok(json!({ "path": path, "backups": items }))
452pub async fn read_backup(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
453 let path = plain_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
454 let backup = backup_of(&path, payload.get("backup").and_then(Value::as_str).unwrap_or(""))?;
455 let output = run(executor, session_id, operation("apache.backup.read", vec![backup.clone(), MAX_CONFIG_BYTES.to_string()], payload, Duration::from_secs(15), MAX_INVENTORY_OUTPUT)?).await?;
456 Ok(json!({ "path": path, "backup": backup, "text": output.stdout }))
459/// The current state is backed up too, so a restore can itself be undone.
460pub async fn restore_backup(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
461 require_confirm(payload, "Restoring a backup")?;
462 let manager = executor.sessions();
463 let record = manager.require_connected(session_id)?;
464 let path = known_file(executor, session_id, payload).await?;
465 let backup = backup_of(&path, payload.get("backup").and_then(Value::as_str).unwrap_or(""))?;
466 let output = run(executor, session_id, operation("apache.backup.restore", vec![path.clone(), backup.clone(), expected_hash(payload)?], payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?).await?;
467 let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();
468 let previous = marker.captures(&output.stdout).map(|c| c[1].trim().to_string()).unwrap_or_default();
469 manager.logger.warn("Remote Apache configuration restored from a backup after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "backupPath": backup }));
470 Ok(json!({ "path": path, "backupPath": previous, "testOutput": marker.replace(&output.stdout, "").trim() }))
473struct SitePlan {
474 request: conf::SiteRequest,
475 text: String,
476 path: String,
477 link: String,
478 warnings: Vec<Value>,
479 missing_modules: Vec<String>,
482async fn plan(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<SitePlan> {
483 let request = conf::SiteRequest::from_value(payload.get("site").unwrap_or(&Value::Null))?;
484 let raw = load_raw(executor, session_id, payload).await?;
485 let derived = derive(&raw);
486 if !derived.facts.test_ok {
487 return Err(CoreError::new("APACHE_CONFIG_TEST_FAILED", format!("The current configuration does not pass its test, so a new site cannot be checked. {}", derived.facts.test_output)));
488 }
489 let layout = conf::detect_layout(&derived.evaluation.include_patterns).ok_or_else(|| CoreError::new("APACHE_LAYOUT_UNSUPPORTED", "The configuration includes no directory of site files, so a new site would not be loaded. Add such an Include in the configuration editor first."))?;
490 let name = request.file_name();
491 let enabled_name = format!("{}/{name}", layout.directory);
492 let path = if layout.enabled.is_some() { enabled_name.clone() } else { format!("{enabled_name}{DISABLED_SUFFIX}") };
493 plain_config_path(&path)?;
494 let link = layout.enabled.as_ref().map(|directory| format!("{directory}/{name}")).unwrap_or_default();
495 let taken = |candidate: &str| raw.files.iter().any(|(known, _, _)| known == candidate) || raw.links.contains_key(candidate);
496 if taken(&path) || taken(&enabled_name) || (!link.is_empty() && taken(&link)) {
497 return Err(CoreError::new("APACHE_SITE_EXISTS", format!("{path} already exists; existing configuration is never overwritten.")));
498 }
499 let log_directory = derived.facts.main_error_log.rsplit_once('/').map_or("/var/log".to_string(), |(directory, _)| directory.to_string());
500 let text = if request.kind == "custom" { conf::render_template(&request, &log_directory)? } else { conf::render_site(&request, &log_directory) };
501 // Same name, same port: Apache would quietly keep using the first.
502 let ports: Vec<u64> = if request.kind == "custom" {
503 text.lines().filter_map(|line| line.trim().strip_prefix("<VirtualHost ")).filter_map(|rest| rest.trim_end_matches('>').rsplit(':').next()).filter_map(|port| port.parse().ok()).collect()
504 } else {
505 request.ports().into_iter().map(u64::from).collect()
506 };
507 for site in derived.sites.iter().filter(|site| site["enabled"] == true) {
508 let same_port = site["listeners"].as_array().is_some_and(|list| list.iter().filter_map(Value::as_u64).any(|port| ports.contains(&port)));
509 let names: Vec<String> = std::iter::once(site["domain"].as_str().unwrap_or("").to_string()).chain(site["aliases"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_ascii_lowercase)).collect();
510 if same_port && request.names.iter().any(|name| names.contains(name)) {
511 return Err(CoreError::new("APACHE_SITE_CONFLICT", format!("{} already serves one of these names on the same port.", site["file"].as_str().unwrap_or(""))));
512 }
513 }
514 let loaded: BTreeSet<&str> = derived.facts.modules.iter().map(|(name, _)| name.as_str()).collect();
515 let missing_modules: Vec<String> = request.required_modules().into_iter().filter(|name| !loaded.contains(name)).map(str::to_string).collect();
516 let mut warnings = Vec::new();
517 let listen: BTreeSet<u64> = derived.wide["listen"].as_array().into_iter().flatten().filter_map(|item| item["value"].as_str()).filter_map(|value| value.split_whitespace().next()).filter_map(|value| value.rsplit(':').next().and_then(|port| port.parse().ok())).collect();
518 for port in ports.iter().filter(|port| !listen.is_empty() && !listen.contains(port)) {
519 warnings.push(json!({ "code": "no-listen", "port": port }));
520 }
521 if request.https {
522 let check = vec![request.certificate.clone(), request.certificate_key.clone()].into_iter().filter(|path| SAFE_PATH.is_match(path)).collect::<Vec<_>>();
523 let found = parse_paths(&run(executor, session_id, operation("apache.paths.check", check, payload, Duration::from_secs(15), MAX_COMMAND_OUTPUT)?).await?.stdout);
524 if let Some((path, _)) = found.iter().find(|(_, kind)| kind.as_str() != "file") {
525 return Err(CoreError::new("APACHE_CERTIFICATE_MISSING", format!("{path} does not exist on the server. A site on port 443 without its certificate would stop Apache from starting.")));
526 }
527 }
528 Ok(SitePlan { request, text, path, link, warnings, missing_modules })
531/// Shows what a new site would write. Nothing changes on the server.
532pub async fn plan_site(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
533 let plan = plan(executor, session_id, payload).await?;
534 Ok(json!({ "path": plan.path, "link": plan.link, "text": plan.text, "warnings": plan.warnings, "missingModules": plan.missing_modules }))
537/// New file only, switched off, tested; a failed test removes everything this call made. Turning it on is a separate step.
538pub async fn create_site(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
539 require_confirm(payload, "Creating a site")?;
540 let manager = executor.sessions();
541 let record = manager.require_connected(session_id)?;
542 let plan = plan(executor, session_id, payload).await?;
543 if !plan.missing_modules.is_empty() {
544 return Err(CoreError::new("APACHE_MODULES_MISSING", format!("This site needs modules that are not loaded: {}.", plan.missing_modules.join(", "))).with("modules", json!(plan.missing_modules)));
545 }
546 let root = if plan.request.create_root { plan.request.root.clone() } else { String::new() };
547 let mut options = operation("apache.site.create", vec![plan.path.clone(), root, String::new()], payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?;
548 options.frames = vec![Frame::text("DATA", &plan.text)];
549 let output = run(executor, session_id, options).await?;
550 manager.logger.warn("Remote Apache site created after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": plan.path }));
551 Ok(json!({ "path": plan.path, "link": plan.link, "enabled": false, "testOutput": output.stdout.trim() }))
554/// Which helper call turns a site on or off, decided from the file's place. Real files keep being real files.
555fn toggle_arguments(path: &str, link: &str, enabled: bool) -> CoreResult<(&'static str, Vec<String>)> {
556 let path = plain_config_path(path)?;
557 let invalid = || CoreError::new("APACHE_PATH_INVALID", "This site cannot be turned on and off here. Change it in the configuration editor.");
558 if let Some(plain) = path.strip_suffix(ENABLED_COPY_SUFFIX).filter(|_| path.contains("/sites-available/")) {
559 if !enabled {
560 return Err(invalid());
561 }
562 return Ok(("apache.site.rename", vec![path.clone(), plain.replacen("/sites-available/", "/sites-enabled/", 1)]));
563 }
564 if !link.is_empty() {
565 let link = plain_config_path(link)?;
566 if !link.contains("/sites-enabled/") || !path.contains("/sites-available/") {
567 return Err(invalid());
568 }
569 return Ok(if enabled { ("apache.site.enable-link", vec![path, link]) } else { ("apache.site.disable-link", vec![link]) });
570 }
571 if path.contains("/sites-available/") && path.ends_with(".conf") && enabled {
572 let target = path.replacen("/sites-available/", "/sites-enabled/", 1);
573 return Ok(("apache.site.enable-link", vec![path, target]));
574 }
575 if path.contains("/sites-enabled/") && !enabled {
576 let target = format!("{}{ENABLED_COPY_SUFFIX}", path.replacen("/sites-enabled/", "/sites-available/", 1));
577 return Ok(("apache.site.rename", vec![path, target]));
578 }
579 if let Some(plain) = path.strip_suffix(DISABLED_SUFFIX).filter(|_| enabled) {
580 return Ok(("apache.site.rename", vec![path.clone(), plain.to_string()]));
581 }
582 if path.ends_with(".conf") && !enabled {
583 let target = format!("{path}{DISABLED_SUFFIX}");
584 return Ok(("apache.site.rename", vec![path, target]));
585 }
586 Err(invalid())
589pub async fn set_site_enabled(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
590 require_confirm(payload, "Changing a site's state")?;
591 let manager = executor.sessions();
592 let record = manager.require_connected(session_id)?;
593 let enabled = payload.get("enabled") == Some(&Value::Bool(true));
594 let path = payload.get("path").and_then(Value::as_str).unwrap_or("");
595 // The main configuration holds everything, and is never renamed away.
596 let raw = load_raw(executor, session_id, payload).await?;
597 if plain_config_path(path)? == raw.main {
598 return Err(CoreError::new("APACHE_PATH_INVALID", "A site in the main configuration file can only be changed in the configuration editor."));
599 }
600 let (id, args) = toggle_arguments(path, payload.get("linkPath").and_then(Value::as_str).unwrap_or(""), enabled)?;
601 let output = run(executor, session_id, operation(id, args, payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?).await?;
602 manager.logger.warn("Remote Apache site state changed after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": path, "enabled": enabled }));
603 Ok(json!({ "enabled": enabled, "testOutput": output.stdout.trim() }))
606/// Result of a graphical edit, for the diff shown before saving. Saving goes through `save_file` with the hash.
607pub async fn plan_edit(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
608 // Said here rather than by a failed test while saving.
609 if payload["changes"]["https"].is_object() {
610 let raw = load_raw(executor, session_id, payload).await?;
611 if !facts_from(&raw).modules.iter().any(|(name, _)| name == "ssl_module") {
612 return Err(CoreError::new("APACHE_MODULES_MISSING", "HTTPS needs the ssl module, which is not loaded. Turn it on in the Modules tab and reload first.").with("modules", json!(["ssl_module"])));
613 }
614 }
615 let read = read_file(executor, session_id, payload).await?;
616 let before = read["text"].as_str().unwrap_or("");
617 let after = conf::apply_edit(before, payload.get("line").and_then(Value::as_u64).unwrap_or(0), payload.get("changes").unwrap_or(&Value::Null))?;
618 Ok(json!({ "path": read["path"], "before": before, "after": after, "sha256": read["sha256"] }))
621/// The change a finding's fix would make, for the same diff.
622pub async fn plan_fix(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
623 let read = read_file(executor, session_id, payload).await?;
624 let before = read["text"].as_str().unwrap_or("");
625 let after = conf::apply_fix(before, payload.get("code").and_then(Value::as_str).unwrap_or(""), payload.get("line").and_then(Value::as_u64).unwrap_or(0), payload.get("params").unwrap_or(&Value::Null))?;
626 Ok(json!({ "path": read["path"], "before": before, "after": after, "sha256": read["sha256"] }))
629pub async fn adopt_site(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
630 require_confirm(payload, "Converting a site")?;
631 let manager = executor.sessions();
632 let record = manager.require_connected(session_id)?;
633 let enabled = plain_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
634 if !enabled.contains("/sites-enabled/") {
635 return Err(CoreError::new("APACHE_PATH_INVALID", "Only a file in sites-enabled can be converted."));
636 }
637 let available = enabled.replacen("/sites-enabled/", "/sites-available/", 1);
638 let output = run(executor, session_id, operation("apache.site.adopt", vec![enabled.clone(), available.clone()], payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?).await?;
639 let marker = Regex::new(r"\x1eCHJ_BACKUP=([^\r\n]+)\s*$").unwrap();
640 manager.logger.warn("Remote Apache site converted to a symlink after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "path": enabled }));
641 Ok(json!({ "path": available, "link": enabled, "backupPath": marker.captures(&output.stdout).map(|c| c[1].trim().to_string()), "testOutput": marker.replace(&output.stdout, "").trim() }))
644pub async fn drop_broken_link(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
645 require_confirm(payload, "Removing a symlink")?;
646 let link = plain_config_path(payload.get("path").and_then(Value::as_str).unwrap_or(""))?;
647 if !link.contains("-enabled/") {
648 return Err(CoreError::new("APACHE_PATH_INVALID", "Only a symlink in an -enabled directory can be removed."));
649 }
650 let output = run(executor, session_id, operation("apache.site.drop-broken-link", vec![link.clone()], payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?).await?;
651 Ok(json!({ "path": link, "testOutput": output.stdout.trim() }))
654/// Where a2enmod exists. Elsewhere modules are lines in configuration files, which the editor can change.
655pub async fn set_module(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
656 require_confirm(payload, "Changing a module")?;
657 let manager = executor.sessions();
658 let record = manager.require_connected(session_id)?;
659 let name = payload.get("name").and_then(Value::as_str).unwrap_or("").trim().trim_end_matches("_module").to_string();
660 if !MODULE_NAME.is_match(&name) {
661 return Err(CoreError::new("APACHE_MODULE_INVALID", "This module name is not valid."));
662 }
663 let enabling = payload.get("enabled") == Some(&Value::Bool(true));
664 if !enabling {
665 let raw = load_raw(executor, session_id, payload).await?;
666 let used = conf::modules_in_use(&derive(&raw).sites);
667 if let Some(sites) = used.get(&format!("{name}_module")) {
668 return Err(CoreError::new("APACHE_MODULE_IN_USE", format!("These sites need this module: {}. The configuration test would pass, but they would stop working.", sites.join(", "))).with("sites", json!(sites)));
669 }
670 }
671 let id = if enabling { "apache.module.enable" } else { "apache.module.disable" };
672 let output = run(executor, session_id, operation(id, vec![name.clone()], payload, Duration::from_secs(30), MAX_COMMAND_OUTPUT)?).await?;
673 manager.logger.warn("Remote Apache module changed after successful validation.", json!({ "sessionId": record.session_id, "host": record.host, "module": name, "enabled": payload.get("enabled") }));
674 Ok(json!({ "name": name, "testOutput": output.stdout.trim() }))
677fn split_listening(stdout: &str) -> (String, Option<BTreeSet<u64>>) {
678 let mut ports = BTreeSet::new();
679 let mut known = true;
680 let mut text = String::new();
681 for line in stdout.lines() {
682 if let Some(port) = line.strip_prefix("\x1eCHJ_PORT=") {
683 ports.extend(port.trim().parse::<u64>().ok());
684 } else if line.starts_with("\x1eCHJ_PORTS=") {
685 known = false;
686 } else {
687 text.push_str(line);
688 text.push('\n');
689 }
690 }
691 (text.trim().to_string(), known.then_some(ports))
694fn missing_ports(sites: &[Value], listening: &BTreeSet<u64>) -> Vec<u64> {
695 let mut missing = BTreeSet::new();
696 for site in sites.iter().filter(|site| site["enabled"] == true) {
697 for port in site["addresses"].as_array().into_iter().flatten().filter_map(Value::as_str).filter_map(|address| address.rsplit(':').next()).filter_map(|port| port.parse::<u64>().ok()) {
698 if !listening.contains(&port) {
699 missing.insert(port);
700 }
701 }
702 }
703 missing.into_iter().collect()
706/// Graceful reload after a passing test, then a look at whether what should listen does.
707pub async fn reload(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
708 require_confirm(payload, "Reloading Apache")?;
709 let manager = executor.sessions();
710 let record = manager.require_connected(session_id)?;
711 let output = run(executor, session_id, operation("apache.reload", vec![], payload, Duration::from_secs(45), MAX_COMMAND_OUTPUT)?).await?;
712 manager.logger.warn("Remote Apache graceful reload completed.", json!({ "sessionId": record.session_id, "host": record.host }));
713 let (text, listening) = split_listening(&output.stdout);
714 let missing = match (listening, load_raw(executor, session_id, payload).await) {
715 (Some(ports), Ok(raw)) => missing_ports(&derive(&raw).sites, &ports),
716 _ => Vec::new(),
717 };
718 Ok(json!({ "ok": true, "output": text, "missingPorts": missing }))
721/// Installs certbot from the package manager of the server. The user asked for it, and agreed separately to any added repository.
722pub async fn install_certbot(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
723 require_confirm(payload, "Installing certbot")?;
724 let args = if payload.get("allowEpel") == Some(&Value::Bool(true)) { vec!["epel".to_string()] } else { vec![] };
725 let manager = executor.sessions();
726 let record = manager.require_connected(session_id)?;
727 let output = run(executor, session_id, operation("apache.certbot.install", args, payload, Duration::from_secs(900), MAX_COMMAND_OUTPUT)?).await?;
728 manager.logger.warn("certbot installed on the remote server.", json!({ "sessionId": record.session_id, "host": record.host }));
729 Ok(crate::core::ssh::nginx::certbot_install_result(&output.stdout))
732/// certbot with the web root of the site. The user has seen the terms and asked for this certificate.
733pub async fn issue_certificate(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
734 require_confirm(payload, "Requesting a certificate")?;
735 if payload.get("agreeTos") != Some(&Value::Bool(true)) {
736 return Err(CoreError::new("APACHE_CONFIRMATION_REQUIRED", "The certificate authority's terms must be accepted first."));
737 }
738 let email = payload.get("email").and_then(Value::as_str).unwrap_or("").trim();
739 if !email.is_empty() && !EMAIL.is_match(email) {
740 return Err(CoreError::new("APACHE_SITE_INVALID", "The e-mail address for certificate notices is not valid."));
741 }
742 let root = crate::core::ssh::nginx_conf::check_root(payload.get("root").and_then(Value::as_str).unwrap_or("")).map_err(|error| CoreError::new("APACHE_SITE_INVALID", error.message))?;
743 let domains: Vec<String> = payload.get("domains").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(crate::core::ssh::nginx_conf::check_host).collect::<CoreResult<_>>().map_err(|error| CoreError::new("APACHE_SITE_INVALID", error.message))?;
744 if domains.is_empty() || domains.len() > 32 || domains.iter().any(|domain| domain.starts_with('*')) {
745 return Err(CoreError::new("APACHE_SITE_INVALID", "Enter 1 to 32 domain names. The webroot method cannot issue wildcard certificates."));
746 }
747 let manager = executor.sessions();
748 let record = manager.require_connected(session_id)?;
749 let mut args = vec![email.to_string(), root];
750 args.extend(domains.iter().cloned());
751 let output = run(executor, session_id, operation("apache.certbot.issue", args, payload, Duration::from_secs(300), MAX_COMMAND_OUTPUT)?).await?;
752 manager.logger.warn("Remote certificate requested with certbot.", json!({ "sessionId": record.session_id, "host": record.host, "domains": domains }));
753 let live = format!("/etc/letsencrypt/live/{}", domains[0]);
754 Ok(json!({ "output": output.stdout.trim(), "certificate": format!("{live}/fullchain.pem"), "certificateKey": format!("{live}/privkey.pem") }))
757fn valid_log_path(path: &str) -> bool {
758 SAFE_PATH.is_match(path) && !path.contains("..") && !path.contains("//")
761/// Logs under /var/log are readable directly; anywhere else the configuration must name the file.
762pub async fn read_log(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
763 let path = payload.get("path").and_then(Value::as_str).unwrap_or("").trim().to_string();
764 if !valid_log_path(&path) {
765 return Err(CoreError::new("APACHE_PATH_INVALID", "This log path is not valid."));
766 }
767 if !path.starts_with("/var/log/") {
768 let output = run(executor, session_id, operation("apache.inventory.read", vec![], payload, Duration::from_secs(60), MAX_INVENTORY_OUTPUT)?).await?;
769 if !derive(&parse_raw(&output.stdout)).logs.contains(&path) {
770 return Err(CoreError::new("APACHE_PATH_INVALID", "The Apache configuration does not name this log file."));
771 }
772 }
773 let lines = payload.get("lines").and_then(Value::as_u64).unwrap_or(200).clamp(1, MAX_LOG_LINES);
774 let output = run(executor, session_id, operation("apache.log.read", vec![path.clone(), lines.to_string()], payload, Duration::from_secs(15), MAX_COMMAND_OUTPUT)?).await?;
775 Ok(json!({ "path": path, "text": output.stdout, "readAt": now() }))
778/// Connects from the server itself to the addresses its configuration names.
779pub async fn probe_backends(executor: &PrivilegedExecutor, session_id: &str, payload: &Value) -> CoreResult<Value> {
780 let output = run(executor, session_id, operation("apache.inventory.read", vec![], payload, Duration::from_secs(60), MAX_INVENTORY_OUTPUT)?).await?;
781 let derived = derive(&parse_raw(&output.stdout));
782 let targets = conf::backend_targets(&derived.sites, &derived.wide);
783 let shown: Vec<Value> = targets.iter().filter(|target| target["target"].as_str().is_some_and(|value| BACKEND.is_match(value))).take(MAX_PROBES).cloned().collect();
784 let args: Vec<String> = shown.iter().filter_map(|target| target["target"].as_str().map(str::to_string)).collect();
785 if args.is_empty() {
786 return Ok(json!({ "backends": [], "omitted": 0 }));
787 }
788 let output = run(executor, session_id, operation("apache.backends.probe", args, payload, Duration::from_secs(150), MAX_COMMAND_OUTPUT)?).await?;
789 let states: BTreeMap<&str, &str> = output.stdout.lines().filter_map(|line| line.strip_prefix("\x1eCHJ_PROBE=")).filter_map(|line| line.rsplit_once('|')).collect();
790 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();
791 Ok(json!({ "backends": backends, "omitted": targets.len().saturating_sub(MAX_PROBES) }))
794#[cfg(test)]
795mod tests {
796 use super::*;
798 #[test]
799 fn inspection_reports_a_missing_server() {
800 assert_eq!(parse_inspection("installed=0\n")["installed"], false);
801 let found = parse_inspection("installed=1\ncontrol=httpd\nbinary=/usr/sbin/httpd\nversion=Apache/2.4.63 (Rocky Linux)\nmpm=event\nservice=httpd\nserviceState=active\n");
802 assert_eq!((found["installed"].as_bool(), found["version"].as_str(), found["serviceState"].as_str()), (Some(true), Some("Apache/2.4.63 (Rocky Linux)"), Some("active")));
803 }
805 const OUTPUT: &str = "\x1eCHJ_SECTION=env\nAPACHE_LOG_DIR=/var/log/apache2\n\x1eCHJ_SECTION=version\nServer version: Apache/2.4.58 (Ubuntu)\nServer MPM: event\n -D HTTPD_ROOT=\"/etc/apache2\"\n -D SERVER_CONFIG_FILE=\"apache2.conf\"\n\x1eCHJ_SECTION=vhosts\nVirtualHost configuration:\n*:80 a.test (/etc/apache2/sites-enabled/a.conf:1)\nServerRoot: \"/etc/apache2\"\n\x1eCHJ_SECTION=modules\nLoaded Modules:\n core_module (static)\n ssl_module (shared)\n\x1eCHJ_SECTION=includes\nIncluded configuration files:\n (*) /etc/apache2/apache2.conf\n (1) /etc/apache2/sites-enabled/a.conf\n\x1eCHJ_SECTION=runcfg\nServerRoot: \"/etc/apache2\"\nMain DocumentRoot: \"/var/www/html\"\n\x1eCHJ_SECTION=test\nSyntax OK\n\x1eCHJ_EXIT=0\n\x1eCHJ_SECTION=files\n\x1eCHJ_FILE=/etc/apache2/apache2.conf|1\nListen 80\nServerName host.test\nIncludeOptional sites-enabled/*.conf\n\n\x1eCHJ_LINK=/etc/apache2/sites-enabled/a.conf|/etc/apache2/sites-available/a.conf\n\x1eCHJ_FILE=/etc/apache2/sites-enabled/a.conf|1\n<VirtualHost *:80>\nServerName a.test\nDocumentRoot /var/www/a\nErrorLog ${APACHE_LOG_DIR}/a.log\n</VirtualHost>\n\n\x1eCHJ_FILE=/etc/apache2/sites-available/a.conf|0\n<VirtualHost *:80>\nServerName a.test\nDocumentRoot /var/www/a\n</VirtualHost>\n\n\x1eCHJ_FILE=/etc/apache2/sites-available/off.conf|0\n<VirtualHost *:80>\nServerName off.test\nDocumentRoot /var/www/off\n</VirtualHost>\n\n\x1eCHJ_SECTION=facts\n\x1eCHJ_MAIN=/etc/apache2/apache2.conf\n\x1eCHJ_MODAVAIL=ssl\n\x1eCHJ_MODAVAIL=rewrite\n\x1eCHJ_PROCESSES=4\n\x1eCHJ_DONE\n";
807 #[test]
808 fn a_linked_site_counts_once_and_a_spare_one_is_off() {
809 let raw = parse_raw(OUTPUT);
810 let derived = derive(&raw);
811 let value = inventory_value(&raw, &derived, &BTreeMap::new(), &json!({ "certificates": [] }), &[]);
812 let sites = value["sites"].as_array().unwrap();
813 let names: Vec<(&str, bool)> = sites.iter().map(|site| (site["domain"].as_str().unwrap(), site["enabled"].as_bool().unwrap())).collect();
814 assert_eq!(names, [("a.test", true), ("off.test", false)]);
815 assert_eq!(sites[0]["errorLog"], "/var/log/apache2/a.log", "Debian's variables come from envvars");
816 assert_eq!(sites[0]["isDefault"], true);
817 assert_eq!(sites[0]["confirmedByApache"], true);
818 assert_eq!(value["availableModules"], json!(["rewrite"]));
819 assert_eq!(value["processes"], 4);
820 assert!(value["findings"].as_array().unwrap().iter().all(|item| item["code"] != "parser-missed-vhost" && item["code"] != "parser-missed-file"), "{}", value["findings"]);
821 let file = value["files"].as_array().unwrap().iter().find(|file| file["path"] == "/etc/apache2/sites-enabled/a.conf").unwrap();
822 assert_eq!(file["linkTarget"], "/etc/apache2/sites-available/a.conf");
823 assert_eq!(file["loaded"], true);
824 }
826 #[test]
827 fn certificates_are_listed_once_with_days() {
828 let today = chrono::DateTime::parse_from_rfc3339("2026-11-01T00:00:00Z").unwrap().with_timezone(&chrono::Utc);
829 let parsed = parse_certificates("\x1eCHJ_CERT=/c|ok|Dec 1 00:00:00 2026 GMT|CN=a|CN=b\n\x1eCHJ_CERT=/c|ok|Dec 1 00:00:00 2026 GMT|CN=a|CN=b\n\x1eCHJ_CERTBOT=0\n", today);
830 assert_eq!(parsed["certificates"].as_array().unwrap().len(), 1);
831 assert_eq!(parsed["certificates"][0]["daysLeft"], 30);
832 }
834 #[test]
835 fn toggling_follows_the_files_place() {
836 let cases = [
837 ("/etc/apache2/sites-available/a.conf", "", true, "apache.site.enable-link", "/etc/apache2/sites-enabled/a.conf"),
838 ("/etc/apache2/sites-available/a.conf", "/etc/apache2/sites-enabled/a.conf", false, "apache.site.disable-link", "/etc/apache2/sites-enabled/a.conf"),
839 ("/etc/apache2/sites-enabled/real.conf", "", false, "apache.site.rename", "/etc/apache2/sites-available/real.conf.chj-enabled-copy"),
840 ("/etc/apache2/sites-available/real.conf.chj-enabled-copy", "", true, "apache.site.rename", "/etc/apache2/sites-enabled/real.conf"),
841 ("/etc/httpd/conf.d/a.conf", "", false, "apache.site.rename", "/etc/httpd/conf.d/a.conf.chj-disabled"),
842 ("/etc/httpd/conf.d/a.conf.chj-disabled", "", true, "apache.site.rename", "/etc/httpd/conf.d/a.conf"),
843 ];
844 for (path, link, enabled, id, last) in cases {
845 let (got, args) = toggle_arguments(path, link, enabled).unwrap();
846 assert_eq!((got, args.last().unwrap().as_str()), (id, last), "{path}");
847 }
848 for (path, link) in [("/etc/x/../shadow.conf", ""), ("/etc/httpd/conf.d/a.key", ""), ("/etc/httpd/conf/magic", ""), ("/etc/apache2/sites-available/a.conf", "/tmp/x")] {
849 assert!(toggle_arguments(path, link, true).is_err() && toggle_arguments(path, link, false).is_err(), "{path}");
850 }
851 }
853 #[test]
854 fn backups_must_belong_to_the_file() {
855 let path = "/etc/apache2/sites-available/a.conf";
856 assert!(backup_of(path, "/var/backups/chj-apache/%etc%apache2%sites-available%a.conf.chj-backup-20261101T120000").is_ok());
857 for bad in ["/var/backups/chj-apache/%etc%shadow.chj-backup-20261101T120000", "/etc/shadow", "/var/backups/chj-apache/%etc%apache2%sites-available%a.conf.chj-backup-1/../x"] {
858 assert!(backup_of(path, bad).is_err(), "{bad}");
859 }
860 }
862 #[test]
863 fn config_paths_refuse_keys_and_climbing() {
864 assert!(plain_config_path("/opt/my web/x.conf").is_err());
865 assert!(plain_config_path("/opt/web/x.conf").is_ok());
866 for bad in ["/etc/ssl/a.key", "/etc/apache2/.htpasswd", "/etc/apache2/../shadow", "relative.conf", "/a/b.conf.chj-backup-1", "/a;b"] {
867 assert!(plain_config_path(bad).is_err(), "{bad}");
868 }
869 }
871 #[test]
872 fn a_real_file_is_a_choice_and_a_broken_link_is_a_problem() {
873 let raw = parse_raw(OUTPUT.replace("\x1eCHJ_LINK=/etc/apache2/sites-enabled/a.conf|/etc/apache2/sites-available/a.conf\n", "").replace("\x1eCHJ_SECTION=facts", "\x1eCHJ_BROKEN=/etc/apache2/sites-enabled/gone.conf|/x\n\x1eCHJ_SECTION=facts").as_str());
874 let found = proposals(&raw, &shadow_files(&raw));
875 assert_eq!(found[0]["kind"], "broken-link");
876 assert_eq!(found[1]["kind"], "adopt");
877 assert_eq!(found[1]["identical"], false, "{found:?}");
878 }
880 #[test]
881 fn log_paths_must_be_plain() {
882 assert!(valid_log_path("/var/log/apache2/error.log"));
883 for bad in ["/var/log/../shadow", "relative", "/var/log/a;b", "/var//log/a"] {
884 assert!(!valid_log_path(bad), "{bad}");
885 }
886 }
SHA-2563040f97cbbfe466b9999e73d9beb9732ac08f314a07e3a755beb8ecfe60eb63e

CH-J Proprietary Software License 1.14 · Sors huwa pprovdut taħt iż-CH-J Proprietary Software License 1.14. Id-disponibbiltà tagħha ma tibdilx it-termini tal-liċenzja jew tagħti drittijiet addizzjonali.

Archive SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fajls