CH-J Toolsupravljanje strežnikov prek SSH
Meni
Objavljen vir

CH-J Tools

Brskajte po imenikih in datotekah za določeno izdajo programa.

Prenos vir ZIP
CH-J / Server Manager 1704 datoteke
0.1.2-beta Dodano: · Zgrajena:
apache_conf.rs Dodano: Datum, ki ni zabeležen 104,8 KB · 1906 vrstice
Oznaka Prenesi datoteko
1//! Apache HTTP Server configuration as text: a parser that keeps files, lines and order, the configuration as Apache
2//! evaluates it, and what follows from it. Nothing here talks to a server or changes anything.
4use crate::core::error::{CoreError, CoreResult};
5use crate::core::ssh::nginx_conf::{check_backend, check_host, check_path, check_proxy_url, check_root};
6use once_cell::sync::Lazy;
7use regex::Regex;
8use serde_json::{json, Value};
9use std::collections::{BTreeMap, BTreeSet};
11const MAX_DEPTH: usize = 32;
13#[derive(Debug, Clone)]
14pub 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),
27/// Words of a directive line. A quote only opens at the start of a word; a `#` inside a line is not a comment.
28fn 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 out
67pub 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)
142// ---------------------------------------------------------------------------------------------
143// Output of the server's own commands
144// ---------------------------------------------------------------------------------------------
146#[derive(Debug, Clone, Default)]
147pub 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,
165#[derive(Debug, Clone, Default)]
166pub 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>,
175pub 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)))
180pub 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 }
199pub 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 }
217pub 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()
222/// `None` when this build does not know DUMP_INCLUDES.
223pub 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())
231/// `-S`: the virtual hosts Apache itself sees, the first one on an address being the default.
232pub 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 found
277// ---------------------------------------------------------------------------------------------
278// The configuration as Apache evaluates it
279// ---------------------------------------------------------------------------------------------
281/// A directive after includes, conditions, variables and macros, with where it really came from.
282#[derive(Debug, Clone)]
283pub 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>>,
295impl 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 }
309pub 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>,
319pub struct Config {
320 files: BTreeMap<String, (String, Vec<Node>)>,
321 pub errors: Vec<(String, String)>,
322 facts: ServerFacts,
323 modules: BTreeSet<String>,
326struct 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>,
336fn 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")
350fn 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 })
372impl 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 VARIABLE
391 .replace_all(value, |c: &regex::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 items
542 }
544 pub fn file_paths(&self) -> impl Iterator<Item = &String> {
545 self.files.keys()
546 }
549fn 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 }
558// ---------------------------------------------------------------------------------------------
559// Sites
560// ---------------------------------------------------------------------------------------------
562fn 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 }
573fn all<'a>(items: &'a [Item], key: &str) -> Vec<&'a Item> {
574 let mut found = Vec::new();
575 deep(items, key, &mut found);
576 found
579fn 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()
587fn is_redirect_status(word: &str) -> bool {
588 matches!(word.to_ascii_lowercase().as_str(), "permanent" | "temp" | "seeother" | "301" | "302" | "303" | "307" | "308")
591/// `Redirect` and `RedirectMatch`, and `RewriteRule` only with the R flag, each with where it is written.
592fn 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 rules
610fn 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, "/" | "^" | "^/" | ".*" | "^.*$" | "^/?(.*)$" | "^/(.*)$" | "(.*)" | "^(.*)$" | "/(.*)")
618fn 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 None
641fn 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 rules
666fn 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 blocks
688fn first_arg<'a>(items: &'a [Item], key: &str) -> Option<&'a Item> {
689 items.iter().find(|item| item.key == key)
692fn 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 })
744fn count_directives(items: &[Item]) -> usize {
745 items.iter().map(|item| 1 + count_directives(item.children())).sum()
748fn 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 names
754fn 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())
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.
762pub 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 }
806/// The virtual hosts and server-wide facts of an evaluated configuration.
807pub 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()
812fn 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 found
824pub 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 })
846fn 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()
852/// Every log file a directive names, for the log viewer. Pipes and variables cannot be read back.
853pub 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 found
869fn 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 }
878/// `host:port` and `unix:/path` behind ProxyPass, balancers and PHP-FPM handlers. A value with a variable is not probed.
879pub 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()
911// ---------------------------------------------------------------------------------------------
912// Where new sites go, generated sites and edits. The file is parsed again for every change and only
913// the bytes of that directive change.
914// ---------------------------------------------------------------------------------------------
916pub const GENERATED_MARK: &str = "# Managed by CH-J Server Manager. Manual changes are kept.";
918#[derive(Debug, Clone, PartialEq)]
919pub struct Layout {
920 pub kind: &'static str,
921 pub directory: String,
922 pub enabled: Option<String>,
925/// From what the configuration really includes, not from the distribution name. Without a wildcard directory there is nowhere safe to add a site.
926pub 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 None
940#[derive(Debug, Clone, Default)]
941pub 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,
960fn invalid(message: impl Into<String>) -> CoreError {
961 CoreError::new("APACHE_SITE_INVALID", message.into())
964fn field<'a>(input: &'a Value, key: &str) -> &'a str {
965 input.get(key).and_then(Value::as_str).map_or("", str::trim)
968fn 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 }
975fn rephrase<T>(result: CoreResult<T>) -> CoreResult<T> {
976 result.map_err(|error| invalid(error.message))
979fn 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 }
987impl 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 })
1013 pub fn file_name(&self) -> String {
1014 format!("{}.conf", self.names[0].replacen("*.", "wildcard.", 1))
1017 pub fn ports(&self) -> Vec<u16> {
1018 if self.https { vec![self.port, self.https_port] } else { vec![self.port] }
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 _ => {}
1032 if self.https {
1033 modules.push("ssl_module");
1035 if self.security_headers {
1036 modules.push("headers_module");
1038 modules
1042fn php_target(php: &str) -> String {
1043 if php.starts_with("unix:") { format!("proxy:{php}|fcgi://localhost") } else { format!("proxy:fcgi://{php}") }
1046fn 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(" ")));
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()]);
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()]);
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}/"));
1075 _ => lines.push(format!("Redirect {} / {}/", if request.permanent { "permanent" } else { "temp" }, request.redirect_target)),
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());
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 lines
1090fn 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"));
1099 out.push_str("</VirtualHost>\n");
1102/// The text of a new site, built in Core from checked values. AllowOverride stays None: `.htaccess` is never turned on by the program.
1103pub 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(" ")));
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 lines
1115 } 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);
1126 out
1129const MAX_TEMPLATE_BYTES: usize = 64 * 1024;
1131/// The administrator's own template with its placeholders filled in from checked values.
1132pub 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."));
1137 let mut problem = None;
1138 let filled = PLACEHOLDER.replace_all(&request.template, |caps: &regex::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]) });
1152 value
1153 }).into_owned();
1154 if let Some(message) = problem {
1155 return Err(invalid(message));
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."));
1161 Ok(format!("{GENERATED_MARK}\n\n{filled}{}", if filled.ends_with('\n') { "" } else { "\n" }))
1164fn 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));
1173 found
1176fn edit_error(message: impl Into<String>) -> CoreError {
1177 CoreError::new("APACHE_EDIT_UNSUPPORTED", message.into())
1180fn 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.")))
1184fn 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."))
1188fn line_start(text: &str, at: usize) -> usize {
1189 text[..at].rfind('\n').map_or(0, |found| found + 1)
1192fn next_line(text: &str, at: usize) -> usize {
1193 text[at..].find('\n').map_or(text.len(), |found| at + found + 1)
1196fn indent_at(text: &str, at: usize) -> String {
1197 text[line_start(text, at)..at].chars().take_while(|c| c.is_whitespace()).collect()
1200/// A whole line goes when the directive is alone on it. Apache allows nothing else on a line anyway.
1201fn 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..])
1206fn 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..])
1217/// Replaces, adds or (with no values) removes one directive. Several of one name are a human decision.
1218fn 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())));
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 })
1231fn 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)?)
1236/// Applies graphical changes to one VirtualHost and keeps every other byte of the file.
1237pub 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)))?;
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))?;
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..]);
1263 Ok(changed)
1264 })?;
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."));
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."));
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..]);
1286 Ok(changed)
1287 })?;
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 })?;
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))?;
1301 parsed(&output)?;
1302 Ok(output)
1305/// A second VirtualHost on 443 that repeats this one with its certificate, keeping every directive it holds.
1306fn 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."));
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}/"));
1330 Ok(output)
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.
1334pub 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);
1341 if let Some(found) = node.body.as_deref().and_then(|body| find_line(body, line)) {
1342 return Some(found);
1345 None
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..])
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..])
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..])
1366 _ => return Err(edit_error("This finding has no automatic fix.")),
1367 };
1368 parsed(&output)?;
1369 Ok(output)
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.
1378pub 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());
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);
1396 if target.starts_with("https://") {
1397 add("ssl_module");
1400 if site["phpHandler"]["kind"] == "php-fpm" && site["phpInherited"] != true {
1401 add("proxy_module");
1402 add("proxy_fcgi_module");
1404 if site["sslEnabled"] == true {
1405 add("ssl_module");
1407 if site["rewriteEngine"] == true {
1408 add("rewrite_module");
1411 used
1414pub 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],
1427fn finding(level: &str, code: &str, file: &Value, line: &Value, params: Value) -> Value {
1428 json!({ "level": level, "code": code, "file": file, "line": line, "params": params })
1431pub 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 })));
1438 for (path, message) in input.config_errors {
1439 out.push(finding("warning", "not-parsed", &json!(path), &none, json!({ "message": message })));
1441 for item in &input.evaluation.missing {
1442 out.push(finding("error", "include-missing", &item["file"], &item["line"], json!({ "pattern": item["pattern"] })));
1444 for item in &input.evaluation.unknown_macros {
1445 out.push(finding("warning", "macro-unknown", &item["file"], &item["line"], json!({ "name": item["name"] })));
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 })));
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!({})));
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 })));
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);
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 })));
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"] })));
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 })));
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!({})));
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 })));
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 })));
1516 if site["sslEnabled"] == true && site["certificate"].is_null() {
1517 out.push(finding("warning", "certificate-not-set", &site["file"], &site["line"], json!({ "site": name })));
1519 if site["sslEnabled"] == true && site["domain"].is_null() {
1520 out.push(finding("recommendation", "tls-without-name", &site["file"], &site["line"], json!({})));
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 })));
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 })));
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"] })));
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"] })));
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 })));
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 _ => {}
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"] })));
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"] })));
1567 if input.wide["serverName"].is_null() {
1568 out.push(finding("recommendation", "no-server-name", &none, &none, json!({})));
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 out
1575#[cfg(test)]
1576mod 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() }
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)
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}");
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));
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()));
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");
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)
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:?}");
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()
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:?}");
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:?}");
1715 assert_eq!(found[0].0, "error", "errors come first");
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:?}");
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);
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");
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());
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();
1774 SiteRequest::from_value(&input).unwrap()
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);
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}");
1804 assert!(render_site(&generated(json!({ "type": "proxy", "backend": "http://127.0.0.1:3000" })), "/l").contains("ProxyRequests Off"));
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();
1814 assert_eq!(SiteRequest::from_value(&input).unwrap_err().code, "APACHE_SITE_INVALID", "{bad}");
1816 assert_eq!(generated(json!({ "type": "proxy", "backend": "http://x" })).required_modules(), ["proxy_module", "proxy_http_module"]);
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}");
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}");
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");
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");
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")));
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"));
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"]);
SHA-256e47a4095f3ddec32d42fd019bf27cf0ae5a49b5cfd5aac54fa422ec15b6e317e

CH-J Proprietary Software License 1.14 · Vir je na voljo pod CH-J Proprietary Software License 1.14. Njena razpoložljivost ne spreminja pogojev licence ali dodeljuje dodatnih pravic.

Arhiv SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Datoteke