公開済みのソース
ソースの ZIP をダウンロード
CH-J Tools
特定のアプリケーションリリースのディレクトリとファイルを閲覧できます。
CH-J / Server Manager
1704 ファイル
0.1.2-beta
追加日時: · ビルド日時:
1
//! Apache HTTP Server configuration as text: a parser that keeps files, lines and order, the configuration as Apache2
//! evaluates it, and what follows from it. Nothing here talks to a server or changes anything.4
use crate::core::error::{CoreError, CoreResult};5
use crate::core::ssh::nginx_conf::{check_backend, check_host, check_path, check_proxy_url, check_root};6
use once_cell::sync::Lazy;7
use regex::Regex;8
use serde_json::{json, Value};9
use std::collections::{BTreeMap, BTreeSet};11
const MAX_DEPTH: usize = 32;13
#[derive(Debug, Clone)]14
pub struct Node {15
pub name: String,16
pub key: String,17
pub args: Vec<String>,18
pub line: usize,19
/// Byte range of the directive, or of the whole section with its closing tag.20
pub start: usize,21
pub end: usize,22
pub body: Option<Vec<Node>>,23
/// Byte range of the lines between the tags, which a macro needs as text.24
pub inner: (usize, usize),25
}27
/// Words of a directive line. A quote only opens at the start of a word; a `#` inside a line is not a comment.28
fn words(text: &str) -> Vec<String> {29
let mut out = Vec::new();30
let mut chars = text.chars().peekable();31
while let Some(&first) = chars.peek() {32
if first.is_whitespace() {33
chars.next();34
continue;35
}36
let mut word = String::new();37
if first == '"' || first == '\'' {38
chars.next();39
while let Some(c) = chars.next() {40
if c == '\\' {41
if let Some(next) = chars.next() {42
if next != first && next != '\\' {43
word.push('\\');44
}45
word.push(next);46
}47
} else if c == first {48
break;49
} else {50
word.push(c);51
}52
}53
} else {54
while let Some(&c) = chars.peek() {55
if c.is_whitespace() {56
break;57
}58
word.push(c);59
chars.next();60
}61
}62
out.push(word);63
}64
out65
}67
pub fn parse(text: &str) -> Result<Vec<Node>, String> {68
struct Frame {69
node: Node,70
children: Vec<Node>,71
}72
let mut stack: Vec<Frame> = Vec::new();73
let mut root: Vec<Node> = Vec::new();74
let mut at = 0;75
let mut number = 0;76
while at < text.len() {77
// One logical line: a backslash at the end joins the next physical line.78
let start = at;79
let first_line = number + 1;80
let mut logical = String::new();81
let line_end = loop {82
let end = text[at..].find('\n').map_or(text.len(), |found| at + found);83
let physical = text[at..end].trim_end_matches('\r');84
number += 1;85
at = (end + 1).min(text.len());86
if end == text.len() {87
at = text.len();88
}89
if let Some(joined) = physical.strip_suffix('\\').filter(|_| at < text.len()) {90
logical.push_str(joined);91
logical.push(' ');92
continue;93
}94
logical.push_str(physical);95
break end;96
};97
let content = logical.trim();98
if content.is_empty() || content.starts_with('#') {99
continue;100
}101
let node_start = start + (logical.len() - logical.trim_start().len());102
if let Some(closing) = content.strip_prefix("</") {103
let name = closing.trim_end_matches('>').trim();104
let Some(frame) = stack.pop() else { return Err(format!("line {first_line}: </{name}> closes nothing")) };105
if !frame.node.key.eq_ignore_ascii_case(name) {106
return Err(format!("line {first_line}: </{name}> closes <{}> from line {}", frame.node.name, frame.node.line));107
}108
let mut node = frame.node;109
node.end = line_end;110
node.inner.1 = start;111
node.body = Some(frame.children);112
match stack.last_mut() {113
Some(parent) => parent.children.push(node),114
None => root.push(node),115
}116
} else if let Some(opening) = content.strip_prefix('<') {117
let Some(opening) = opening.strip_suffix('>') else { return Err(format!("line {first_line}: section tag is not closed")) };118
let mut parts = words(opening);119
if parts.is_empty() {120
return Err(format!("line {first_line}: empty section tag"));121
}122
let name = parts.remove(0);123
let key = name.to_ascii_lowercase();124
stack.push(Frame { node: Node { name, key, args: parts, line: first_line, start: node_start, end: line_end, body: None, inner: (at, at) }, children: Vec::new() });125
} else {126
let mut parts = words(content);127
let name = parts.remove(0);128
let key = name.to_ascii_lowercase();129
let node = Node { name, key, args: parts, line: first_line, start: node_start, end: line_end, body: None, inner: (0, 0) };130
match stack.last_mut() {131
Some(parent) => parent.children.push(node),132
None => root.push(node),133
}134
}135
}136
if let Some(frame) = stack.last() {137
return Err(format!("line {}: <{}> is never closed", frame.node.line, frame.node.name));138
}139
Ok(root)140
}142
// ---------------------------------------------------------------------------------------------143
// Output of the server's own commands144
// ---------------------------------------------------------------------------------------------146
#[derive(Debug, Clone, Default)]147
pub struct ServerFacts {148
pub version: (u32, u32, u32),149
pub version_text: String,150
pub mpm: String,151
pub httpd_root: String,152
pub server_root: String,153
pub main_config: String,154
pub main_document_root: String,155
pub main_error_log: String,156
pub defines: BTreeSet<String>,157
pub env: BTreeMap<String, String>,158
pub modules: Vec<(String, String)>,159
pub includes: Option<Vec<String>>,160
pub vhosts: Vec<ReportedVhost>,161
pub test_ok: bool,162
pub test_output: String,163
}165
#[derive(Debug, Clone, Default)]166
pub struct ReportedVhost {167
pub address: String,168
pub name: String,169
pub file: String,170
pub line: usize,171
pub is_default: bool,172
pub aliases: Vec<String>,173
}175
pub fn parse_version(text: &str) -> (u32, u32, u32) {176
static VERSION: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\d+)\.(\d+)\.(\d+)").unwrap());177
VERSION.captures(text).map_or((0, 0, 0), |c| (c[1].parse().unwrap_or(0), c[2].parse().unwrap_or(0), c[3].parse().unwrap_or(0)))178
}180
pub fn parse_build(text: &str, facts: &mut ServerFacts) {181
for line in text.lines() {182
let line = line.trim();183
if let Some(value) = line.strip_prefix("Server version:") {184
facts.version_text = value.trim().to_string();185
facts.version = parse_version(value);186
} else if let Some(value) = line.strip_prefix("Server MPM:") {187
facts.mpm = value.trim().to_string();188
} else if let Some(value) = line.strip_prefix("-D HTTPD_ROOT=") {189
facts.httpd_root = value.trim_matches('"').to_string();190
} else if let Some(value) = line.strip_prefix("-D SERVER_CONFIG_FILE=") {191
facts.main_config = value.trim_matches('"').to_string();192
}193
}194
if !facts.main_config.starts_with('/') && !facts.main_config.is_empty() {195
facts.main_config = format!("{}/{}", facts.httpd_root, facts.main_config);196
}197
}199
pub fn parse_run_config(text: &str, facts: &mut ServerFacts) {200
for line in text.lines() {201
let line = line.trim();202
if let Some(value) = line.strip_prefix("ServerRoot:") {203
facts.server_root = value.trim().trim_matches('"').to_string();204
} else if let Some(value) = line.strip_prefix("Main DocumentRoot:") {205
facts.main_document_root = value.trim().trim_matches('"').to_string();206
} else if let Some(value) = line.strip_prefix("Main ErrorLog:") {207
facts.main_error_log = value.trim().trim_matches('"').to_string();208
} else if let Some(value) = line.strip_prefix("Define:") {209
let name = value.trim().split('=').next().unwrap_or("").to_string();210
if !name.starts_with("DUMP_") && !name.is_empty() {211
facts.defines.insert(name);212
}213
}214
}215
}217
pub fn parse_modules(text: &str) -> Vec<(String, String)> {218
static MODULE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*([a-z0-9_]+_module)\s+\((static|shared)\)").unwrap());219
text.lines().filter_map(|line| MODULE.captures(line)).map(|c| (c[1].to_string(), c[2].to_string())).collect()220
}222
/// `None` when this build does not know DUMP_INCLUDES.223
pub fn parse_includes(text: &str) -> Option<Vec<String>> {224
static INCLUDED: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*\((?:\*|\d+)\)\s+(/.*)$").unwrap());225
if !text.contains("Included configuration files") {226
return None;227
}228
Some(text.lines().filter_map(|line| INCLUDED.captures(line)).map(|c| c[1].trim().to_string()).collect())229
}231
/// `-S`: the virtual hosts Apache itself sees, the first one on an address being the default.232
pub fn parse_vhost_report(text: &str) -> Vec<ReportedVhost> {233
static SOURCE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\(([^()]+):(\d+)\)\s*$").unwrap());234
static NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?:namevhost|default server)\s+(\S+)").unwrap());235
let mut found: Vec<ReportedVhost> = Vec::new();236
let mut address = String::new();237
let mut in_section = false;238
for line in text.lines() {239
if line.starts_with("VirtualHost configuration") {240
in_section = true;241
continue;242
}243
if !in_section {244
continue;245
}246
if line.starts_with("ServerRoot:") {247
break;248
}249
let indented = line.starts_with(' ') || line.starts_with('\t');250
let trimmed = line.trim();251
if let Some(alias) = trimmed.strip_prefix("alias ").or_else(|| trimmed.strip_prefix("wild alias ")) {252
if let Some(last) = found.last_mut() {253
last.aliases.push(alias.trim().to_string());254
}255
continue;256
}257
if !indented {258
address = trimmed.split_whitespace().next().unwrap_or("").to_string();259
}260
if trimmed.contains("default server") && found.iter().any(|known| known.address == address && known.is_default) {261
continue;262
}263
let Some(source) = SOURCE.captures(trimmed) else { continue };264
let is_default = trimmed.contains("default server") || !indented;265
let name = NAME.captures(trimmed).map_or_else(|| trimmed.split_whitespace().nth(1).unwrap_or("").to_string(), |c| c[1].to_string());266
let (file, line_number) = (source[1].to_string(), source[2].parse().unwrap_or(0));267
// The default is listed twice: as "default server" and again as a namevhost.268
if let Some(known) = found.iter_mut().find(|known| known.address == address && known.file == file && known.line == line_number) {269
known.is_default |= is_default;270
continue;271
}272
found.push(ReportedVhost { address: address.clone(), name, file, line: line_number, is_default, aliases: Vec::new() });273
}274
found275
}277
// ---------------------------------------------------------------------------------------------278
// The configuration as Apache evaluates it279
// ---------------------------------------------------------------------------------------------281
/// A directive after includes, conditions, variables and macros, with where it really came from.282
#[derive(Debug, Clone)]283
pub struct Item {284
pub name: String,285
pub key: String,286
pub args: Vec<String>,287
pub file: String,288
pub line: usize,289
/// Conditions decided only while serving or on the server: `<If>`, `<IfFile>`.290
pub conditions: Vec<String>,291
pub from_macro: bool,292
pub body: Option<Vec<Item>>,293
}295
impl Item {296
fn children(&self) -> &[Item] {297
self.body.as_deref().unwrap_or(&[])298
}300
fn first(&self, key: &str) -> Option<&Item> {301
self.children().iter().find(|item| item.key == key)302
}304
fn arg(&self, index: usize) -> &str {305
self.args.get(index).map_or("", String::as_str)306
}307
}309
pub struct Evaluation {310
pub items: Vec<Item>,311
pub included: Vec<String>,312
pub missing: Vec<Value>,313
pub unknown_macros: Vec<Value>,314
defines: BTreeMap<String, String>,315
macros: BTreeMap<String, (Vec<String>, String)>,316
pub include_patterns: Vec<String>,317
}319
pub struct Config {320
files: BTreeMap<String, (String, Vec<Node>)>,321
pub errors: Vec<(String, String)>,322
facts: ServerFacts,323
modules: BTreeSet<String>,324
}326
struct State {327
defines: BTreeMap<String, String>,328
macros: BTreeMap<String, (Vec<String>, String)>,329
included: Vec<String>,330
missing: Vec<Value>,331
unknown_macros: Vec<Value>,332
skip: BTreeSet<String>,333
include_patterns: Vec<String>,334
}336
fn module_key(name: &str) -> String {337
let name = name.to_ascii_lowercase();338
let base = name.strip_suffix(".c").unwrap_or(&name);339
let base = base.strip_prefix("mod_").unwrap_or(base);340
if base.ends_with("_module") {341
return base.to_string();342
}343
// <IfModule event.c>, <IfModule prefork.c>344
if matches!(base, "event" | "worker" | "prefork") {345
return format!("mpm_{base}_module");346
}347
format!("{base}_module")348
}350
fn version_matches(args: &[String], version: (u32, u32, u32)) -> Option<bool> {351
let (operator, target) = match args {352
[only] => ("=", only.as_str()),353
[operator, target, ..] => (operator.as_str(), target.as_str()),354
_ => return None,355
};356
if operator == "~" || target.starts_with('/') {357
return None;358
}359
let mut parts = target.split('.').map(|part| part.parse::<u32>().unwrap_or(0));360
let target = (parts.next().unwrap_or(0), parts.next().unwrap_or(0), parts.next().unwrap_or(0));361
Some(match operator {362
"=" | "==" => version == target,363
">" => version > target,364
">=" => version >= target,365
"<" => version < target,366
"<=" => version <= target,367
"!=" => version != target,368
_ => return None,369
})370
}372
impl Config {373
pub fn new(sources: Vec<(String, String)>, facts: ServerFacts) -> Self {374
let mut files = BTreeMap::new();375
let mut errors = Vec::new();376
for (path, text) in sources {377
match parse(&text) {378
Ok(nodes) => {379
files.insert(path, (text, nodes));380
}381
Err(message) => errors.push((path, message)),382
}383
}384
let modules = facts.modules.iter().map(|(name, _)| name.clone()).collect();385
Self { files, errors, facts, modules }386
}388
fn expand(&self, value: &str, state: &State) -> String {389
static VARIABLE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\$\{([A-Za-z0-9_.]+)\}").unwrap());390
VARIABLE391
.replace_all(value, |c: ®ex::Captures| state.defines.get(&c[1]).or_else(|| self.facts.env.get(&c[1])).cloned().unwrap_or_else(|| c[0].to_string()))392
.into_owned()393
}395
fn absolute(&self, path: &str) -> String {396
if path.starts_with('/') { path.to_string() } else { format!("{}/{path}", self.facts.server_root.trim_end_matches('/')) }397
}399
/// Files an Include names, in the order Apache reads them. A directory stands for everything below it.400
fn resolve(&self, pattern: &str) -> Vec<&String> {401
let absolute = self.absolute(pattern);402
if self.files.contains_key(&absolute) {403
return self.files.keys().filter(|path| **path == absolute).collect();404
}405
if absolute.contains(['*', '?', '[']) {406
let expression = regex::escape(&absolute).replace("\\*", "[^/]*").replace("\\?", "[^/]");407
let Ok(matcher) = Regex::new(&format!("^{expression}$")) else { return Vec::new() };408
return self.files.keys().filter(|path| matcher.is_match(path)).collect();409
}410
let prefix = format!("{}/", absolute.trim_end_matches('/'));411
self.files.keys().filter(|path| path.starts_with(&prefix)).collect()412
}414
fn walk(&self, nodes: &[Node], text: &str, file: &str, conditions: &[String], from_macro: bool, depth: usize, state: &mut State, out: &mut Vec<Item>) {415
if depth > MAX_DEPTH {416
return;417
}418
for node in nodes {419
let args: Vec<String> = node.args.iter().map(|arg| self.expand(arg, state)).collect();420
let body = node.body.as_deref().unwrap_or(&[]);421
let recurse = |this: &Self, state: &mut State, out: &mut Vec<Item>, extra: Option<String>| {422
let mut conditions = conditions.to_vec();423
conditions.extend(extra);424
this.walk(body, text, file, &conditions, from_macro, depth + 1, state, out);425
};426
match node.key.as_str() {427
"define" => {428
if let Some(name) = args.first() {429
state.defines.insert(name.clone(), args.get(1).cloned().unwrap_or_default());430
}431
}432
"undefine" => {433
if let Some(name) = args.first() {434
state.defines.remove(name);435
}436
}437
"include" | "includeoptional" => {438
for pattern in &args {439
state.include_patterns.push(self.absolute(pattern));440
let found: Vec<String> = self.resolve(pattern).into_iter().cloned().collect();441
if found.is_empty() && node.key == "include" {442
state.missing.push(json!({ "file": file, "line": node.line, "pattern": pattern }));443
}444
for path in found {445
// A spare copy of the main file includes everything the real one does: those are not more sites.446
if state.skip.contains(&path) {447
continue;448
}449
state.included.push(path.clone());450
if let Some((included_text, nodes)) = self.files.get(&path) {451
self.walk(nodes, included_text, &path, conditions, from_macro, depth + 1, state, out);452
}453
}454
}455
}456
"ifmodule" => {457
let wanted = args.first().map_or("", String::as_str);458
let (negated, name) = wanted.strip_prefix('!').map_or((false, wanted), |rest| (true, rest));459
if self.modules.contains(&module_key(name)) != negated {460
recurse(self, state, out, None);461
}462
}463
"ifdefine" => {464
let wanted = args.first().map_or("", String::as_str);465
let (negated, name) = wanted.strip_prefix('!').map_or((false, wanted), |rest| (true, rest));466
if (state.defines.contains_key(name) || self.facts.defines.contains(name)) != negated {467
recurse(self, state, out, None);468
}469
}470
"ifversion" => match version_matches(&args, self.facts.version) {471
Some(true) => recurse(self, state, out, None),472
Some(false) => {}473
None => recurse(self, state, out, Some(format!("<IfVersion {}>", args.join(" ")))),474
},475
"iffile" | "ifdirective" | "ifsection" => recurse(self, state, out, Some(format!("<{} {}>", node.name, args.join(" ")))),476
"macro" => {477
if let Some(name) = args.first() {478
let raw = text.get(node.inner.0..node.inner.1).unwrap_or("").to_string();479
state.macros.insert(name.to_ascii_lowercase(), (node.args[1..].to_vec(), raw));480
}481
}482
"use" => {483
let name = args.first().map_or(String::new(), |name| name.to_ascii_lowercase());484
let Some((parameters, raw)) = state.macros.get(&name).cloned() else {485
state.unknown_macros.push(json!({ "file": file, "line": node.line, "name": args.first() }));486
continue;487
};488
let mut pairs: Vec<(&String, &String)> = parameters.iter().zip(args[1..].iter()).collect();489
pairs.sort_by_key(|(parameter, _)| std::cmp::Reverse(parameter.len()));490
let mut filled = raw;491
for (parameter, value) in pairs {492
filled = filled.replace(parameter.as_str(), value);493
}494
if let Ok(nodes) = parse(&filled) {495
// Lines of a macro body say nothing; everything points at the Use line.496
let mut produced = Vec::new();497
self.walk(&nodes, &filled, file, conditions, true, depth + 1, state, &mut produced);498
relocate(&mut produced, node.line);499
out.extend(produced);500
}501
}502
_ => {503
let mut item = Item { name: node.name.clone(), key: node.key.clone(), args, file: file.to_string(), line: node.line, conditions: conditions.to_vec(), from_macro, body: None };504
if node.body.is_some() {505
let mut children = Vec::new();506
self.walk(body, text, file, conditions, from_macro, depth + 1, state, &mut children);507
item.body = Some(children);508
}509
out.push(item);510
}511
}512
}513
}515
fn fresh_state(&self) -> State {516
State { defines: BTreeMap::new(), macros: BTreeMap::new(), included: Vec::new(), missing: Vec::new(), unknown_macros: Vec::new(), skip: BTreeSet::new(), include_patterns: Vec::new() }517
}519
/// Everything reached from the main configuration file.520
pub fn evaluate(&self) -> Evaluation {521
let mut state = self.fresh_state();522
let mut items = Vec::new();523
let main = self.facts.main_config.clone();524
state.included.push(main.clone());525
if let Some((text, nodes)) = self.files.get(&main) {526
self.walk(nodes, text, &main, &[], false, 0, &mut state, &mut items);527
}528
Evaluation { items, included: state.included, missing: state.missing, unknown_macros: state.unknown_macros, defines: state.defines, macros: state.macros, include_patterns: state.include_patterns }529
}531
/// A file Apache does not load, read as if it did, keeping what the loaded configuration defines.532
pub fn evaluate_inactive(&self, path: &str, active: &Evaluation) -> Vec<Item> {533
let mut state = self.fresh_state();534
state.defines = active.defines.clone();535
state.macros = active.macros.clone();536
state.skip = active.included.iter().cloned().collect();537
let mut items = Vec::new();538
if let Some((text, nodes)) = self.files.get(path) {539
self.walk(nodes, text, path, &[], false, 1, &mut state, &mut items);540
}541
items542
}544
pub fn file_paths(&self) -> impl Iterator<Item = &String> {545
self.files.keys()546
}547
}549
fn relocate(items: &mut [Item], line: usize) {550
for item in items {551
item.line = line;552
if let Some(body) = &mut item.body {553
relocate(body, line);554
}555
}556
}558
// ---------------------------------------------------------------------------------------------559
// Sites560
// ---------------------------------------------------------------------------------------------562
fn deep<'a>(items: &'a [Item], key: &str, found: &mut Vec<&'a Item>) {563
for item in items {564
if item.key == key {565
found.push(item);566
}567
if item.body.is_some() && item.key != "virtualhost" {568
deep(item.children(), key, found);569
}570
}571
}573
fn all<'a>(items: &'a [Item], key: &str) -> Vec<&'a Item> {574
let mut found = Vec::new();575
deep(items, key, &mut found);576
found577
}579
fn port_of(address: &str) -> Option<u32> {580
let port = address.rsplit_once(':').map(|(_, port)| port)?;581
if address.starts_with('[') && !address.contains("]:") {582
return None;583
}584
port.parse().ok()585
}587
fn is_redirect_status(word: &str) -> bool {588
matches!(word.to_ascii_lowercase().as_str(), "permanent" | "temp" | "seeother" | "301" | "302" | "303" | "307" | "308")589
}591
/// `Redirect` and `RedirectMatch`, and `RewriteRule` only with the R flag, each with where it is written.592
fn redirect_rules(items: &[Item]) -> Vec<Value> {593
let mut rules = Vec::new();594
for item in all(items, "redirect").into_iter().chain(all(items, "redirectpermanent")).chain(all(items, "redirecttemp")) {595
let args: Vec<&str> = item.args.iter().map(String::as_str).filter(|arg| !is_redirect_status(arg)).collect();596
rules.push(json!({ "kind": item.name, "from": args.first(), "to": args.get(1), "file": item.file, "line": item.line, "conditions": item.conditions }));597
}598
for item in all(items, "redirectmatch") {599
let args: Vec<&str> = item.args.iter().map(String::as_str).filter(|arg| !is_redirect_status(arg)).collect();600
rules.push(json!({ "kind": item.name, "from": args.first(), "to": args.get(1), "file": item.file, "line": item.line, "regex": true, "conditions": item.conditions }));601
}602
for item in all(items, "rewriterule") {603
let flags = item.args.get(2).map_or("", String::as_str).to_ascii_lowercase();604
let redirecting = flags.trim_matches(['[', ']']).split(',').any(|flag| flag == "r" || flag.starts_with("r="));605
rules.push(json!({ "kind": item.name, "from": item.arg(0), "to": item.arg(1), "flags": item.args.get(2), "file": item.file, "line": item.line, "regex": true, "redirect": redirecting, "conditions": item.conditions }));606
}607
rules608
}610
fn whole_site_rule(rule: &Value) -> bool {611
if rule["kind"].as_str().is_some_and(|kind| kind.eq_ignore_ascii_case("rewriterule")) && rule["redirect"] != true {612
return false;613
}614
let from = rule["from"].as_str().unwrap_or("");615
matches!(from, "/" | "^" | "^/" | ".*" | "^.*$" | "^/?(.*)$" | "^/(.*)$" | "(.*)" | "^(.*)$" | "/(.*)")616
}618
fn php_handler(items: &[Item]) -> Option<Value> {619
for item in all(items, "sethandler") {620
let value = item.arg(0);621
if value.starts_with("proxy:") && value.contains("fcgi") {622
return Some(json!({ "kind": "php-fpm", "target": value, "file": item.file, "line": item.line }));623
}624
if value.contains("php") {625
return Some(json!({ "kind": "module", "target": value, "file": item.file, "line": item.line }));626
}627
}628
for item in all(items, "proxypassmatch") {629
if item.args.iter().any(|arg| arg.starts_with("fcgi://") || arg.contains("|fcgi://")) {630
return Some(json!({ "kind": "php-fpm", "target": item.args.get(1), "file": item.file, "line": item.line }));631
}632
}633
for item in all(items, "addhandler").into_iter().chain(all(items, "addtype")) {634
if item.args.iter().any(|arg| arg.contains("php")) {635
return Some(json!({ "kind": "module", "target": item.args.join(" "), "file": item.file, "line": item.line }));636
}637
}638
None639
}641
fn proxy_rules(items: &[Item]) -> Vec<Value> {642
let mut rules = Vec::new();643
fn visit(items: &[Item], location: Option<&str>, rules: &mut Vec<Value>) {644
for item in items {645
match item.key.as_str() {646
"proxypass" => {647
let (path, target) = match (location, item.args.len()) {648
(Some(path), _) => (path.to_string(), item.arg(0).to_string()),649
(None, _) => (item.arg(0).to_string(), item.arg(1).to_string()),650
};651
rules.push(json!({ "path": path, "target": target, "excluded": target == "!", "file": item.file, "line": item.line }));652
}653
"proxypassmatch" if !item.args.iter().any(|arg| arg.starts_with("fcgi://") || arg.contains("|fcgi://")) => {654
rules.push(json!({ "path": item.arg(0), "target": item.arg(1), "regex": true, "file": item.file, "line": item.line }));655
}656
"location" | "locationmatch" => visit(item.children(), Some(item.arg(0)), rules),657
_ if item.body.is_some() && item.key != "virtualhost" => visit(item.children(), location, rules),658
_ => {}659
}660
}661
}662
visit(items, None, &mut rules);663
rules664
}666
fn access_blocks(items: &[Item]) -> Vec<Value> {667
let mut blocks = Vec::new();668
for key in ["directory", "directorymatch", "location", "locationmatch", "files", "filesmatch", "proxy"] {669
for item in all(items, key) {670
let values = |wanted: &str| -> Vec<String> { item.children().iter().filter(|child| child.key == wanted).map(|child| child.args.join(" ")).collect() };671
let mut requires = values("require");672
for child in all(item.children(), "requireall").into_iter().chain(all(item.children(), "requireany")) {673
requires.extend(child.children().iter().filter(|inner| inner.key == "require").map(|inner| inner.args.join(" ")));674
}675
blocks.push(json!({676
"section": item.name, "path": item.arg(0), "file": item.file, "line": item.line,677
"require": requires, "legacy": values("order").first().is_some() || values("deny").first().is_some() || values("allow").first().is_some(),678
"allowOverride": item.first("allowoverride").map(|child| child.args.join(" ")),679
"options": item.first("options").map(|child| child.args.join(" ")),680
"auth": item.first("authtype").map(|child| child.arg(0).to_string()),681
"conditions": item.conditions,682
}));683
}684
}685
blocks686
}688
fn first_arg<'a>(items: &'a [Item], key: &str) -> Option<&'a Item> {689
items.iter().find(|item| item.key == key)690
}692
fn site_value(vhost: &Item, global: &[Item], enabled: bool, main_root: &str) -> Value {693
let body = vhost.children();694
let names: Vec<String> = body.iter().filter(|item| item.key == "servername").map(|item| item.arg(0).to_string()).collect();695
// ServerName may carry a scheme and a port; the host is what identifies the site.696
let domain = names.first().map(|name| name.split("://").last().unwrap_or(name).split(':').next().unwrap_or(name).to_ascii_lowercase());697
let aliases: Vec<String> = body.iter().filter(|item| item.key == "serveralias").flat_map(|item| item.args.clone()).collect();698
let root_item = first_arg(body, "documentroot");699
let inherited_root = root_item.is_none() && !main_root.is_empty();700
let ports: BTreeSet<u32> = vhost.args.iter().filter_map(|address| port_of(address)).collect();701
let ssl = first_arg(body, "sslengine").is_some_and(|item| item.arg(0).eq_ignore_ascii_case("on"));702
let redirects = redirect_rules(body);703
let proxies = proxy_rules(body);704
let own_php = php_handler(body);705
let inherited_php = if own_php.is_none() { php_handler(global) } else { None };706
let whole = redirects.iter().any(whole_site_rule);707
let proxied_root = proxies.iter().any(|rule| rule["path"] == "/" && rule["excluded"] != true);708
let kind = if whole {709
"redirect"710
} else if proxied_root {711
"proxy"712
} else if own_php.is_some() || (inherited_php.is_some() && (root_item.is_some() || inherited_root)) {713
"php"714
} else if root_item.is_some() || inherited_root {715
"static"716
} else if !proxies.is_empty() {717
"proxy"718
} else {719
"other"720
};721
let rule_target = redirects.iter().find(|rule| whole_site_rule(rule)).map(|rule| rule["to"].clone());722
json!({723
"server": "apache",724
"file": vhost.file, "line": vhost.line,725
"addresses": vhost.args, "listeners": ports,726
"domain": domain, "aliases": aliases,727
"documentRoot": root_item.map_or_else(|| (!main_root.is_empty()).then(|| main_root.to_string()), |item| Some(item.arg(0).to_string())),728
"rootInherited": inherited_root,729
"type": kind, "enabled": enabled, "fromMacro": vhost.from_macro, "conditions": vhost.conditions,730
"sslEnabled": ssl,731
"certificate": all(body, "sslcertificatefile").first().map(|item| item.arg(0).to_string()),732
"certificateKey": all(body, "sslcertificatekeyfile").first().map(|item| item.arg(0).to_string()),733
"sslProtocol": all(body, "sslprotocol").first().map(|item| item.args.join(" ")),734
"phpHandler": own_php.or(inherited_php.clone()), "phpInherited": inherited_php.is_some(),735
"proxies": proxies, "redirects": redirects, "redirectTarget": rule_target,736
"rewriteEngine": first_arg(body, "rewriteengine").is_some_and(|item| item.arg(0).eq_ignore_ascii_case("on")),737
"access": access_blocks(body),738
"accessLog": all(body, "customlog").iter().map(|item| item.arg(0).to_string()).collect::<Vec<_>>(),739
"errorLog": all(body, "errorlog").first().map(|item| item.arg(0).to_string()),740
"directives": count_directives(body),741
})742
}744
fn count_directives(items: &[Item]) -> usize {745
items.iter().map(|item| 1 + count_directives(item.children())).sum()746
}748
fn names_of(site: &Value) -> Vec<String> {749
let mut names: Vec<String> = site["domain"].as_str().map(str::to_string).into_iter().collect();750
names.extend(site["aliases"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_ascii_lowercase));751
names752
}754
fn redirect_host(site: &Value) -> Option<String> {755
let target = site["redirectTarget"].as_str()?;756
let rest = target.split_once("://")?.1;757
let host = rest.split(['/', ':', '%', '$']).next().unwrap_or("");758
(!host.is_empty()).then(|| host.to_ascii_lowercase())759
}761
/// A web is what a person calls one: the HTTP block that only leads to HTTPS, or serves the same files, belongs to it.762
pub fn assign_roles(sites: &mut [Value]) {763
for site in sites.iter_mut() {764
let role = if site["type"] == "redirect" { "redirect" } else if site["domain"].is_null() { "unnamed" } else { "site" };765
site["role"] = json!(role);766
}767
let mains: Vec<(usize, Vec<String>, Value, Value)> = sites.iter().enumerate().filter(|(_, site)| site["role"] == "site" && site["sslEnabled"] == true).map(|(index, site)| (index, names_of(site), site["enabled"].clone(), site["documentRoot"].clone())).collect();768
let mains_any: Vec<(usize, Vec<String>, Value)> = sites.iter().enumerate().filter(|(_, site)| site["role"] == "site").map(|(index, site)| (index, names_of(site), site["enabled"].clone())).collect();769
let mut joined: Vec<(usize, usize, bool)> = Vec::new();770
for index in 0..sites.len() {771
let names = names_of(&sites[index]);772
let enabled = sites[index]["enabled"].clone();773
if sites[index]["role"] == "redirect" {774
let target = redirect_host(&sites[index]);775
let found = mains_any.iter().find(|(_, main, state)| *state == enabled && main.iter().any(|name| names.contains(name) || target.as_ref() == Some(name)));776
if let Some((main, _, _)) = found {777
joined.push((*main, index, sites[index]["redirectTarget"].as_str().is_some_and(|to| to.starts_with("https://")) && sites[index]["sslEnabled"] != true));778
}779
} else if sites[index]["role"] == "site" && sites[index]["sslEnabled"] != true {780
// The plain twin of an HTTPS site with the same files is the same web.781
let found = mains.iter().find(|(_, main, state, root)| *state == enabled && *root == sites[index]["documentRoot"] && main.iter().any(|name| names.contains(name)));782
if let Some((main, _, _, _)) = found {783
joined.push((*main, index, false));784
}785
}786
}787
for (main, block, to_https) in joined {788
let label = format!("{}:{}", sites[main]["file"].as_str().unwrap_or(""), sites[main]["line"]);789
let main_names = names_of(&sites[main]);790
let extra: Vec<String> = names_of(&sites[block]).into_iter().filter(|name| !main_names.contains(name)).collect();791
let ports = sites[block]["listeners"].clone();792
sites[block]["role"] = json!("companion");793
sites[block]["companionOf"] = json!(label);794
let mut listeners: BTreeSet<u64> = sites[main]["listeners"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect();795
listeners.extend(ports.as_array().into_iter().flatten().filter_map(Value::as_u64));796
sites[main]["listeners"] = json!(listeners);797
let mut redirected = sites[main]["redirectedFrom"].as_array().cloned().unwrap_or_default();798
redirected.extend(extra.into_iter().map(Value::from));799
sites[main]["redirectedFrom"] = json!(redirected);800
if to_https {801
sites[main]["redirectsHttp"] = json!(true);802
}803
}804
}806
/// The virtual hosts and server-wide facts of an evaluated configuration.807
pub fn collect_sites(items: &[Item], enabled: bool, main_root: &str) -> Vec<Value> {808
let global: Vec<Item> = items.iter().filter(|item| item.key != "virtualhost").cloned().collect();809
all_vhosts(items).into_iter().map(|vhost| site_value(vhost, &global, enabled, main_root)).collect()810
}812
fn all_vhosts(items: &[Item]) -> Vec<&Item> {813
let mut found = Vec::new();814
for item in items {815
if item.key == "virtualhost" {816
found.push(item);817
} else if item.body.is_some() {818
found.extend(all_vhosts(item.children()));819
}820
}821
found822
}824
pub fn server_wide(items: &[Item]) -> Value {825
let global: Vec<Item> = items.iter().filter(|item| item.key != "virtualhost").cloned().collect();826
let values = |key: &str| -> Vec<Value> { all(&global, key).iter().map(|item| json!({ "value": item.args.join(" "), "file": item.file, "line": item.line })).collect() };827
let tuning: Vec<Value> = ["maxrequestworkers", "serverlimit", "threadsperchild", "startservers", "minsparethreads", "maxsparethreads", "minspareservers", "maxspareservers", "maxconnectionsperchild", "keepalive", "keepalivetimeout", "maxkeepaliverequests", "timeout"]828
.iter()829
.flat_map(|key| all(&global, key).into_iter().last())830
.map(|item| json!({ "name": item.name, "value": item.args.join(" "), "file": item.file, "line": item.line }))831
.collect();832
json!({833
"listen": values("listen"),834
"serverName": all(&global, "servername").first().map(|item| item.arg(0).to_string()),835
"tuning": tuning,836
"access": access_blocks(&global),837
"redirects": redirect_rules(&global),838
"php": php_handler(&global),839
"proxyRequests": all(&global, "proxyrequests").first().map(|item| json!({ "value": item.arg(0), "file": item.file, "line": item.line })),840
"accessLog": all(&global, "customlog").iter().map(|item| item.arg(0).to_string()).collect::<Vec<_>>(),841
"errorLog": all(&global, "errorlog").last().map(|item| item.arg(0).to_string()),842
"balancers": balancers(items),843
})844
}846
fn balancers(items: &[Item]) -> Vec<Value> {847
let mut pools = Vec::new();848
deep_all(items, "proxy", &mut pools);849
pools.into_iter().filter(|item| item.arg(0).starts_with("balancer://")).map(|item| json!({ "name": item.arg(0), "members": all(item.children(), "balancermember").iter().map(|member| member.arg(0).to_string()).collect::<Vec<_>>(), "file": item.file, "line": item.line })).collect()850
}852
/// Every log file a directive names, for the log viewer. Pipes and variables cannot be read back.853
pub fn log_paths(items: &[Item], server_root: &str) -> BTreeSet<String> {854
let mut found = BTreeSet::new();855
for key in ["customlog", "errorlog", "transferlog"] {856
let mut hits = Vec::new();857
deep_all(items, key, &mut hits);858
for item in hits {859
let value = item.arg(0);860
if value.is_empty() || value.starts_with('|') || value.contains('$') || value.contains('%') || value.starts_with("syslog") {861
continue;862
}863
found.insert(if value.starts_with('/') { value.to_string() } else { format!("{}/{value}", server_root.trim_end_matches('/')) });864
}865
}866
found867
}869
fn deep_all<'a>(items: &'a [Item], key: &str, found: &mut Vec<&'a Item>) {870
for item in items {871
if item.key == key {872
found.push(item);873
}874
deep_all(item.children(), key, found);875
}876
}878
/// `host:port` and `unix:/path` behind ProxyPass, balancers and PHP-FPM handlers. A value with a variable is not probed.879
pub fn backend_targets(sites: &[Value], wide: &Value) -> Vec<Value> {880
static SOCKET: Lazy<Regex> = Lazy::new(|| Regex::new(r"unix:(/[A-Za-z0-9._/-]+)").unwrap());881
static URL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(https?|fcgi|ajp|ws|wss|h2c?)://(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9._-]+)(?::([0-9]{1,5}))?").unwrap());882
let mut found: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();883
let members: Vec<(String, Vec<String>)> = wide["balancers"].as_array().into_iter().flatten().map(|pool| (pool["name"].as_str().unwrap_or("").to_string(), pool["members"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect())).collect();884
let add = |value: &str, label: &str, found: &mut BTreeMap<String, BTreeSet<String>>| {885
if value.contains('$') || value.contains('%') {886
return;887
}888
if let Some(socket) = SOCKET.captures(value) {889
found.entry(format!("unix:{}", &socket[1])).or_default().insert(label.to_string());890
} else if let Some(url) = URL.captures(value) {891
let port = url.get(3).map_or_else(|| match &url[1] { "https" | "wss" => "443".to_string(), "ajp" => "8009".to_string(), "fcgi" => "9000".to_string(), _ => "80".to_string() }, |port| port.as_str().to_string());892
found.entry(format!("{}:{port}", &url[2])).or_default().insert(label.to_string());893
}894
};895
for site in sites.iter().filter(|site| site["enabled"] == true) {896
let label = site["domain"].as_str().unwrap_or(site["file"].as_str().unwrap_or("")).to_string();897
for rule in site["proxies"].as_array().into_iter().flatten().filter(|rule| rule["excluded"] != true) {898
let target = rule["target"].as_str().unwrap_or("");899
match members.iter().find(|(name, _)| target.trim_end_matches('/') == name.trim_end_matches('/')) {900
Some((_, pool)) => pool.iter().for_each(|member| add(member, &label, &mut found)),901
None => add(target, &label, &mut found),902
}903
}904
if let Some(handler) = site["phpHandler"]["target"].as_str().filter(|_| site["phpHandler"]["kind"] == "php-fpm") {905
add(handler, &label, &mut found);906
}907
}908
found.into_iter().map(|(target, labels)| json!({ "target": target, "sites": labels })).collect()909
}911
// ---------------------------------------------------------------------------------------------912
// Where new sites go, generated sites and edits. The file is parsed again for every change and only913
// the bytes of that directive change.914
// ---------------------------------------------------------------------------------------------916
pub const GENERATED_MARK: &str = "# Managed by CH-J Server Manager. Manual changes are kept.";918
#[derive(Debug, Clone, PartialEq)]919
pub struct Layout {920
pub kind: &'static str,921
pub directory: String,922
pub enabled: Option<String>,923
}925
/// From what the configuration really includes, not from the distribution name. Without a wildcard directory there is nowhere safe to add a site.926
pub fn detect_layout(patterns: &[String]) -> Option<Layout> {927
let glob_dir = |pattern: &String| pattern.strip_suffix("/*.conf").or_else(|| pattern.strip_suffix("/*")).map(str::to_string);928
if let Some(enabled) = patterns.iter().filter_map(glob_dir).find(|dir| dir.ends_with("/sites-enabled")) {929
let directory = format!("{}/sites-available", enabled.trim_end_matches("/sites-enabled"));930
return Some(Layout { kind: "sites", directory, enabled: Some(enabled) });931
}932
for word in ["vhost", "site", "conf.d"] {933
if let Some(directory) = patterns.iter().filter(|pattern| pattern.ends_with("/*.conf")).filter_map(glob_dir).find(|dir| dir.rsplit('/').next().is_some_and(|name| name.contains(word))) {934
return Some(Layout { kind: "fragments", directory, enabled: None });935
}936
}937
None938
}940
#[derive(Debug, Clone, Default)]941
pub struct SiteRequest {942
pub kind: String,943
pub names: Vec<String>,944
pub port: u16,945
pub https_port: u16,946
pub root: String,947
pub create_root: bool,948
pub php: String,949
pub backend: String,950
pub redirect_target: String,951
pub permanent: bool,952
pub https: bool,953
pub redirect_http: bool,954
pub certificate: String,955
pub certificate_key: String,956
pub security_headers: bool,957
pub template: String,958
}960
fn invalid(message: impl Into<String>) -> CoreError {961
CoreError::new("APACHE_SITE_INVALID", message.into())962
}964
fn field<'a>(input: &'a Value, key: &str) -> &'a str {965
input.get(key).and_then(Value::as_str).map_or("", str::trim)966
}968
fn port_field(input: &Value, key: &str, fallback: u16) -> CoreResult<u16> {969
match &input[key] {970
Value::Null => Ok(fallback),971
other => other.as_u64().and_then(|port| u16::try_from(port).ok()).filter(|port| *port > 0).ok_or_else(|| invalid("The port must be a number from 1 to 65535.")),972
}973
}975
fn rephrase<T>(result: CoreResult<T>) -> CoreResult<T> {976
result.map_err(|error| invalid(error.message))977
}979
fn name_list(value: &Value) -> Vec<String> {980
match value {981
Value::Array(items) => items.iter().filter_map(Value::as_str).map(|item| item.trim().to_string()).filter(|item| !item.is_empty()).collect(),982
Value::String(text) => text.split(|c: char| c == ',' || c.is_whitespace()).map(str::to_string).filter(|item| !item.is_empty()).collect(),983
_ => Vec::new(),984
}985
}987
impl SiteRequest {988
pub fn from_value(input: &Value) -> CoreResult<Self> {989
let kind = field(input, "type").to_string();990
if !matches!(kind.as_str(), "static" | "php" | "wordpress" | "proxy" | "websocket" | "redirect" | "custom") {991
return Err(invalid("Unknown site type."));992
}993
let names = name_list(&input["serverNames"]).iter().map(|name| rephrase(check_host(name))).collect::<CoreResult<Vec<_>>>()?;994
if names.is_empty() || names.len() > 32 {995
return Err(invalid("Enter between 1 and 32 domain names."));996
}997
let uses_root = matches!(kind.as_str(), "static" | "php" | "wordpress") || (kind == "custom" && !field(input, "root").is_empty());998
let root = if uses_root { rephrase(check_root(field(input, "root")))? } else { String::new() };999
let php = if matches!(kind.as_str(), "php" | "wordpress") || (kind == "custom" && !field(input, "phpBackend").is_empty()) { rephrase(check_backend(field(input, "phpBackend")))? } else { String::new() };1000
let backend = if matches!(kind.as_str(), "proxy" | "websocket") || (kind == "custom" && !field(input, "backend").is_empty()) { rephrase(check_proxy_url(field(input, "backend")))? } else { String::new() };1001
let redirect_target = if kind == "redirect" { rephrase(check_proxy_url(field(input, "redirectTarget")))?.trim_end_matches('/').to_string() } else { String::new() };1002
let https = kind != "custom" && input.get("https") == Some(&Value::Bool(true));1003
let (certificate, certificate_key) = if https { (rephrase(check_path(field(input, "certificate")))?, rephrase(check_path(field(input, "certificateKey")))?) } else { Default::default() };1004
let flag = |key: &str| input.get(key) == Some(&Value::Bool(true));1005
Ok(Self {1006
names, port: port_field(input, "port", 80)?, https_port: port_field(input, "httpsPort", 443)?, create_root: uses_root && flag("createRoot"), root, php, backend, redirect_target,1007
permanent: input["redirectCode"].as_u64() != Some(302), https, redirect_http: https && flag("redirectHttp"), certificate, certificate_key, security_headers: flag("securityHeaders"),1008
template: if kind == "custom" { input["template"].as_str().unwrap_or("").to_string() } else { String::new() },1009
kind,1010
})1011
}1013
pub fn file_name(&self) -> String {1014
format!("{}.conf", self.names[0].replacen("*.", "wildcard.", 1))1015
}1017
pub fn ports(&self) -> Vec<u16> {1018
if self.https { vec![self.port, self.https_port] } else { vec![self.port] }1019
}1021
/// Modules the generated text uses, so that a missing one is named before the configuration test fails on it.1022
pub fn required_modules(&self) -> Vec<&'static str> {1023
let mut modules = Vec::new();1024
match self.kind.as_str() {1025
"php" => modules.extend(["proxy_module", "proxy_fcgi_module"]),1026
"wordpress" => modules.extend(["proxy_module", "proxy_fcgi_module", "rewrite_module"]),1027
"proxy" => modules.extend(["proxy_module", "proxy_http_module"]),1028
"websocket" => modules.extend(["proxy_module", "proxy_http_module", "proxy_wstunnel_module"]),1029
"redirect" => modules.push("alias_module"),1030
_ => {}1031
}1032
if self.https {1033
modules.push("ssl_module");1034
}1035
if self.security_headers {1036
modules.push("headers_module");1037
}1038
modules1039
}1040
}1042
fn php_target(php: &str) -> String {1043
if php.starts_with("unix:") { format!("proxy:{php}|fcgi://localhost") } else { format!("proxy:fcgi://{php}") }1044
}1046
fn body_lines(request: &SiteRequest, log_directory: &str) -> Vec<String> {1047
let mut lines = vec![format!("ServerName {}", request.names[0])];1048
if request.names.len() > 1 {1049
lines.push(format!("ServerAlias {}", request.names[1..].join(" ")));1050
}1051
lines.push(String::new());1052
match request.kind.as_str() {1053
"static" | "php" | "wordpress" => {1054
lines.push(format!("DocumentRoot {}", request.root));1055
lines.push(format!("<Directory {}>", request.root));1056
lines.push(" Options -Indexes +FollowSymLinks".into());1057
lines.push(" AllowOverride None".into());1058
lines.push(" Require all granted".into());1059
if request.kind == "wordpress" {1060
lines.extend([" RewriteEngine On".to_string(), " RewriteRule ^index\\.php$ - [L]".into(), " RewriteCond %{REQUEST_FILENAME} !-f".into(), " RewriteCond %{REQUEST_FILENAME} !-d".into(), " RewriteRule . /index.php [L]".into()]);1061
}1062
lines.push("</Directory>".into());1063
if request.kind != "static" {1064
lines.push("DirectoryIndex index.php index.html".into());1065
lines.extend(["<FilesMatch \\.php$>".to_string(), format!(" SetHandler \"{}\"", php_target(&request.php)), "</FilesMatch>".into()]);1066
}1067
}1068
"proxy" | "websocket" => {1069
lines.push("ProxyRequests Off".into());1070
lines.push("ProxyPreserveHost On".into());1071
let target = request.backend.trim_end_matches('/');1072
lines.push(if request.kind == "websocket" { format!("ProxyPass / {target}/ upgrade=websocket") } else { format!("ProxyPass / {target}/") });1073
lines.push(format!("ProxyPassReverse / {target}/"));1074
}1075
_ => lines.push(format!("Redirect {} / {}/", if request.permanent { "permanent" } else { "temp" }, request.redirect_target)),1076
}1077
if request.security_headers {1078
lines.extend(["Header always set X-Content-Type-Options nosniff".to_string(), "Header always set X-Frame-Options SAMEORIGIN".into(), "Header always set Referrer-Policy strict-origin-when-cross-origin".into()]);1079
if request.https {1080
lines.push("Header always set Strict-Transport-Security \"max-age=31536000\"".into());1081
}1082
}1083
lines.push(String::new());1084
let base = log_directory.trim_end_matches('/');1085
lines.push(format!("ErrorLog {base}/{}-error.log", request.names[0]));1086
lines.push(format!("CustomLog {base}/{}-access.log combined", request.names[0]));1087
lines1088
}1090
fn virtual_host(out: &mut String, port: u16, lines: &[String]) {1091
out.push_str(&format!("<VirtualHost *:{port}>\n"));1092
for line in lines {1093
if line.is_empty() {1094
out.push('\n');1095
} else {1096
out.push_str(&format!(" {line}\n"));1097
}1098
}1099
out.push_str("</VirtualHost>\n");1100
}1102
/// The text of a new site, built in Core from checked values. AllowOverride stays None: `.htaccess` is never turned on by the program.1103
pub fn render_site(request: &SiteRequest, log_directory: &str) -> String {1104
let mut out = format!("{GENERATED_MARK}\n\n");1105
let mut body = body_lines(request, log_directory);1106
if request.https {1107
let plain = if request.redirect_http {1108
let mut lines = vec![format!("ServerName {}", request.names[0])];1109
if request.names.len() > 1 {1110
lines.push(format!("ServerAlias {}", request.names[1..].join(" ")));1111
}1112
let port = if request.https_port == 443 { String::new() } else { format!(":{}", request.https_port) };1113
lines.push(format!("Redirect permanent / https://{}{port}/", request.names[0]));1114
lines1115
} else {1116
body.clone()1117
};1118
virtual_host(&mut out, request.port, &plain);1119
out.push('\n');1120
let position = body.iter().position(String::is_empty).map_or(body.len(), |at| at + 1);1121
body.splice(position..position, ["SSLEngine on".to_string(), format!("SSLCertificateFile {}", request.certificate), format!("SSLCertificateKeyFile {}", request.certificate_key), String::new()]);1122
virtual_host(&mut out, request.https_port, &body);1123
} else {1124
virtual_host(&mut out, request.port, &body);1125
}1126
out1127
}1129
const MAX_TEMPLATE_BYTES: usize = 64 * 1024;1131
/// The administrator's own template with its placeholders filled in from checked values.1132
pub fn render_template(request: &SiteRequest, log_directory: &str) -> CoreResult<String> {1133
static PLACEHOLDER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\{\{\s*([A-Za-z_]*)\s*\}\}").unwrap());1134
if request.template.trim().is_empty() || request.template.len() > MAX_TEMPLATE_BYTES || request.template.contains('\0') {1135
return Err(invalid("A template must be text of 1 byte to 64 KiB."));1136
}1137
let mut problem = None;1138
let filled = PLACEHOLDER.replace_all(&request.template, |caps: ®ex::Captures| {1139
let (value, known) = match &caps[1] {1140
"domain" => (request.names[0].clone(), true),1141
"aliases" => (request.names[1..].join(" "), true),1142
"port" => (request.port.to_string(), true),1143
"root" => (request.root.clone(), true),1144
"backend" => (request.backend.clone(), true),1145
"php" => (if request.php.is_empty() { String::new() } else { php_target(&request.php) }, true),1146
"logs" => (log_directory.trim_end_matches('/').to_string(), true),1147
_ => (String::new(), false),1148
};1149
if value.is_empty() && &caps[1] != "aliases" && problem.is_none() {1150
problem = Some(if known { format!("The template needs a value for {{{{{}}}}}.", &caps[1]) } else { format!("The template uses an unknown placeholder {{{{{}}}}}.", &caps[1]) });1151
}1152
value1153
}).into_owned();1154
if let Some(message) = problem {1155
return Err(invalid(message));1156
}1157
let nodes = parse(&filled).map_err(|message| invalid(format!("The template is not valid configuration text: {message}.")))?;1158
if !find_vhosts(&nodes).iter().any(|_| true) {1159
return Err(invalid("The template has no VirtualHost."));1160
}1161
Ok(format!("{GENERATED_MARK}\n\n{filled}{}", if filled.ends_with('\n') { "" } else { "\n" }))1162
}1164
fn find_vhosts(nodes: &[Node]) -> Vec<&Node> {1165
let mut found = Vec::new();1166
for node in nodes {1167
if node.key == "virtualhost" {1168
found.push(node);1169
} else if let Some(body) = &node.body {1170
found.extend(find_vhosts(body));1171
}1172
}1173
found1174
}1176
fn edit_error(message: impl Into<String>) -> CoreError {1177
CoreError::new("APACHE_EDIT_UNSUPPORTED", message.into())1178
}1180
fn parsed(text: &str) -> CoreResult<Vec<Node>> {1181
parse(text).map_err(|message| edit_error(format!("The file could not be parsed for a graphical edit: {message}. Use the configuration editor.")))1182
}1184
fn vhost_at(nodes: &[Node], line: u64) -> CoreResult<&Node> {1185
find_vhosts(nodes).into_iter().find(|node| node.line as u64 == line).ok_or_else(|| CoreError::new("APACHE_CONFIG_CHANGED", "The site is no longer at this place in the file; reload the list."))1186
}1188
fn line_start(text: &str, at: usize) -> usize {1189
text[..at].rfind('\n').map_or(0, |found| found + 1)1190
}1192
fn next_line(text: &str, at: usize) -> usize {1193
text[at..].find('\n').map_or(text.len(), |found| at + found + 1)1194
}1196
fn indent_at(text: &str, at: usize) -> String {1197
text[line_start(text, at)..at].chars().take_while(|c| c.is_whitespace()).collect()1198
}1200
/// A whole line goes when the directive is alone on it. Apache allows nothing else on a line anyway.1201
fn without_lines(text: &str, start: usize, end: usize) -> String {1202
let (from, to) = (line_start(text, start), next_line(text, end));1203
format!("{}{}", &text[..from], &text[to..])1204
}1206
fn insert_in_block(text: &str, block: &Node, line: &str) -> String {1207
let children = block.body.as_deref().unwrap_or(&[]);1208
let anchor = children.iter().rev().find(|node| node.key == "servername" || node.key == "serveralias");1209
let (at, indent) = match (anchor, children.first()) {1210
(Some(node), _) => (next_line(text, node.end), indent_at(text, node.start)),1211
(None, Some(node)) => (line_start(text, node.start), indent_at(text, node.start)),1212
_ => (block.inner.1, format!("{} ", indent_at(text, block.start))),1213
};1214
format!("{}{indent}{line}\n{}", &text[..at], &text[at..])1215
}1217
/// Replaces, adds or (with no values) removes one directive. Several of one name are a human decision.1218
fn set_directive(text: &str, block: &Node, key: &str, name: &str, values: &[String]) -> CoreResult<String> {1219
let existing: Vec<&Node> = block.body.as_deref().unwrap_or(&[]).iter().filter(|node| node.key == key).collect();1220
if existing.len() > 1 {1221
return Err(edit_error(format!("{name} appears {} times in this block. Use the configuration editor.", existing.len())));1222
}1223
Ok(match (existing.first(), values.is_empty()) {1224
(Some(node), true) => without_lines(text, node.start, node.end),1225
(Some(node), false) => format!("{}{} {}{}", &text[..node.start], node.name, values.join(" "), &text[node.end..]),1226
(None, true) => text.to_string(),1227
(None, false) => insert_in_block(text, block, &format!("{name} {}", values.join(" "))),1228
})1229
}1231
fn with_vhost<F: Fn(&str, &Node) -> CoreResult<String>>(text: &str, line: u64, change: F) -> CoreResult<String> {1232
let nodes = parsed(text)?;1233
change(text, vhost_at(&nodes, line)?)1234
}1236
/// Applies graphical changes to one VirtualHost and keeps every other byte of the file.1237
pub fn apply_edit(original: &str, line: u64, changes: &Value) -> CoreResult<String> {1238
let mut output = original.to_string();1239
if changes.get("serverName").is_some() {1240
let name = rephrase(check_host(field(changes, "serverName")))?;1241
output = with_vhost(&output, line, |text, vhost| set_directive(text, vhost, "servername", "ServerName", std::slice::from_ref(&name)))?;1242
}1243
if changes.get("aliases").is_some() {1244
let names = name_list(&changes["aliases"]).iter().map(|name| rephrase(check_host(name))).collect::<CoreResult<Vec<_>>>()?;1245
output = with_vhost(&output, line, |text, vhost| set_directive(text, vhost, "serveralias", "ServerAlias", &names))?;1246
}1247
if changes.get("documentRoot").is_some() {1248
let root = rephrase(check_root(field(changes, "documentRoot")))?;1249
output = with_vhost(&output, line, |text, vhost| {1250
let old = vhost.body.as_deref().unwrap_or(&[]).iter().find(|node| node.key == "documentroot").and_then(|node| node.args.first()).cloned();1251
let mut changed = set_directive(text, vhost, "documentroot", "DocumentRoot", std::slice::from_ref(&root))?;1252
// The <Directory> of the old root would otherwise protect nothing and leave the new one to the defaults.1253
if let Some(old) = old {1254
let nodes = parsed(&changed)?;1255
let block = vhost_at(&nodes, line)?;1256
let tag = block.body.as_deref().unwrap_or(&[]).iter().find(|node| node.key == "directory" && node.args.first().is_some_and(|arg| arg.trim_end_matches('/') == old.trim_end_matches('/')));1257
if let Some(tag) = tag {1258
let end = next_line(&changed, tag.start);1259
let replaced = changed[tag.start..end].replacen(&old, &root, 1);1260
changed = format!("{}{replaced}{}", &changed[..tag.start], &changed[end..]);1261
}1262
}1263
Ok(changed)1264
})?;1265
}1266
if changes.get("backend").is_some() {1267
let target = format!("{}/", rephrase(check_proxy_url(field(changes, "backend")))?.trim_end_matches('/'));1268
output = with_vhost(&output, line, |text, vhost| {1269
let mut changed = text.to_string();1270
let found = vhost.body.as_deref().unwrap_or(&[]).iter().any(|node| node.key == "proxypass" && node.args.first().is_some_and(|path| path == "/"));1271
if !found {1272
return Err(edit_error("This site has no ProxyPass for /. Use the configuration editor."));1273
}1274
for key in ["proxypass", "proxypassreverse"] {1275
let nodes = parsed(&changed)?;1276
let block = vhost_at(&nodes, line)?;1277
let rules: Vec<&Node> = block.body.as_deref().unwrap_or(&[]).iter().filter(|node| node.key == key && node.args.first().is_some_and(|path| path == "/")).collect();1278
if rules.len() > 1 {1279
return Err(edit_error("Several rules exist for /. Use the configuration editor."));1280
}1281
if let Some(rule) = rules.first() {1282
let extra = rule.args.get(2..).map_or(String::new(), |rest| rest.iter().map(|arg| format!(" {arg}")).collect());1283
changed = format!("{}{} / {target}{extra}{}", &changed[..rule.start], rule.name, &changed[rule.end..]);1284
}1285
}1286
Ok(changed)1287
})?;1288
}1289
if changes.get("redirect").is_some() {1290
let target = format!("{}/", rephrase(check_proxy_url(field(changes, "redirect")))?.trim_end_matches('/'));1291
output = with_vhost(&output, line, |text, vhost| {1292
let rules: Vec<&Node> = vhost.body.as_deref().unwrap_or(&[]).iter().filter(|node| node.key == "redirect" && node.args.iter().any(|arg| arg == "/")).collect();1293
let [rule] = rules.as_slice() else { return Err(edit_error("This site has no single Redirect for /. Use the configuration editor.")) };1294
let status = rule.args.first().filter(|arg| *arg != "/").map_or(String::new(), |arg| format!("{arg} "));1295
Ok(format!("{}{} {status}/ {target}{}", &text[..rule.start], rule.name, &text[rule.end..]))1296
})?;1297
}1298
if let Some(https) = changes.get("https").filter(|value| value.is_object()) {1299
output = with_vhost(&output, line, |text, vhost| enable_https(text, vhost, https))?;1300
}1301
parsed(&output)?;1302
Ok(output)1303
}1305
/// A second VirtualHost on 443 that repeats this one with its certificate, keeping every directive it holds.1306
fn enable_https(text: &str, vhost: &Node, https: &Value) -> CoreResult<String> {1307
let certificate = rephrase(check_path(field(https, "certificate")))?;1308
let key = rephrase(check_path(field(https, "certificateKey")))?;1309
if vhost.body.as_deref().unwrap_or(&[]).iter().any(|node| node.key == "sslengine") {1310
return Err(edit_error("This site already has SSLEngine."));1311
}1312
let Some(name) = vhost.body.as_deref().unwrap_or(&[]).iter().find(|node| node.key == "servername").and_then(|node| node.args.first()).cloned() else {1313
return Err(edit_error("HTTPS needs a ServerName. Add it first."));1314
};1315
let inside = &text[vhost.inner.0..vhost.inner.1];1316
let indent = format!("{} ", indent_at(text, vhost.start));1317
let outer = indent_at(text, vhost.start);1318
let nodes = parse(inside).map_err(|message| edit_error(message))?;1319
// The new block starts after its names, as a person would write it.1320
let after_names = nodes.iter().filter(|node| node.key == "servername" || node.key == "serveralias").map(|node| next_line(inside, node.end)).max().unwrap_or(0);1321
let tls_block = format!("{}{indent}SSLEngine on\n{indent}SSLCertificateFile {certificate}\n{indent}SSLCertificateKeyFile {key}\n{}", &inside[..after_names], &inside[after_names..]);1322
let copy = format!("\n{outer}<VirtualHost *:443>\n{tls_block}{outer}</VirtualHost>\n");1323
let at = next_line(text, vhost.end);1324
let mut output = format!("{}{copy}{}", &text[..at], &text[at..]);1325
if https.get("redirectHttp") == Some(&Value::Bool(true)) {1326
let parsed_again = parsed(&output)?;1327
let plain = vhost_at(&parsed_again, vhost.line as u64)?;1328
output = insert_in_block(&output, plain, &format!("Redirect permanent / https://{name}/"));1329
}1330
Ok(output)1331
}1333
/// A fix for a finding, as new file text. Only what a reader can check in the diff; anything less clear stays a suggestion.1334
pub fn apply_fix(original: &str, code: &str, line: u64, params: &Value) -> CoreResult<String> {1335
let nodes = parsed(original)?;1336
fn find_line(nodes: &[Node], line: u64) -> Option<&Node> {1337
for node in nodes {1338
if node.line as u64 == line {1339
return Some(node);1340
}1341
if let Some(found) = node.body.as_deref().and_then(|body| find_line(body, line)) {1342
return Some(found);1343
}1344
}1345
None1346
}1347
let moved = || CoreError::new("APACHE_CONFIG_CHANGED", "The directive is no longer at this place in the file; reload the list.");1348
let output = match code {1349
"open-proxy" => {1350
let node = find_line(&nodes, line).filter(|node| node.key == "proxyrequests").ok_or_else(moved)?;1351
format!("{}{} Off{}", &original[..node.start], node.name, &original[node.end..])1352
}1353
"directory-listing" => {1354
let block = find_line(&nodes, line).filter(|node| node.body.is_some()).ok_or_else(moved)?;1355
let options = block.body.as_deref().unwrap_or(&[]).iter().find(|node| node.key == "options").ok_or_else(moved)?;1356
let words: Vec<String> = options.args.iter().map(|word| match word.as_str() { "Indexes" => None, "+Indexes" => Some("-Indexes".to_string()), other => Some(other.to_string()) }).flatten().collect();1357
let values = if words.iter().any(|word| word == "-Indexes") || words.iter().any(|word| word.starts_with(['+', '-'])) || options.args.iter().any(|word| word == "+Indexes") { words } else if words.is_empty() { vec!["None".to_string()] } else { words };1358
format!("{}{} {}{}", &original[..options.start], options.name, values.join(" "), &original[options.end..])1359
}1360
"no-listen" => {1361
let port = params["port"].as_u64().filter(|port| *port > 0 && *port < 65536).ok_or_else(|| invalid("The port is not valid."))?;1362
let anchor = find_line(&nodes, line).filter(|node| node.key == "listen").ok_or_else(moved)?;1363
let at = next_line(original, anchor.end);1364
format!("{}{}Listen {port}\n{}", &original[..at], indent_at(original, anchor.start), &original[at..])1365
}1366
_ => return Err(edit_error("This finding has no automatic fix.")),1367
};1368
parsed(&output)?;1369
Ok(output)1370
}1372
// ---------------------------------------------------------------------------------------------1373
// Findings: a working layout is not a problem. A finding needs a concrete reason.1374
// ---------------------------------------------------------------------------------------------1376
/// Modules active sites need while serving. The configuration test does not see these: with mod_proxy loaded, `ProxyPass`1377
/// to http:// passes it even without mod_proxy_http, and the site then answers with an error.1378
pub fn modules_in_use(sites: &[Value]) -> BTreeMap<String, Vec<String>> {1379
let mut used: BTreeMap<String, Vec<String>> = BTreeMap::new();1380
for site in sites.iter().filter(|site| site["enabled"] == true) {1381
let label = site["domain"].as_str().or(site["file"].as_str()).unwrap_or("").to_string();1382
let mut add = |module: &str| {1383
let names = used.entry(module.to_string()).or_default();1384
if !names.contains(&label) {1385
names.push(label.clone());1386
}1387
};1388
for rule in site["proxies"].as_array().into_iter().flatten().filter(|rule| rule["excluded"] != true) {1389
let target = rule["target"].as_str().unwrap_or("").to_ascii_lowercase();1390
add("proxy_module");1391
for (scheme, module) in [("http://", "proxy_http_module"), ("https://", "proxy_http_module"), ("ws://", "proxy_wstunnel_module"), ("wss://", "proxy_wstunnel_module"), ("fcgi://", "proxy_fcgi_module"), ("ajp://", "proxy_ajp_module"), ("balancer://", "proxy_balancer_module"), ("h2://", "proxy_http2_module"), ("h2c://", "proxy_http2_module")] {1392
if target.starts_with(scheme) {1393
add(module);1394
}1395
}1396
if target.starts_with("https://") {1397
add("ssl_module");1398
}1399
}1400
if site["phpHandler"]["kind"] == "php-fpm" && site["phpInherited"] != true {1401
add("proxy_module");1402
add("proxy_fcgi_module");1403
}1404
if site["sslEnabled"] == true {1405
add("ssl_module");1406
}1407
if site["rewriteEngine"] == true {1408
add("rewrite_module");1409
}1410
}1411
used1412
}1414
pub struct CheckInput<'a> {1415
pub sites: &'a [Value],1416
pub wide: &'a Value,1417
pub facts: &'a ServerFacts,1418
pub evaluation: &'a Evaluation,1419
pub config_errors: &'a [(String, String)],1420
pub paths: &'a BTreeMap<String, String>,1421
pub certificates: &'a [Value],1422
pub htaccess: &'a [(String, String)],1423
pub broken_links: &'a [(String, String)],1424
pub shadows: &'a [Value],1425
}1427
fn finding(level: &str, code: &str, file: &Value, line: &Value, params: Value) -> Value {1428
json!({ "level": level, "code": code, "file": file, "line": line, "params": params })1429
}1431
pub fn findings(input: &CheckInput) -> Vec<Value> {1432
let mut out = Vec::new();1433
let active: Vec<&Value> = input.sites.iter().filter(|site| site["enabled"] == true).collect();1434
let none = Value::Null;1435
if !input.facts.test_ok {1436
out.push(finding("error", "config-test-failed", &none, &none, json!({ "output": input.facts.test_output })));1437
}1438
for (path, message) in input.config_errors {1439
out.push(finding("warning", "not-parsed", &json!(path), &none, json!({ "message": message })));1440
}1441
for item in &input.evaluation.missing {1442
out.push(finding("error", "include-missing", &item["file"], &item["line"], json!({ "pattern": item["pattern"] })));1443
}1444
for item in &input.evaluation.unknown_macros {1445
out.push(finding("warning", "macro-unknown", &item["file"], &item["line"], json!({ "name": item["name"] })));1446
}1447
for (link, target) in input.broken_links {1448
// A wildcard include skips it, so the configuration test passes; a site meant to be on is simply not there.1449
out.push(finding("warning", "broken-link", &json!(link), &none, json!({ "target": target })));1450
}1451
// What this program understood against what Apache reports. A difference means the overview cannot be trusted.1452
if let Some(reported) = &input.facts.includes {1453
let own: BTreeSet<&String> = input.evaluation.included.iter().collect();1454
for path in reported.iter().filter(|path| !own.contains(path)) {1455
out.push(finding("warning", "parser-missed-file", &json!(path), &none, json!({})));1456
}1457
}1458
let own_hosts: BTreeSet<(String, u64)> = active.iter().map(|site| (site["file"].as_str().unwrap_or("").to_string(), site["line"].as_u64().unwrap_or(0))).collect();1459
let macro_files: BTreeSet<&str> = active.iter().filter(|site| site["fromMacro"] == true).filter_map(|site| site["file"].as_str()).collect();1460
for vhost in &input.facts.vhosts {1461
if !own_hosts.contains(&(vhost.file.clone(), vhost.line as u64)) && !macro_files.contains(vhost.file.as_str()) {1462
out.push(finding("warning", "parser-missed-vhost", &json!(vhost.file), &json!(vhost.line), json!({ "name": vhost.name })));1463
}1464
}1465
// The same name twice on one address: Apache quietly uses the first.1466
let mut owners: BTreeMap<(String, String), Vec<&Value>> = BTreeMap::new();1467
for site in &active {1468
for address in site["addresses"].as_array().into_iter().flatten().filter_map(Value::as_str) {1469
for name in names_of(site) {1470
owners.entry((address.to_string(), name)).or_default().push(site);1471
}1472
}1473
}1474
for ((address, name), sites) in owners.iter().filter(|(_, sites)| sites.len() > 1) {1475
let places: Vec<String> = sites.iter().map(|site| format!("{}:{}", site["file"].as_str().unwrap_or(""), site["line"])).collect();1476
out.push(finding("warning", "duplicate-vhost", &sites[1]["file"], &sites[1]["line"], json!({ "name": name, "address": address, "places": places })));1477
}1478
// A virtual host on a port nothing listens on never answers.1479
let listening: BTreeSet<u32> = input.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();1480
for site in &active {1481
for port in site["listeners"].as_array().into_iter().flatten().filter_map(Value::as_u64) {1482
if !listening.is_empty() && !listening.contains(&(port as u32)) && site["role"] != "companion" {1483
let own: Vec<u64> = site["addresses"].as_array().into_iter().flatten().filter_map(Value::as_str).filter_map(port_of).map(u64::from).collect();1484
if own.contains(&port) {1485
out.push(finding("warning", "no-listen", &site["file"], &site["line"], json!({ "port": port, "site": site["domain"] })));1486
}1487
}1488
}1489
}1490
if !input.facts.modules.is_empty() {1491
let loaded: BTreeSet<&str> = input.facts.modules.iter().map(|(name, _)| name.as_str()).collect();1492
for (module, names) in modules_in_use(input.sites) {1493
if !loaded.contains(module.as_str()) {1494
let holder = active.iter().find(|site| site["domain"].as_str().or(site["file"].as_str()) == names.first().map(String::as_str));1495
out.push(finding("error", "module-missing", &holder.map_or(none.clone(), |site| site["file"].clone()), &holder.map_or(none.clone(), |site| site["line"].clone()), json!({ "module": module, "sites": names })));1496
}1497
}1498
}1499
let state = |path: &str| input.paths.get(path).map(String::as_str);1500
let proxy_requests = input.wide["proxyRequests"].clone();1501
if proxy_requests["value"].as_str().is_some_and(|value| value.eq_ignore_ascii_case("on")) {1502
out.push(finding("warning", "open-proxy", &proxy_requests["file"], &proxy_requests["line"], json!({})));1503
}1504
for site in &active {1505
let name = site["domain"].clone();1506
if let Some(root) = site["documentRoot"].as_str().filter(|_| site["type"] != "redirect" && site["type"] != "proxy") {1507
if state(root) == Some("missing") {1508
out.push(finding("warning", "docroot-missing", &site["file"], &site["line"], json!({ "site": name, "path": root })));1509
}1510
}1511
if let Some(path) = site["certificate"].as_str().filter(|_| site["sslEnabled"] == true) {1512
if state(path) == Some("missing") {1513
out.push(finding("error", "certificate-missing", &site["file"], &site["line"], json!({ "site": name, "path": path })));1514
}1515
}1516
if site["sslEnabled"] == true && site["certificate"].is_null() {1517
out.push(finding("warning", "certificate-not-set", &site["file"], &site["line"], json!({ "site": name })));1518
}1519
if site["sslEnabled"] == true && site["domain"].is_null() {1520
out.push(finding("recommendation", "tls-without-name", &site["file"], &site["line"], json!({})));1521
}1522
if let Some(protocol) = site["sslProtocol"].as_str() {1523
let lower = protocol.to_ascii_lowercase();1524
let enabled_old = lower.split_whitespace().any(|word| matches!(word, "sslv3" | "+sslv3" | "tlsv1" | "+tlsv1" | "tlsv1.1" | "+tlsv1.1"));1525
if enabled_old {1526
out.push(finding("warning", "old-tls", &site["file"], &site["line"], json!({ "site": name, "value": protocol })));1527
}1528
}1529
if let Some(socket) = site["phpHandler"]["target"].as_str().and_then(|target| target.split("unix:").nth(1)).map(|rest| rest.split('|').next().unwrap_or(rest)) {1530
if state(socket) == Some("missing") {1531
out.push(finding("error", "php-socket-missing", &site["phpHandler"]["file"], &site["phpHandler"]["line"], json!({ "site": name, "path": socket })));1532
}1533
}1534
for block in site["access"].as_array().into_iter().flatten() {1535
if block["legacy"] == true {1536
out.push(finding("recommendation", "legacy-access", &block["file"], &block["line"], json!({ "path": block["path"] })));1537
}1538
if block["options"].as_str().is_some_and(|options| options.split_whitespace().any(|word| word == "Indexes" || word == "+Indexes")) {1539
out.push(finding("recommendation", "directory-listing", &block["file"], &block["line"], json!({ "path": block["path"] })));1540
}1541
}1542
if site["role"] == "site" && site["sslEnabled"] != true && site["redirectsHttp"] != true && !active.iter().any(|other| other["sslEnabled"] == true && names_of(other).iter().any(|other_name| names_of(site).contains(other_name))) {1543
out.push(finding("recommendation", "http-only", &site["file"], &site["line"], json!({ "site": name })));1544
}1545
}1546
for certificate in input.certificates {1547
match certificate["daysLeft"].as_i64() {1548
Some(days) if days < 0 => out.push(finding("error", "certificate-expired", &certificate["path"], &none, json!({ "days": days }))),1549
Some(days) if days < 21 => out.push(finding("warning", "certificate-expiring", &certificate["path"], &none, json!({ "days": days }))),1550
_ => {}1551
}1552
}1553
// .htaccess that Apache ignores, because the directory above it says AllowOverride None.1554
for (path, _) in input.htaccess {1555
let site = active.iter().filter_map(|site| site["documentRoot"].as_str().filter(|root| path.starts_with(&format!("{}/", root.trim_end_matches('/')))).map(|root| (*site, root))).max_by_key(|(_, root)| root.len());1556
let Some((site, root)) = site else { continue };1557
let blocks = site["access"].as_array().cloned().unwrap_or_default();1558
let global = input.wide["access"].as_array().cloned().unwrap_or_default();1559
let governing = blocks.iter().chain(global.iter()).filter(|block| block["section"].as_str().is_some_and(|section| section.eq_ignore_ascii_case("directory")) && block["allowOverride"].is_string()).filter_map(|block| block["path"].as_str().map(|dir| (dir.trim_end_matches('/').len(), block))).filter(|(_, block)| block["path"].as_str().is_some_and(|dir| root.starts_with(dir.trim_end_matches('/')))).max_by_key(|(length, _)| *length);1560
if governing.is_some_and(|(_, block)| block["allowOverride"].as_str().is_some_and(|value| value.eq_ignore_ascii_case("none"))) {1561
out.push(finding("warning", "htaccess-ignored", &json!(path), &none, json!({ "site": site["domain"] })));1562
}1563
}1564
for shadow in input.shadows.iter().filter(|shadow| shadow["identical"] == false) {1565
out.push(finding("recommendation", "twin-differs", &shadow["path"], &none, json!({ "loaded": shadow["loaded"] })));1566
}1567
if input.wide["serverName"].is_null() {1568
out.push(finding("recommendation", "no-server-name", &none, &none, json!({})));1569
}1570
let order = |level: &str| match level { "error" => 0, "warning" => 1, _ => 2 };1571
out.sort_by_key(|item| order(item["level"].as_str().unwrap_or("")));1572
out1573
}1575
#[cfg(test)]1576
mod tests {1577
use super::*;1579
fn facts(modules: &[&str]) -> ServerFacts {1580
ServerFacts { version: (2, 4, 58), server_root: "/etc/apache2".into(), main_config: "/etc/apache2/apache2.conf".into(), modules: modules.iter().map(|name| (name.to_string(), "shared".to_string())).collect(), test_ok: true, ..Default::default() }1581
}1583
fn evaluated(files: &[(&str, &str)], modules: &[&str]) -> (Config, Evaluation) {1584
let config = Config::new(files.iter().map(|(path, text)| (path.to_string(), text.to_string())).collect(), facts(modules));1585
assert!(config.errors.is_empty(), "{:?}", config.errors);1586
let evaluation = config.evaluate();1587
(config, evaluation)1588
}1590
#[test]1591
fn parser_keeps_lines_case_continuations_and_comments() {1592
let text = "# comment\nServerName a.test # not a comment\nProxyPass \"/a b\" \\\n http://x/ retry=0\n<IfModule mod_ssl.c>\n <VirtualHost *:443>\n SSLEngine on\n </virtualhost>\n</IfModule>\n";1593
let nodes = parse(text).unwrap();1594
assert_eq!(nodes[0].args, ["a.test", "#", "not", "a", "comment"]);1595
assert_eq!(nodes[1].line, 3);1596
assert_eq!(nodes[1].args, ["/a b", "http://x/", "retry=0"]);1597
let vhost = &nodes[2].body.as_ref().unwrap()[0];1598
assert_eq!((vhost.key.as_str(), vhost.line), ("virtualhost", 6));1599
assert_eq!(&text[nodes[0].start..nodes[0].start + 10], "ServerName");1600
for bad in ["<VirtualHost *:80>\n", "</VirtualHost>\n", "<Directory /a>\n</Files>\n", "<Directory /a\n"] {1601
assert!(parse(bad).is_err(), "{bad}");1602
}1603
}1605
#[test]1606
fn real_command_outputs_are_understood() {1607
let mut found = ServerFacts::default();1608
parse_build("Server version: Apache/2.4.63 (Rocky Linux)\nServer MPM: event\n -D HTTPD_ROOT=\"/etc/httpd\"\n -D SERVER_CONFIG_FILE=\"conf/httpd.conf\"\n", &mut found);1609
assert_eq!((found.version, found.mpm.as_str(), found.main_config.as_str()), ((2, 4, 63), "event", "/etc/httpd/conf/httpd.conf"));1610
parse_run_config("AH00558: httpd: Could not reliably determine\nServerRoot: \"/var/www\"\nMain DocumentRoot: \"/var/www/localhost/htdocs\"\nDefine: DUMP_RUN_CFG\nDefine: SSL\n", &mut found);1611
assert_eq!(found.server_root, "/var/www");1612
assert_eq!(found.defines.iter().collect::<Vec<_>>(), ["SSL"]);1613
assert_eq!(parse_modules("AH00558: x\nLoaded Modules:\n core_module (static)\n ssl_module (shared)\n"), [("core_module".to_string(), "static".to_string()), ("ssl_module".to_string(), "shared".to_string())]);1614
let includes = parse_includes("Included configuration files:\n (*) /etc/apache2/httpd.conf\n (482) /etc/apache2/conf.d/a.conf\nAH00558: x\n").unwrap();1615
assert_eq!(includes, ["/etc/apache2/httpd.conf", "/etc/apache2/conf.d/a.conf"]);1616
assert!(parse_includes("Passing arguments to httpd using apachectl is no longer supported.").is_none());1617
let hosts = parse_vhost_report("AH00558: x\nVirtualHost configuration:\n*:443 is a NameVirtualHost\n default server a.test (/etc/s/a.conf:2)\n port 443 namevhost a.test (/etc/s/a.conf:2)\n alias www.a.test\n port 443 namevhost b.test (/etc/s/b.conf:1)\n*:80 c.test (/etc/s/c.conf:1)\nServerRoot: \"/etc\"\n");1618
assert_eq!(hosts.len(), 3, "{hosts:?}");1619
assert!(hosts[0].is_default && !hosts[1].is_default && hosts[2].is_default, "{hosts:?}");1620
assert_eq!(hosts[0].aliases, ["www.a.test"]);1621
assert_eq!((hosts[2].address.as_str(), hosts[2].name.as_str(), hosts[2].line), ("*:80", "c.test", 1));1622
}1624
const MAIN: &str = "ServerName host.test\nListen 80\nListen 443\nDefine LOGS /var/log/x\n<IfModule mod_ssl.c>\nIncludeOptional sites-enabled/*.conf\n</IfModule>\n<IfModule mod_absent.c>\nInclude absent.conf\n</IfModule>\n<IfVersion >= 2.4>\nInclude conf.d\n</IfVersion>\n<IfVersion < 2.0>\nInclude old.conf\n</IfVersion>\n";1626
#[test]1627
fn includes_conditions_and_variables_follow_apache() {1628
let (_, evaluation) = evaluated(&[1629
("/etc/apache2/apache2.conf", MAIN),1630
("/etc/apache2/sites-enabled/a.conf", "<VirtualHost *:80>\nServerName a.test\nDocumentRoot /var/www/a\nErrorLog ${LOGS}/e.log\n</VirtualHost>\n"),1631
("/etc/apache2/conf.d/deep/b.conf", "ServerAdmin x\n"),1632
("/etc/apache2/absent.conf", "<VirtualHost *:80>\nServerName ghost.test\n</VirtualHost>\n"),1633
("/etc/apache2/old.conf", "<VirtualHost *:80>\nServerName old.test\n</VirtualHost>\n"),1634
], &["ssl_module"]);1635
let sites = collect_sites(&evaluation.items, true, "");1636
assert_eq!(sites.len(), 1, "{sites:?}");1637
assert_eq!(sites[0]["domain"], "a.test");1638
assert_eq!(sites[0]["errorLog"], "/var/log/x/e.log");1639
assert_eq!(sites[0]["file"], "/etc/apache2/sites-enabled/a.conf");1640
assert!(evaluation.included.contains(&"/etc/apache2/conf.d/deep/b.conf".to_string()), "a directory include is recursive");1641
assert!(!evaluation.included.contains(&"/etc/apache2/absent.conf".to_string()));1642
}1644
#[test]1645
fn missing_include_macros_and_runtime_conditions() {1646
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", "Include nothing.conf\nIncludeOptional nothing2.conf\n<Macro Site $name $root>\n<VirtualHost *:80>\nServerName $name\nDocumentRoot $root\n</VirtualHost>\n</Macro>\nUse Site m.test /var/www/m\nUse Unknown x\n<VirtualHost *:80>\nServerName i.test\n<If \"%{HTTPS} == 'on'\">\nRequire all denied\n</If>\n</VirtualHost>\n")], &[]);1647
assert_eq!(evaluation.missing.len(), 1);1648
assert_eq!(evaluation.missing[0]["pattern"], "nothing.conf");1649
assert_eq!(evaluation.unknown_macros[0]["name"], "Unknown");1650
let sites = collect_sites(&evaluation.items, true, "");1651
assert_eq!(sites[0]["domain"], "m.test");1652
assert_eq!(sites[0]["documentRoot"], "/var/www/m");1653
assert_eq!(sites[0]["fromMacro"], true);1654
assert_eq!(sites[0]["line"], 9, "a macro points at its Use line");1655
}1657
const HOSTS: &str = "<VirtualHost *:80>\nServerName a.test\nServerAlias www.a.test\nRewriteEngine On\nRewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [L,R=301]\n</VirtualHost>\n<VirtualHost *:443>\nServerName a.test\nServerAlias www.a.test\nDocumentRoot /var/www/a\nSSLEngine on\nSSLCertificateFile /c.pem\nSSLCertificateKeyFile /k.pem\n<Directory /var/www/a>\nAllowOverride None\nOptions +Indexes\nRequire all granted\n</Directory>\n<FilesMatch \\.php$>\nSetHandler \"proxy:unix:/run/php/php.sock|fcgi://localhost\"\n</FilesMatch>\nProxyPass /api http://127.0.0.1:3000/\n</VirtualHost>\n<VirtualHost *:80>\nServerName app.test\nProxyPass / http://127.0.0.1:3001/\nProxyPassReverse / http://127.0.0.1:3001/\n</VirtualHost>\n<VirtualHost *:80>\nServerName old.test\nRedirect permanent / https://elsewhere.example/\n</VirtualHost>\n<VirtualHost *:80>\nServerName lb.test\nProxyPass / balancer://pool/\n<Proxy balancer://pool>\nBalancerMember http://10.0.0.1:81\nBalancerMember http://10.0.0.2:81\n</Proxy>\n</VirtualHost>\n";1659
fn host_sites() -> (Vec<Value>, Value, Evaluation) {1660
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", &format!("Listen 80\nListen 443\n{HOSTS}"))], &[]);1661
let mut sites = collect_sites(&evaluation.items, true, "/var/www/html");1662
assign_roles(&mut sites);1663
let wide = server_wide(&evaluation.items);1664
(sites, wide, evaluation)1665
}1667
#[test]1668
fn blocks_become_webs() {1669
let (sites, wide, _) = host_sites();1670
let roles: Vec<&str> = sites.iter().map(|site| site["role"].as_str().unwrap()).collect();1671
assert_eq!(roles, ["companion", "site", "site", "redirect", "site"], "{sites:?}");1672
let main = &sites[1];1673
assert_eq!(main["type"], "php", "a proxied /api does not make it a reverse proxy");1674
assert_eq!(main["redirectsHttp"], true);1675
assert_eq!(main["listeners"], json!([80, 443]));1676
assert_eq!(main["phpHandler"]["kind"], "php-fpm");1677
assert_eq!(main["proxies"][0]["path"], "/api");1678
assert_eq!(main["access"][0]["allowOverride"], "None");1679
assert_eq!(sites[2]["type"], "proxy");1680
assert_eq!(sites[2]["documentRoot"], "/var/www/html");1681
assert_eq!(sites[2]["rootInherited"], true);1682
assert_eq!(sites[3]["type"], "redirect");1683
let targets: Vec<String> = backend_targets(&sites, &wide).iter().map(|item| item["target"].as_str().unwrap().to_string()).collect();1684
for expected in ["127.0.0.1:3000", "127.0.0.1:3001", "unix:/run/php/php.sock", "10.0.0.1:81", "10.0.0.2:81"] {1685
assert!(targets.contains(&expected.to_string()), "{expected}: {targets:?}");1686
}1687
}1689
fn codes(paths: &BTreeMap<String, String>, certificates: &[Value], htaccess: &[(String, String)], broken_links: &[(String, String)], shadows: &[Value]) -> Vec<(String, String)> {1690
let (sites, wide, evaluation) = host_sites();1691
let mut fact = facts(&[]);1692
fact.vhosts = sites.iter().map(|site| ReportedVhost { file: site["file"].as_str().unwrap().to_string(), line: site["line"].as_u64().unwrap() as usize, ..Default::default() }).collect();1693
let input = CheckInput { sites: &sites, wide: &wide, facts: &fact, evaluation: &evaluation, config_errors: &[], paths, certificates, htaccess, broken_links, shadows };1694
findings(&input).iter().map(|item| (item["level"].as_str().unwrap().to_string(), item["code"].as_str().unwrap().to_string())).collect()1695
}1697
#[test]1698
fn a_working_unusual_layout_gives_no_warning() {1699
let found = codes(&BTreeMap::new(), &[], &[], &[], &[]);1700
assert!(found.iter().all(|(level, _)| level == "recommendation"), "{found:?}");1701
}1703
#[test]1704
fn each_problem_has_its_level_and_reason() {1705
let present = |found: &[(String, String)], level: &str, code: &str| found.iter().any(|(l, c)| l == level && c == code);1706
let paths = BTreeMap::from([("/var/www/a".to_string(), "missing".to_string()), ("/c.pem".to_string(), "missing".to_string()), ("/run/php/php.sock".to_string(), "missing".to_string())]);1707
let certificates = [json!({ "path": "/c.pem", "daysLeft": 5 }), json!({ "path": "/o.pem", "daysLeft": -1 })];1708
let htaccess = [("/var/www/a/.htaccess".to_string(), "x".to_string())];1709
let broken = [("/etc/apache2/sites-enabled/gone".to_string(), "/x".to_string())];1710
let shadows = [json!({ "path": "/a", "loaded": "/b", "identical": false })];1711
let found = codes(&paths, &certificates, &htaccess, &broken, &shadows);1712
for (level, code) in [("warning", "docroot-missing"), ("error", "certificate-missing"), ("error", "php-socket-missing"), ("warning", "certificate-expiring"), ("error", "certificate-expired"), ("warning", "htaccess-ignored"), ("warning", "broken-link"), ("recommendation", "twin-differs"), ("recommendation", "directory-listing")] {1713
assert!(present(&found, level, code), "{level} {code}: {found:?}");1714
}1715
assert_eq!(found[0].0, "error", "errors come first");1716
}1718
#[test]1719
fn parser_and_apache_disagreeing_is_reported() {1720
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", "Listen 80\nServerName x\n")], &[]);1721
let sites = collect_sites(&evaluation.items, true, "");1722
let wide = server_wide(&evaluation.items);1723
let mut fact = facts(&[]);1724
fact.includes = Some(vec!["/etc/apache2/apache2.conf".into(), "/etc/apache2/other.conf".into()]);1725
fact.vhosts = vec![ReportedVhost { name: "hidden.test".into(), file: "/etc/apache2/other.conf".into(), line: 3, ..Default::default() }];1726
let paths = BTreeMap::new();1727
let input = CheckInput { sites: &sites, wide: &wide, facts: &fact, evaluation: &evaluation, config_errors: &[], paths: &paths, certificates: &[], htaccess: &[], broken_links: &[], shadows: &[] };1728
let found = findings(&input);1729
assert!(found.iter().any(|item| item["code"] == "parser-missed-file" && item["file"] == "/etc/apache2/other.conf"), "{found:?}");1730
assert!(found.iter().any(|item| item["code"] == "parser-missed-vhost" && item["params"]["name"] == "hidden.test"), "{found:?}");1731
}1733
#[test]1734
fn duplicate_names_old_tls_and_open_proxy_are_warnings() {1735
let text = "Listen 80\nListen 443\nProxyRequests On\nServerName host\n<VirtualHost *:80>\nServerName d.test\nDocumentRoot /a\n</VirtualHost>\n<VirtualHost *:80>\nServerName d.test\nDocumentRoot /b\n</VirtualHost>\n<VirtualHost *:8080>\nServerName p.test\nDocumentRoot /c\n</VirtualHost>\n<VirtualHost *:443>\nServerName t.test\nDocumentRoot /d\nSSLEngine on\nSSLCertificateFile /c\nSSLProtocol all +TLSv1\n</VirtualHost>\n";1736
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", text)], &[]);1737
let mut sites = collect_sites(&evaluation.items, true, "");1738
assign_roles(&mut sites);1739
let wide = server_wide(&evaluation.items);1740
let fact = facts(&[]);1741
let paths = BTreeMap::new();1742
let input = CheckInput { sites: &sites, wide: &wide, facts: &fact, evaluation: &evaluation, config_errors: &[], paths: &paths, certificates: &[], htaccess: &[], broken_links: &[], shadows: &[] };1743
let found = findings(&input);1744
let code = |name: &str| found.iter().find(|item| item["code"] == name).unwrap_or_else(|| panic!("{name}: {found:?}"));1745
assert_eq!(code("duplicate-vhost")["params"]["name"], "d.test");1746
assert_eq!(code("duplicate-vhost")["level"], "warning");1747
assert_eq!(code("no-listen")["params"]["port"], 8080);1748
assert_eq!(code("old-tls")["level"], "warning");1749
assert_eq!(code("open-proxy")["line"], 3);1750
}1752
#[test]1753
fn a_spare_copy_of_the_main_file_adds_no_sites() {1754
let main = "IncludeOptional sites/*.conf\n";1755
let (config, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", main), ("/etc/apache2/apache2.conf.bak", main), ("/etc/apache2/sites/a.conf", "<VirtualHost *:80>\nServerName a.test\n</VirtualHost>\n"), ("/etc/apache2/spare/b.conf", "<VirtualHost *:80>\nServerName b.test\n</VirtualHost>\n")], &[]);1756
assert!(config.evaluate_inactive("/etc/apache2/apache2.conf.bak", &evaluation).is_empty());1757
assert_eq!(collect_sites(&config.evaluate_inactive("/etc/apache2/spare/b.conf", &evaluation), false, "")[0]["domain"], "b.test");1758
}1760
#[test]1761
fn layout_follows_real_includes() {1762
let debian = detect_layout(&["/etc/apache2/mods-enabled/*.load".into(), "/etc/apache2/sites-enabled/*.conf".into()]).unwrap();1763
assert_eq!((debian.directory.as_str(), debian.enabled.as_deref()), ("/etc/apache2/sites-available", Some("/etc/apache2/sites-enabled")));1764
assert_eq!(detect_layout(&["/etc/httpd/conf.d/*.conf".into()]).unwrap().enabled, None);1765
assert_eq!(detect_layout(&["/opt/web/vhosts/*.conf".into()]).unwrap().directory, "/opt/web/vhosts");1766
assert!(detect_layout(&["/etc/httpd/conf.modules.d/*.conf".into(), "/etc/x/one.conf".into()]).is_none());1767
}1769
fn generated(extra: Value) -> SiteRequest {1770
let mut input = json!({ "type": "static", "serverNames": "a.test www.a.test", "root": "/var/www/a" });1771
for (key, value) in extra.as_object().unwrap() {1772
input[key] = value.clone();1773
}1774
SiteRequest::from_value(&input).unwrap()1775
}1777
#[test]1778
fn every_template_renders_a_parsable_site() {1779
for template in [json!({}), json!({ "type": "php", "phpBackend": "unix:/run/php/x.sock" }), json!({ "type": "wordpress", "phpBackend": "127.0.0.1:9000" }), json!({ "type": "proxy", "backend": "http://127.0.0.1:3000" }), json!({ "type": "websocket", "backend": "http://127.0.0.1:3000/" }), json!({ "type": "redirect", "redirectTarget": "https://b.test/" })] {1780
for https in [false, true] {1781
let mut extra = template.clone();1782
extra["securityHeaders"] = json!(true);1783
if https {1784
extra["https"] = json!(true);1785
extra["certificate"] = json!("/c.pem");1786
extra["certificateKey"] = json!("/k.pem");1787
extra["redirectHttp"] = json!(true);1788
}1789
let request = generated(extra);1790
let text = render_site(&request, "/var/log/apache2");1791
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", &text)], &[]);1792
let mut sites = collect_sites(&evaluation.items, true, "");1793
assign_roles(&mut sites);1794
assert_eq!(sites.len(), if https { 2 } else { 1 }, "{text}");1795
let main = &sites[sites.len() - 1];1796
assert_eq!(main["domain"], "a.test", "{text}");1797
assert_eq!(main["sslEnabled"], https, "{text}");1798
assert!(!text.contains("AllowOverride All"), "{text}");1799
if https && template["type"] != "redirect" {1800
assert_eq!(sites[0]["role"], "companion", "{text}");1801
}1802
}1803
}1804
assert!(render_site(&generated(json!({ "type": "proxy", "backend": "http://127.0.0.1:3000" })), "/l").contains("ProxyRequests Off"));1805
}1807
#[test]1808
fn generated_sites_refuse_hostile_values_and_name_their_modules() {1809
for bad in [json!({ "type": "shell" }), json!({ "serverNames": "a.test; x" }), json!({ "root": "/etc/x" }), json!({ "root": "/var/www/a\nInclude /etc/shadow" }), json!({ "type": "proxy", "backend": "http://a b" }), json!({ "type": "php", "phpBackend": "x;y" }), json!({ "https": true, "certificate": "../c", "certificateKey": "/k" })] {1810
let mut input = json!({ "type": "static", "serverNames": "a.test", "root": "/var/www/a" });1811
for (key, value) in bad.as_object().unwrap() {1812
input[key] = value.clone();1813
}1814
assert_eq!(SiteRequest::from_value(&input).unwrap_err().code, "APACHE_SITE_INVALID", "{bad}");1815
}1816
assert_eq!(generated(json!({ "type": "proxy", "backend": "http://x" })).required_modules(), ["proxy_module", "proxy_http_module"]);1817
}1819
#[test]1820
fn templates_fill_checked_values_and_refuse_unknown_placeholders() {1821
let site = |template: &str| SiteRequest::from_value(&json!({ "type": "custom", "serverNames": "a.test b.test", "root": "/var/www/a", "port": 8080, "template": template })).unwrap();1822
let text = render_template(&site("<VirtualHost *:{{port}}>\nServerName {{domain}}\nServerAlias {{aliases}}\nDocumentRoot {{root}}\nErrorLog {{logs}}/x.log\nMyDirective keep\n</VirtualHost>\n"), "/var/log/x").unwrap();1823
assert!(text.contains("<VirtualHost *:8080>\nServerName a.test\nServerAlias b.test\nDocumentRoot /var/www/a\nErrorLog /var/log/x/x.log\nMyDirective keep"), "{text}");1824
for bad in ["<VirtualHost *:80>\nX {{nope}}\n</VirtualHost>\n", "<VirtualHost *:80>\nX {{backend}}\n</VirtualHost>\n", "Listen 80\n", "<VirtualHost *:80>\n", ""] {1825
assert_eq!(render_template(&site(bad), "/l").unwrap_err().code, "APACHE_SITE_INVALID", "{bad}");1826
}1827
}1829
const EDITED: &str = "# keep\nListen 80\n<IfModule mod_x.c>\n<VirtualHost *:80>\n ServerName a.test # odd\n ServerAlias www.a.test\n DocumentRoot /var/www/a\n <Directory /var/www/a/>\n Options +Indexes +FollowSymLinks\n MyUnknown stays\n </Directory>\n ProxyPass /api http://127.0.0.1:1/\n # note\n ProxyPass / http://127.0.0.1:2/ retry=0\n ProxyPassReverse / http://127.0.0.1:2/\n</VirtualHost>\n</IfModule>\n";1831
#[test]1832
fn edits_keep_everything_they_do_not_change() {1833
let edited = apply_edit(EDITED, 4, &json!({ "documentRoot": "/srv/new", "backend": "http://10.0.0.1:9", "aliases": "" })).unwrap();1834
let expected = EDITED.replace(" ServerAlias www.a.test\n", "").replace("DocumentRoot /var/www/a", "DocumentRoot /srv/new").replace("<Directory /var/www/a/>", "<Directory /srv/new/>").replace("ProxyPass / http://127.0.0.1:2/ retry=0", "ProxyPass / http://10.0.0.1:9/ retry=0").replace("ProxyPassReverse / http://127.0.0.1:2/", "ProxyPassReverse / http://10.0.0.1:9/");1835
assert_eq!(edited, expected);1836
assert_eq!(apply_edit(EDITED, 9, &json!({ "documentRoot": "/srv/x" })).unwrap_err().code, "APACHE_CONFIG_CHANGED");1837
assert_eq!(apply_edit(EDITED, 4, &json!({ "documentRoot": "/etc/x" })).unwrap_err().code, "APACHE_SITE_INVALID");1838
assert_eq!(apply_edit("<VirtualHost *:80>\nServerName a\nServerName b\n</VirtualHost>\n", 1, &json!({ "serverName": "c.test" })).unwrap_err().code, "APACHE_EDIT_UNSUPPORTED");1839
let added = apply_edit("<VirtualHost *:80>\n ServerName a.test\n</VirtualHost>\n", 1, &json!({ "documentRoot": "/srv/d" })).unwrap();1840
assert!(added.contains(" ServerName a.test\n DocumentRoot /srv/d\n"), "{added}");1841
}1843
#[test]1844
fn enabling_https_repeats_the_site_and_keeps_its_directives() {1845
let edited = apply_edit(EDITED, 4, &json!({ "https": { "certificate": "/c.pem", "certificateKey": "/k.pem", "redirectHttp": true } })).unwrap();1846
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", &edited)], &["x_module"]);1847
let sites = collect_sites(&evaluation.items, true, "");1848
assert_eq!(sites.len(), 2, "{edited}");1849
assert_eq!(sites[1]["sslEnabled"], true);1850
assert_eq!(sites[1]["addresses"][0], "*:443");1851
assert!(edited.matches("MyUnknown stays").count() == 2, "{edited}");1852
assert!(edited.contains("Redirect permanent / https://a.test/"), "{edited}");1853
assert_eq!(apply_edit("<VirtualHost *:443>\nServerName a\nSSLEngine on\n</VirtualHost>\n", 1, &json!({ "https": { "certificate": "/c", "certificateKey": "/k" } })).unwrap_err().code, "APACHE_EDIT_UNSUPPORTED");1854
}1856
#[test]1857
fn fixes_change_only_the_named_directive() {1858
let open = "Listen 80\nProxyRequests On # odd\n";1859
assert_eq!(apply_fix(open, "open-proxy", 2, &json!({})).unwrap(), "Listen 80\nProxyRequests Off # odd\n".replace(" # odd", ""));1860
assert_eq!(apply_fix(open, "no-listen", 1, &json!({ "port": 8099 })).unwrap(), "Listen 80\nListen 8099\nProxyRequests On # odd\n");1861
let listing = apply_fix(EDITED, "directory-listing", 8, &json!({})).unwrap();1862
assert_eq!(listing, EDITED.replace("Options +Indexes +FollowSymLinks", "Options -Indexes +FollowSymLinks"));1863
assert_eq!(apply_fix("Options Indexes\n", "open-proxy", 1, &json!({})).unwrap_err().code, "APACHE_CONFIG_CHANGED");1864
assert_eq!(apply_fix(open, "duplicate-vhost", 1, &json!({})).unwrap_err().code, "APACHE_EDIT_UNSUPPORTED");1865
}1867
#[test]1868
fn a_module_a_site_needs_is_known_although_the_configuration_test_passes() {1869
let (sites, ..) = host_sites();1870
let used = modules_in_use(&sites);1871
assert!(used["proxy_http_module"].contains(&"app.test".to_string()), "{used:?}");1872
assert!(used["proxy_fcgi_module"].contains(&"a.test".to_string()), "{used:?}");1873
assert!(used["ssl_module"].contains(&"a.test".to_string()), "{used:?}");1874
assert!(used["proxy_balancer_module"].contains(&"lb.test".to_string()), "{used:?}");1875
assert!(used["rewrite_module"].contains(&"a.test".to_string()) || !sites.iter().any(|site| site["rewriteEngine"] == true && site["enabled"] == true), "{used:?}");1876
let (sites, wide, evaluation) = host_sites();1877
let fact = facts(&["proxy_module", "ssl_module", "rewrite_module", "proxy_fcgi_module", "proxy_balancer_module"]);1878
let paths = BTreeMap::new();1879
let input = CheckInput { sites: &sites, wide: &wide, facts: &fact, evaluation: &evaluation, config_errors: &[], paths: &paths, certificates: &[], htaccess: &[], broken_links: &[], shadows: &[] };1880
let found = findings(&input);1881
let missing = found.iter().find(|item| item["code"] == "module-missing").unwrap_or_else(|| panic!("{found:?}"));1882
assert_eq!((missing["level"].as_str(), missing["params"]["module"].as_str()), (Some("error"), Some("proxy_http_module")));1883
}1885
#[test]1886
fn ipv6_addresses_are_read_and_reported_correctly() {1887
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", "Listen [::]:80\nListen [2001:db8::1]:8080\n<VirtualHost [::]:80>\nServerName six.test\nDocumentRoot /a\n</VirtualHost>\n<VirtualHost [2001:db8::1]:8080>\nServerName literal.test\nDocumentRoot /b\n</VirtualHost>\n<VirtualHost [2001:db8::1]>\nServerName noport.test\nDocumentRoot /c\n</VirtualHost>\n")], &[]);1888
let sites = collect_sites(&evaluation.items, true, "");1889
assert_eq!(sites[0]["listeners"], json!([80]));1890
assert_eq!(sites[1]["listeners"], json!([8080]));1891
assert_eq!(sites[2]["listeners"], json!([]), "the last group of an address is not a port");1892
let wide = server_wide(&evaluation.items);1893
let fact = facts(&[]);1894
let paths = BTreeMap::new();1895
let input = CheckInput { sites: &sites, wide: &wide, facts: &fact, evaluation: &evaluation, config_errors: &[], paths: &paths, certificates: &[], htaccess: &[], broken_links: &[], shadows: &[] };1896
assert!(!findings(&input).iter().any(|item| item["code"] == "no-listen"), "IPv6 Listen lines count");1897
let hosts = parse_vhost_report("VirtualHost configuration:\n[2001:db8::1]:8080 literal.test (/a.conf:7)\n[::]:80 six.test (/a.conf:3)\nServerRoot: \"/etc\"\n");1898
assert_eq!((hosts[0].address.as_str(), hosts[1].address.as_str()), ("[2001:db8::1]:8080", "[::]:80"));1899
}1901
#[test]1902
fn logs_skip_pipes_and_variables() {1903
let (_, evaluation) = evaluated(&[("/etc/apache2/apache2.conf", "ErrorLog logs/e.log\nCustomLog \"|/usr/bin/rotatelogs x\" combined\nCustomLog /var/log/a.log combined\nCustomLog /var/log/${UNKNOWN}/b.log combined\n")], &[]);1904
assert_eq!(log_paths(&evaluation.items, "/etc/apache2").into_iter().collect::<Vec<_>>(), ["/etc/apache2/logs/e.log", "/var/log/a.log"]);1905
}1906
}SHA-256
e47a4095f3ddec32d42fd019bf27cf0ae5a49b5cfd5aac54fa422ec15b6e317eCH-J Proprietary Software License 1.14 · ソースは CH-J Proprietary Software License 1.14 に基づいて提供されています。公開されていても、ライセンス条項が変わったり、追加の権利が付与されたりすることはありません。
アーカイブの SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f