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