Fonte publicada
Transferir fonte ZIP
CH-J Tools
Navegue por diretórios e arquivos para uma versão específica do aplicativo.
CH-J / Server Manager
1704 arquivos
0.1.2-beta
Adicionado: · Construído:
1
//! Apache Manager backend. The commands live in the matrix (`apache.sh`, `apache_inspect.sh`). Every change is a new file2
//! or a checked replacement: backup, configuration test, restore on failure. A reload is always a separate step.4
use crate::core::error::{CoreError, CoreResult};5
use crate::core::ssh::apache_conf as conf;6
use crate::core::ssh::exec::MAX_COMMAND_OUTPUT;7
use crate::core::ssh::nginx::normalize_sudo_password;8
use crate::core::ssh::platform::detect_platform;9
use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions, RunResult};10
use once_cell::sync::Lazy;11
use regex::Regex;12
use serde_json::{json, Value};13
use sha2::{Digest, Sha256};14
use std::collections::{BTreeMap, BTreeSet};15
use std::time::Duration;17
/// A server with hundreds of rules has far more text than one file.18
const MAX_INVENTORY_OUTPUT: usize = 32 * 1024 * 1024;19
const MAX_LOG_LINES: u64 = 2000;20
const MAX_PROBES: usize = 32;21
const MAX_PATH_ARGS: usize = 256;22
const MAX_CONFIG_BYTES: usize = 1024 * 1024;23
const 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.25
const ENABLED_COPY_SUFFIX: &str = ".chj-enabled-copy";26
static SHA: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9a-f]{64}$").unwrap());27
static CONFIG_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+@-]{1,511}$").unwrap());28
static SECRET_FILE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(\.(key|pem|crt|cer|csr|p12|pfx|der|so|gz)|htpasswd[^/]*|\.passwd)$").unwrap());29
static 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());30
static EMAIL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._+-]{1,64}@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$").unwrap());31
static MODULE_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9_-]{0,63}$").unwrap());32
static SAFE_PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/+@-]{1,255}$").unwrap());33
static 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());35
fn now() -> String {36
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)37
}39
fn 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
})54
}56
async 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)62
}64
fn 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
result76
}78
pub 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)90
}92
#[derive(Default)]93
struct 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,104
}106
/// Splits the helper's output. Markers start with a control character no configuration text starts a line with.107
fn 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
raw145
}147
fn 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
facts169
}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.172
fn 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
found188
}190
/// Everything derived from the text, before asking the server about the paths it names.191
struct 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>,199
}201
fn 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 }224
}226
fn 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()239
}241
fn 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()243
}245
fn 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 })272
}274
/// Fixes that are not a change of text. A real file in sites-enabled is a valid layout, so this stays a choice.275
fn 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
found284
}286
fn 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
})340
}342
/// The whole read: configuration, then the paths, certificates and .htaccess files it names.343
pub 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))360
}362
fn sha256(text: &str) -> String {363
Sha256::digest(text.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect()364
}366
fn 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())372
}374
fn 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(())379
}381
fn 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())387
}389
async 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))395
}397
/// Only a file the inventory lists can be read or written here. A server may keep its configuration anywhere, so the398
/// list comes from the server instead of a fixed directory, and a symlink is edited at its target.399
async 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)406
}408
pub 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 }))412
}414
pub 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() }))431
}433
fn 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())438
}440
pub 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 }))450
}452
pub 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 }))457
}459
/// The current state is backed up too, so a restore can itself be undone.460
pub 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() }))471
}473
struct SitePlan {474
request: conf::SiteRequest,475
text: String,476
path: String,477
link: String,478
warnings: Vec<Value>,479
missing_modules: Vec<String>,480
}482
async 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 })529
}531
/// Shows what a new site would write. Nothing changes on the server.532
pub 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 }))535
}537
/// New file only, switched off, tested; a failed test removes everything this call made. Turning it on is a separate step.538
pub 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() }))552
}554
/// Which helper call turns a site on or off, decided from the file's place. Real files keep being real files.555
fn 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())587
}589
pub 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() }))604
}606
/// Result of a graphical edit, for the diff shown before saving. Saving goes through `save_file` with the hash.607
pub 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"] }))619
}621
/// The change a finding's fix would make, for the same diff.622
pub 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"] }))627
}629
pub 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() }))642
}644
pub 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() }))652
}654
/// Where a2enmod exists. Elsewhere modules are lines in configuration files, which the editor can change.655
pub 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() }))675
}677
fn 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))692
}694
fn 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()704
}706
/// Graceful reload after a passing test, then a look at whether what should listen does.707
pub 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 }))719
}721
/// Installs certbot from the package manager of the server. The user asked for it, and agreed separately to any added repository.722
pub 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))730
}732
/// certbot with the web root of the site. The user has seen the terms and asked for this certificate.733
pub 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") }))755
}757
fn valid_log_path(path: &str) -> bool {758
SAFE_PATH.is_match(path) && !path.contains("..") && !path.contains("//")759
}761
/// Logs under /var/log are readable directly; anywhere else the configuration must name the file.762
pub 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() }))776
}778
/// Connects from the server itself to the addresses its configuration names.779
pub 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) }))792
}794
#[cfg(test)]795
mod 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
}887
}SHA-256
3040f97cbbfe466b9999e73d9beb9732ac08f314a07e3a755beb8ecfe60eb63eCH-J Proprietary Software License 1.14 · A fonte é fornecida sob o CH-J Proprietary Software License 1.14. A sua disponibilidade não altera os termos licença nem concede direitos adicionais.
Ficheiro SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f