CH-J ToolsSSH によるサーバー管理
メニュー
公開済みのソース

CH-J Tools

特定のアプリケーションリリースのディレクトリとファイルを閲覧できます。

ソースの ZIP をダウンロード
CH-J / Server Manager 1704 ファイル
0.1.2-beta 追加日時: · ビルド日時:
nginx_conf.rs 追加日時: 日付の記録なし 97.4 KB · 1942 行
コード ファイルをダウンロード
1//! NGINX configuration as text: a position-keeping parser, the site inventory, generated sites and edits
2//! that touch only the requested directives. Nothing here talks to a server.
4use crate::core::error::{CoreError, CoreResult};
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde_json::{json, Value};
8use std::collections::{BTreeMap, BTreeSet};
10/// Webroot for ACME challenges; the same path is in `nginx.sh`.
11pub const ACME_ROOT: &str = "/var/www/chj-acme";
12pub const GENERATED_MARK: &str = "# Managed by CH-J Server Manager. Manual changes are kept.";
13const MAX_DEPTH: usize = 32;
15static HOST: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\*\.)?[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$").unwrap());
16static PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,255}$").unwrap());
17static PROXY_URL: Lazy<Regex> = Lazy::new(|| Regex::new(r"^https?://([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?(/[A-Za-z0-9._~/%-]*)?$").unwrap());
18static BACKEND: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(unix:/[A-Za-z0-9._/-]{1,255}|([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\]):[0-9]{1,5})$").unwrap());
19static CHUNK: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._-]{1,64}$").unwrap());
20static DURATION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9]{1,5}(ms|s|m)?$").unwrap());
22fn invalid(message: impl Into<String>) -> CoreError {
23 CoreError::new("NGINX_SITE_INVALID", message.into())
26// ---------------------------------------------------------------------------------------------
27// Parser
28// ---------------------------------------------------------------------------------------------
30#[derive(Debug, Clone)]
31pub struct Node {
32 pub name: String,
33 pub args: Vec<String>,
34 /// First byte of the name and the byte after `;` or `}`.
35 pub start: usize,
36 pub end: usize,
37 pub body: Option<Vec<Node>>,
40impl Node {
41 fn children(&self) -> &[Node] {
42 self.body.as_deref().unwrap_or(&[])
43 }
45 fn first(&self, name: &str) -> Option<&Node> {
46 self.children().iter().find(|node| node.name == name)
47 }
49 fn all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Node> + 'a {
50 self.children().iter().filter(move |node| node.name == name)
51 }
54struct Parser<'a> {
55 text: &'a str,
56 bytes: &'a [u8],
57 at: usize,
60impl Parser<'_> {
61 fn skip_space(&mut self) {
62 while let Some(&byte) = self.bytes.get(self.at) {
63 if byte.is_ascii_whitespace() {
64 self.at += 1;
65 } else if byte == b'#' {
66 while self.bytes.get(self.at).is_some_and(|b| *b != b'\n') {
67 self.at += 1;
68 }
69 } else {
70 break;
71 }
72 }
73 }
75 fn word(&mut self) -> Result<String, String> {
76 let start = self.at;
77 let first = self.bytes[start];
78 if first == b'"' || first == b'\'' {
79 self.at += 1;
80 let mut value = String::new();
81 let mut from = self.at;
82 while let Some(&byte) = self.bytes.get(self.at) {
83 if byte == b'\\' {
84 value.push_str(&self.text[from..self.at]);
85 self.at += 1;
86 from = self.at;
87 if self.at < self.bytes.len() {
88 self.at += self.text[self.at..].chars().next().map_or(1, char::len_utf8);
89 }
90 } else if byte == first {
91 value.push_str(&self.text[from..self.at]);
92 self.at += 1;
93 return Ok(value);
94 } else {
95 self.at += 1;
96 }
97 }
98 return Err("unterminated string".into());
99 }
100 while let Some(&byte) = self.bytes.get(self.at) {
101 if byte == b'$' && self.bytes.get(self.at + 1) == Some(&b'{') {
102 while self.bytes.get(self.at).is_some_and(|b| *b != b'}') {
103 self.at += 1;
104 }
105 self.at = (self.at + 1).min(self.bytes.len());
106 continue;
107 }
108 if byte == b'\\' {
109 self.at += 1;
110 if self.at < self.bytes.len() {
111 self.at += self.text[self.at..].chars().next().map_or(1, char::len_utf8);
112 }
113 continue;
114 }
115 if byte.is_ascii_whitespace() || matches!(byte, b';' | b'{' | b'}') {
116 break;
117 }
118 self.at += 1;
119 }
120 self.at = self.at.min(self.bytes.len());
121 Ok(self.text[start..self.at].to_string())
122 }
124 /// Lua and similar blocks hold another language, so only their braces are counted.
125 fn skip_raw_block(&mut self) -> Result<(), String> {
126 let mut depth = 1;
127 while let Some(&byte) = self.bytes.get(self.at) {
128 self.at += 1;
129 match byte {
130 b'{' => depth += 1,
131 b'}' => {
132 depth -= 1;
133 if depth == 0 {
134 return Ok(());
135 }
136 }
137 _ => {}
138 }
139 }
140 Err("unterminated block".into())
141 }
143 fn block(&mut self, depth: usize) -> Result<Vec<Node>, String> {
144 if depth > MAX_DEPTH {
145 return Err("blocks are nested too deeply".into());
146 }
147 let mut nodes = Vec::new();
148 loop {
149 self.skip_space();
150 let Some(&byte) = self.bytes.get(self.at) else {
151 return if depth == 0 { Ok(nodes) } else { Err("a block is not closed".into()) };
152 };
153 if byte == b'}' {
154 if depth == 0 {
155 return Err("unexpected }".into());
156 }
157 self.at += 1;
158 return Ok(nodes);
159 }
160 if byte == b';' || byte == b'{' {
161 return Err(format!("unexpected {}", byte as char));
162 }
163 let start = self.at;
164 let name = self.word()?;
165 let mut args = Vec::new();
166 loop {
167 self.skip_space();
168 match self.bytes.get(self.at) {
169 None => return Err(format!("{name} is not terminated")),
170 Some(b';') => {
171 self.at += 1;
172 nodes.push(Node { name, args, start, end: self.at, body: None });
173 break;
174 }
175 Some(b'{') => {
176 self.at += 1;
177 let body = if name.ends_with("_by_lua_block") {
178 self.skip_raw_block()?;
179 Vec::new()
180 } else {
181 self.block(depth + 1)?
182 };
183 nodes.push(Node { name, args, start, end: self.at, body: Some(body) });
184 break;
185 }
186 Some(b'}') => return Err(format!("{name} is not terminated")),
187 Some(_) => args.push(self.word()?),
188 }
189 }
190 }
191 }
194pub fn parse(text: &str) -> Result<Vec<Node>, String> {
195 Parser { text, bytes: text.as_bytes(), at: 0 }.block(0)
198/// Server blocks of a file in document order. The position in this list identifies a site in the file.
199fn servers(nodes: &[Node]) -> Vec<&Node> {
200 let mut found = Vec::new();
201 for node in nodes {
202 match node.name.as_str() {
203 "server" if node.body.is_some() => found.push(node),
204 "http" | "stream" => found.extend(servers(node.children())),
205 _ => {}
206 }
207 }
208 found
211fn in_stream_block(nodes: &[Node], server: &Node) -> bool {
212 nodes.iter().any(|node| node.name == "stream" && node.start <= server.start && server.end <= node.end)
215fn upstreams(nodes: &[Node]) -> Vec<&Node> {
216 let mut found = Vec::new();
217 for node in nodes {
218 match node.name.as_str() {
219 "upstream" if node.body.is_some() => found.push(node),
220 "http" | "stream" => found.extend(upstreams(node.children())),
221 _ => {}
222 }
223 }
224 found
227/// Include patterns of the `http` context, or of the file itself for fragments.
228pub fn http_includes(nodes: &[Node]) -> Vec<String> {
229 let mut found = Vec::new();
230 for node in nodes {
231 if node.name == "http" {
232 found.extend(node.all("include").filter_map(|include| include.args.first().cloned()));
233 }
234 }
235 found
238// ---------------------------------------------------------------------------------------------
239// Inventory
240// ---------------------------------------------------------------------------------------------
242fn listen_port(listen: &Node) -> Option<u32> {
243 let address = listen.args.first()?;
244 if address.starts_with("unix:") {
245 return None;
246 }
247 let port = match address.rsplit_once(':') {
248 Some((_, port)) => port,
249 None if address.bytes().all(|b| b.is_ascii_digit()) => address,
250 None => return Some(80),
251 };
252 port.parse().ok()
255/// Directives of a server block and of the locations in it, nested locations included.
256fn deep<'a>(block: &'a Node, name: &str, found: &mut Vec<&'a Node>) {
257 for node in block.children() {
258 if node.name == name {
259 found.push(node);
260 }
261 if node.name == "location" || node.name == "if" {
262 deep(node, name, found);
263 }
264 }
267fn deep_values(block: &Node, name: &str) -> Vec<String> {
268 let mut nodes = Vec::new();
269 deep(block, name, &mut nodes);
270 let mut values: Vec<String> = Vec::new();
271 for node in nodes {
272 if let Some(value) = node.args.first() {
273 if !values.contains(value) {
274 values.push(value.clone());
275 }
276 }
277 }
278 values
281fn root_location(server: &Node) -> Option<&Node> {
282 server.all("location").find(|location| location.args.len() == 1 && location.args[0] == "/")
285fn is_redirect(node: &Node) -> bool {
286 node.args.first().is_some_and(|code| matches!(code.as_str(), "301" | "302" | "303" | "307" | "308"))
289/// What the site is, judged by who answers `/`: a proxied /api does not turn a web site into a reverse proxy.
290fn site_type(server: &Node) -> &'static str {
291 let top_return = server.first("return").is_some_and(is_redirect);
292 let root_return = root_location(server).and_then(|location| location.first("return")).is_some_and(is_redirect);
293 if top_return || root_return {
294 return "redirect";
295 }
296 let root = root_location(server);
297 if server.first("proxy_pass").is_some() || root.is_some_and(|location| location.first("proxy_pass").is_some()) {
298 return "proxy";
299 }
300 let tries = root.and_then(|location| location.first("try_files"));
301 let php = !deep_values(server, "fastcgi_pass").is_empty();
302 if php {
303 return "php";
304 }
305 if tries.is_some_and(|tries| tries.args.last().is_some_and(|last| last.ends_with("/index.html"))) {
306 return "spa";
307 }
308 if site_root(server).is_some() {
309 return "static";
310 }
311 if !deep_values(server, "proxy_pass").is_empty() {
312 return "proxy";
313 }
314 "other"
317/// The site's own root: a root inside a location (such as the ACME one) only serves that location.
318fn site_root(server: &Node) -> Option<String> {
319 let own = server.first("root").or_else(|| root_location(server).and_then(|location| location.first("root")));
320 own.and_then(|node| node.args.first().cloned())
323/// Every file NGINX loaded. Includes are expanded for reading only: edits always work on the raw text of one file.
324pub struct ConfigSet {
325 files: Vec<(String, Vec<Node>, String)>,
326 base: String,
329impl ConfigSet {
330 /// Files in `nginx -T` order, so the first one is the main configuration. Unparsable files come back separately.
331 pub fn new(sources: &[(String, String)]) -> (Self, Vec<(String, String)>) {
332 let base = sources.first().and_then(|(path, _)| path.rsplit_once('/')).map_or("/etc/nginx".to_string(), |(directory, _)| directory.to_string());
333 let (mut files, mut errors) = (Vec::new(), Vec::new());
334 for (path, text) in sources {
335 match parse(text) {
336 Ok(nodes) => files.push((path.clone(), nodes, text.clone())),
337 Err(message) => errors.push((path.clone(), message)),
338 }
339 }
340 (Self { files, base }, errors)
341 }
343 fn resolve(&self, pattern: &str) -> Vec<&(String, Vec<Node>, String)> {
344 let absolute = if pattern.starts_with('/') { pattern.to_string() } else { format!("{}/{pattern}", self.base) };
345 let expression = regex::escape(&absolute).replace("\\*", "[^/]*").replace("\\?", "[^/]");
346 let Ok(matcher) = Regex::new(&format!("^{expression}$")) else { return Vec::new() };
347 self.files.iter().filter(|(path, _, _)| matcher.is_match(path)).collect()
348 }
350 fn expand(&self, nodes: &[Node], depth: usize) -> Vec<Node> {
351 let mut out = Vec::new();
352 for node in nodes {
353 if node.name == "include" && node.body.is_none() {
354 if depth < MAX_DEPTH {
355 for (_, included, _) in node.args.first().map(|pattern| self.resolve(pattern)).unwrap_or_default() {
356 out.extend(self.expand(included, depth + 1));
357 }
358 }
359 } else {
360 let mut copy = node.clone();
361 copy.body = node.body.as_ref().map(|body| self.expand(body, depth));
362 out.push(copy);
363 }
364 }
365 out
366 }
368 fn effective(&self, block: &Node) -> Node {
369 let mut copy = block.clone();
370 copy.body = block.body.as_ref().map(|body| self.expand(body, 0));
371 copy
372 }
374 /// Files reached from inside a `stream` block; their bare `server` blocks are not web sites.
375 fn stream_files(&self) -> BTreeSet<String> {
376 fn walk(set: &ConfigSet, nodes: &[Node], in_stream: bool, depth: usize, found: &mut BTreeSet<String>) {
377 for node in nodes {
378 if node.name == "include" && depth < MAX_DEPTH {
379 for (path, included, _) in node.args.first().map(|pattern| set.resolve(pattern)).unwrap_or_default() {
380 if in_stream {
381 found.insert(path.clone());
382 }
383 walk(set, included, in_stream, depth + 1, found);
384 }
385 } else if node.name == "stream" {
386 walk(set, node.children(), true, depth, found);
387 } else if node.name == "http" {
388 walk(set, node.children(), false, depth, found);
389 }
390 }
391 }
392 let mut found = BTreeSet::new();
393 if let Some((_, nodes, _)) = self.files.first() {
394 walk(self, nodes, false, 0, &mut found);
395 }
396 found
397 }
399 /// A value set for all of `http`, which servers without their own value inherit.
400 fn http_level(&self, name: &str, active: &BTreeSet<String>) -> Option<String> {
401 for (path, nodes, _) in self.files.iter().filter(|(path, _, _)| active.contains(path)) {
402 for node in nodes {
403 if node.name == name && !path.contains("sites-") {
404 return node.args.first().cloned();
405 }
406 if node.name == "http" {
407 if let Some(found) = node.first(name) {
408 return found.args.first().cloned();
409 }
410 }
411 }
412 }
413 None
414 }
416 /// Sites and upstreams of one file, read as NGINX sees them. `active` holds the files NGINX loaded.
417 pub fn file_inventory(&self, path: &str, active: &BTreeSet<String>) -> (Vec<Value>, Vec<Value>) {
418 let Some((_, nodes, text)) = self.files.iter().find(|(candidate, _, _)| candidate == path) else { return (Vec::new(), Vec::new()) };
419 let stream_files = self.stream_files();
420 let bare_stream = stream_files.contains(path);
421 let sites = servers(nodes)
422 .into_iter()
423 .enumerate()
424 .map(|(ordinal, server)| {
425 let effective = self.effective(server);
426 let mut site = site_value(path, ordinal, in_stream_block(nodes, server) || bare_stream, &effective, text);
427 site["rootFromInclude"] = json!(site["root"].is_string() && server.first("root").is_none() && root_location(server).and_then(|location| location.first("root")).is_none());
428 if site["https"] == true && site["certificate"].is_null() {
429 site["certificate"] = json!(self.http_level("ssl_certificate", active));
430 }
431 site
432 })
433 .collect();
434 let pools = upstreams(nodes)
435 .into_iter()
436 .map(|upstream| {
437 let upstream = self.effective(upstream);
438 let members: Vec<String> = upstream.all("server").filter_map(|node| node.args.first().cloned()).collect();
439 let method = upstream.children().iter().find(|node| matches!(node.name.as_str(), "least_conn" | "ip_hash" | "hash" | "random")).map(|node| node.name.clone());
440 json!({ "path": path, "name": upstream.args.first().cloned().unwrap_or_default(), "servers": members, "method": method.unwrap_or_else(|| "round_robin".into()) })
441 })
442 .collect();
443 (sites, pools)
444 }
446 /// `map` blocks of the loaded files, with the entries they include from other files.
447 pub fn maps(&self, active: &BTreeSet<String>) -> Vec<Value> {
448 let mut found = Vec::new();
449 for (path, nodes, _) in self.files.iter().filter(|(path, _, _)| active.contains(path)) {
450 let mut blocks: Vec<&Node> = Vec::new();
451 for node in nodes {
452 match node.name.as_str() {
453 "map" => blocks.push(node),
454 "http" | "stream" => blocks.extend(node.all("map")),
455 _ => {}
456 }
457 }
458 for block in blocks {
459 let mut entries = Vec::new();
460 self.map_entries(block.children(), path, 0, &mut entries);
461 found.push(json!({ "path": path, "source": block.args.first(), "variable": block.args.get(1), "hostnames": block.first("hostnames").is_some(), "entries": entries }));
462 }
463 }
464 found
465 }
467 /// Entries of a map with the file each one really lives in, because that is the file an edit must change.
468 fn map_entries(&self, nodes: &[Node], file: &str, depth: usize, out: &mut Vec<Value>) {
469 for node in nodes {
470 match node.name.as_str() {
471 "hostnames" | "volatile" => {}
472 "include" if depth < MAX_DEPTH => {
473 for (path, included, _) in node.args.first().map(|pattern| self.resolve(pattern)).unwrap_or_default() {
474 self.map_entries(included, path, depth + 1, out);
475 }
476 }
477 _ => {
478 if let Some(value) = node.args.first() {
479 out.push(json!({ "key": node.name, "value": value, "file": file, "kind": rule_kind(&node.name) }));
480 }
481 }
482 }
483 }
484 }
487fn rule_kind(key: &str) -> &'static str {
488 if key == "default" {
489 "default"
490 } else if key.starts_with('~') {
491 "regex"
492 } else if key.starts_with('.') || key.starts_with("*.") || key.ends_with(".*") {
493 "wildcard"
494 } else {
495 "exact"
496 }
499fn site_value(path: &str, ordinal: usize, in_stream: bool, server: &Node, text: &str) -> Value {
500 let names: Vec<&String> = server.all("server_name").flat_map(|node| node.args.iter()).collect();
501 let listens: Vec<&Node> = server.all("listen").collect();
502 let flag = |word: &str| listens.iter().any(|listen| listen.args.iter().any(|arg| arg == word));
503 let ports: BTreeSet<u32> = listens.iter().filter_map(|listen| listen_port(listen)).collect();
504 // QUIC and UDP do not show up among listening TCP sockets.
505 let tcp_ports: BTreeSet<u32> = listens.iter().filter(|listen| !listen.args.iter().any(|arg| arg == "quic" || arg == "udp")).filter_map(|listen| listen_port(listen)).collect();
506 let ssl_on = server.first("ssl").is_some_and(|node| node.args.first().is_some_and(|value| value == "on"));
507 let https = flag("ssl") || flag("quic") || ssl_on;
508 let http2 = flag("http2") || server.first("http2").is_some_and(|node| node.args.first().is_some_and(|value| value == "on"));
509 let proxies = deep_values(server, "proxy_pass");
510 let stream = in_stream || (!proxies.is_empty() && proxies.iter().all(|target| !target.contains("://")) && names.is_empty());
511 let mut upgrade = Vec::new();
512 deep(server, "proxy_set_header", &mut upgrade);
513 let websocket = upgrade.iter().any(|node| node.args.first().is_some_and(|name| name.eq_ignore_ascii_case("upgrade")));
514 let redirect = server.first("return").or_else(|| root_location(server).and_then(|location| location.first("return"))).filter(|node| is_redirect(node)).map(|node| json!({ "code": node.args[0], "target": node.args.get(1).cloned().unwrap_or_default() }));
515 json!({
516 "path": path,
517 "ordinal": ordinal,
518 "kind": if stream { "stream" } else { "http" },
519 "type": if stream { "stream" } else { site_type(server) },
520 "serverNames": names,
521 "listen": listens.iter().map(|listen| listen.args.join(" ")).collect::<Vec<_>>(),
522 "ports": ports,
523 "tcpPorts": tcp_ports,
524 "https": https,
525 "http2": http2,
526 "http3": flag("quic"),
527 "defaultServer": flag("default_server") || flag("default"),
528 "root": site_root(server),
529 "index": server.first("index").map(|node| node.args.join(" ")).unwrap_or_default(),
530 "proxyPass": proxies,
531 "fastcgiPass": deep_values(server, "fastcgi_pass"),
532 "redirect": redirect,
533 "websocket": websocket,
534 "certificate": deep_values(server, "ssl_certificate").first(),
535 "certificateKey": deep_values(server, "ssl_certificate_key").first(),
536 "accessLog": deep_values(server, "access_log"),
537 "errorLog": deep_values(server, "error_log"),
538 "locations": count_locations(server),
539 "acme": server.all("location").any(|location| location.args.iter().any(|arg| arg.contains("acme-challenge"))),
540 "generated": text.contains(GENERATED_MARK),
541 })
544fn count_locations(block: &Node) -> usize {
545 block.all("location").map(|location| 1 + count_locations(location)).sum()
548/// One file on its own, for code that has no whole configuration at hand.
549pub fn file_inventory(path: &str, text: &str) -> Result<(Vec<Value>, Vec<Value>), String> {
550 let (set, errors) = ConfigSet::new(&[(path.to_string(), text.to_string())]);
551 if let Some((_, message)) = errors.into_iter().next() {
552 return Err(message);
553 }
554 Ok(set.file_inventory(path, &BTreeSet::from([path.to_string()])))
557const HOST_SOURCES: [&str; 4] = ["$host", "$http_host", "$server_name", "$ssl_preread_server_name"];
559fn map_protocol(map: &Value) -> Option<&'static str> {
560 let source = map["source"].as_str()?;
561 HOST_SOURCES.contains(&source).then_some(if source == "$ssl_preread_server_name" { "tls" } else { "http" })
564fn map_in_use(map: &Value, sites: &[Value]) -> bool {
565 let Some(variable) = map["variable"].as_str() else { return false };
566 sites.iter().any(|site| site["active"] == true && site["proxyPass"].as_array().is_some_and(|list| list.iter().filter_map(Value::as_str).any(|target| target.contains(variable))))
569/// Which domain goes where, from the maps that an enabled proxy_pass really uses.
570pub fn routes(sites: &[Value], maps: &[Value]) -> Vec<Value> {
571 let mut found = Vec::new();
572 for map in maps.iter().filter(|map| map_in_use(map, sites)) {
573 let Some(protocol) = map_protocol(map) else { continue };
574 for entry in map["entries"].as_array().into_iter().flatten() {
575 let kind = entry["kind"].as_str().unwrap_or("exact");
576 found.push(json!({ "host": entry["key"], "backend": entry["value"], "protocol": protocol, "variable": map["variable"], "path": map["path"], "file": entry["file"], "kind": kind, "isDefault": kind == "default" }));
577 }
578 }
579 found
582/// The rule NGINX picks for a host: exact name, longest leading wildcard, longest trailing wildcard, first regular
583/// expression, then the default. Regular expressions use this program's engine, which may differ from NGINX in rare cases.
584pub fn winning_rule<'a>(map: &'a Value, host: &str) -> Option<&'a Value> {
585 let host = host.to_ascii_lowercase();
586 let entries = map["entries"].as_array()?;
587 let of_kind = |kind: &'static str| entries.iter().filter(move |entry| entry["kind"] == kind);
588 let key = |entry: &'a Value| entry["key"].as_str().unwrap_or("").to_ascii_lowercase();
589 if let Some(found) = of_kind("exact").find(|entry| key(entry) == host) {
590 return Some(found);
591 }
592 if map["hostnames"] == true {
593 let leading = of_kind("wildcard").filter_map(|entry| {
594 let name = key(entry);
595 let tail = name.strip_prefix("*.").map(|tail| (tail.to_string(), false)).or_else(|| name.strip_prefix('.').map(|tail| (tail.to_string(), true)))?;
596 let matches = host.ends_with(&format!(".{}", tail.0)) || (tail.1 && host == tail.0);
597 matches.then_some((tail.0.len(), entry))
598 }).max_by_key(|(length, _)| *length);
599 if let Some((_, found)) = leading {
600 return Some(found);
601 }
602 let trailing = of_kind("wildcard").filter_map(|entry| {
603 let name = key(entry);
604 let head = name.strip_suffix(".*")?.to_string();
605 host.starts_with(&format!("{head}.")).then_some((head.len(), entry))
606 }).max_by_key(|(length, _)| *length);
607 if let Some((_, found)) = trailing {
608 return Some(found);
609 }
610 }
611 for entry in of_kind("regex") {
612 let raw = entry["key"].as_str().unwrap_or("");
613 let (pattern, insensitive) = raw.strip_prefix("~*").map_or((raw.trim_start_matches('~'), false), |rest| (rest, true));
614 if Regex::new(&if insensitive { format!("(?i){pattern}") } else { pattern.to_string() }).is_ok_and(|expression| expression.is_match(&host)) {
615 return Some(entry);
616 }
617 }
618 of_kind("default").next()
621/// One change of one rule in one file. The value `None` removes the rule.
622#[derive(Debug, Clone)]
623pub struct RouteAction {
624 pub protocol: &'static str,
625 pub file: String,
626 pub variable: String,
627 pub in_block: bool,
628 pub key: String,
629 pub value: Option<String>,
630 pub exists: bool,
633fn check_route_host(value: &str) -> CoreResult<String> {
634 let bare = value.strip_prefix('.').unwrap_or(value);
635 check_host(bare)?;
636 Ok(value.to_ascii_lowercase())
639/// A backend in a route: an address, or an upstream group the configuration already defines.
640fn check_route_backend(value: &str, pools: &[Value]) -> CoreResult<String> {
641 if pools.iter().any(|pool| pool["name"] == value) {
642 return Ok(value.to_string());
643 }
644 check_backend(value)
647/// Turns a requested change into rule edits. Anything the program cannot be sure about is refused with a pointer to the
648/// editor: several routing maps, the same name twice, or a rule that is not a plain name.
649pub fn route_actions(sites: &[Value], maps: &[Value], pools: &[Value], change: &Value) -> CoreResult<(Vec<RouteAction>, Vec<Value>)> {
650 let host = check_route_host(text(change, "host"))?;
651 let mode = text(change, "mode");
652 let mut actions = Vec::new();
653 let mut warnings = Vec::new();
654 let mut present = BTreeMap::new();
655 for protocol in ["http", "tls"] {
656 let candidates: Vec<&Value> = maps.iter().filter(|map| map_protocol(map) == Some(protocol) && map_in_use(map, sites)).collect();
657 let requested = change.get(protocol);
658 if candidates.len() > 1 && requested.is_some() {
659 return Err(edit_error(format!("Several maps route {protocol} traffic here, so the program cannot tell which one to change. Use the configuration editor.")));
660 }
661 let Some(map) = candidates.first() else {
662 if requested.is_some_and(|value| !value.is_null()) {
663 return Err(edit_error(format!("No map routes {protocol} traffic on this server.")));
664 }
665 continue;
666 };
667 let entries = map["entries"].as_array().cloned().unwrap_or_default();
668 let same_key: Vec<&Value> = entries.iter().filter(|entry| entry["key"].as_str().is_some_and(|key| key.eq_ignore_ascii_case(&host))).collect();
669 if same_key.len() > 1 {
670 return Err(edit_error(format!("{host} appears {} times in this map. Use the configuration editor.", same_key.len())));
671 }
672 let existing = same_key.first();
673 present.insert(protocol, existing.is_some());
674 let Some(requested) = requested else { continue };
675 let value = match requested {
676 Value::Null => None,
677 Value::String(value) if value.trim().is_empty() => None,
678 Value::String(value) => Some(check_route_backend(value.trim(), pools)?),
679 _ => return Err(invalid("A backend must be text.")),
680 };
681 if mode == "add" && existing.is_some() {
682 return Err(CoreError::new("NGINX_ROUTE_EXISTS", format!("{host} already has a {protocol} route.")));
683 }
684 if value.is_none() && existing.is_none() {
685 continue;
686 }
687 if existing.is_some_and(|entry| entry["value"].as_str() == value.as_deref()) {
688 present.insert(protocol, true);
689 continue;
690 }
691 if existing.is_none() && map["hostnames"] == true {
692 if let Some(rule) = winning_rule(map, &host).filter(|rule| rule["kind"] == "wildcard" || rule["kind"] == "regex") {
693 warnings.push(json!({ "code": "overrides-rule", "rule": rule["key"] }));
694 }
695 }
696 let own_path = map["path"].as_str().unwrap_or("").to_string();
697 // A new rule goes where the others are.
698 let file = existing.and_then(|entry| entry["file"].as_str()).map(str::to_string).or_else(|| entries.iter().rev().find(|entry| entry["kind"] == "exact").and_then(|entry| entry["file"].as_str()).map(str::to_string)).unwrap_or_else(|| own_path.clone());
699 present.insert(protocol, value.is_some());
700 actions.push(RouteAction { protocol, in_block: file == own_path, file, variable: map["variable"].as_str().unwrap_or("").to_string(), key: existing.and_then(|entry| entry["key"].as_str()).unwrap_or(&host).to_string(), exists: existing.is_some(), value });
701 }
702 if present.get("http").copied().unwrap_or(true) != present.get("tls").copied().unwrap_or(true) {
703 warnings.push(json!({ "code": "one-protocol" }));
704 }
705 Ok((actions, warnings))
708fn map_block<'a>(nodes: &'a [Node], variable: &str) -> Option<&'a Node> {
709 for node in nodes {
710 match node.name.as_str() {
711 "map" if node.args.get(1).is_some_and(|name| name == variable) => return Some(node),
712 "http" | "stream" => {
713 if let Some(found) = map_block(node.children(), variable) {
714 return Some(found);
715 }
716 }
717 _ => {}
718 }
719 }
720 None
723/// Changes one rule in the text of its file. Comments, blank lines and every other rule keep their bytes.
724pub fn apply_route_action(original: &str, action: &RouteAction) -> CoreResult<String> {
725 let nodes = parsed(original)?;
726 let holder = action.in_block.then(|| map_block(&nodes, &action.variable).ok_or_else(|| CoreError::new("NGINX_CONFIG_CHANGED", "The map is no longer in this file; reload the list."))).transpose()?;
727 let rules: &[Node] = holder.map_or(nodes.as_slice(), |block| block.children());
728 let rule = rules.iter().find(|node| node.name.eq_ignore_ascii_case(&action.key) && node.args.len() == 1);
729 if action.exists && rule.is_none() {
730 return Err(CoreError::new("NGINX_CONFIG_CHANGED", "The rule is no longer in this file; reload the list."));
731 }
732 let output = match (rule, &action.value) {
733 (Some(node), Some(value)) => format!("{}{} {value};{}", &original[..node.start], node.name, &original[node.end..]),
734 (Some(node), None) => {
735 let (from, to) = line_span(original, node.start, node.end);
736 format!("{}{}", &original[..from], &original[to..])
737 }
738 (None, None) => original.to_string(),
739 (None, Some(value)) => {
740 let line = format!("{} {value};", action.key);
741 let backend_host = |text: &str| text.rsplit_once(':').map_or(text.to_string(), |(host, _)| host.to_string());
742 let plain: Vec<&Node> = rules.iter().filter(|node| !matches!(node.name.as_str(), "default" | "hostnames" | "volatile" | "include") && node.args.len() == 1).collect();
743 // Next to the rules of the same backend, which is how such files are usually grouped.
744 let anchor = plain.iter().rev().find(|node| backend_host(&node.args[0]) == backend_host(value)).or(plain.last()).copied();
745 match (anchor, holder) {
746 (Some(node), _) => {
747 let after = original[node.end..].find('\n').map_or(original.len(), |found| node.end + found + 1);
748 let lead = if after == original.len() && !original.ends_with('\n') { "\n" } else { "" };
749 format!("{}{lead}{}{line}\n{}", &original[..after], indent_of(original, node.start), &original[after..])
750 }
751 (None, Some(block)) => {
752 let close = block.end - 1;
753 let line_start = original[..close].rfind('\n').map_or(0, |found| found + 1);
754 format!("{}{} {line}\n{}", &original[..line_start], indent_of(original, block.start), &original[line_start..])
755 }
756 (None, None) => format!("{original}{}{line}\n", if original.is_empty() || original.ends_with('\n') { "" } else { "\n" }),
757 }
758 }
759 };
760 parsed(&output)?;
761 Ok(output)
764/// The routing rule of a domain in each protocol, for showing its whole path.
765pub fn domain_rules(host: &str, sites: &[Value], maps: &[Value]) -> Vec<Value> {
766 let mut found = Vec::new();
767 for map in maps.iter().filter(|map| map_in_use(map, sites)) {
768 let Some(protocol) = map_protocol(map) else { continue };
769 let Some(rule) = winning_rule(map, host) else { continue };
770 let variable = map["variable"].as_str().unwrap_or("");
771 let carrier = sites.iter().find(|site| site["active"] == true && site["proxyPass"].as_array().is_some_and(|list| list.iter().filter_map(Value::as_str).any(|target| target.contains(variable))));
772 found.push(json!({ "protocol": protocol, "port": carrier.map(|site| site["ports"].clone()), "listenFile": carrier.map(|site| site["path"].clone()), "mapFile": map["path"], "variable": variable, "ruleFile": rule["file"], "ruleKey": rule["key"], "ruleKind": rule["kind"], "backend": rule["value"] }));
773 }
774 found
777/// A site whose server_name answers for the host, when no routing map decides.
778pub fn domain_site<'a>(host: &str, sites: &'a [Value]) -> Option<&'a Value> {
779 let host = host.to_ascii_lowercase();
780 let answers = |site: &&Value, wildcard: bool| {
781 site["serverNames"].as_array().is_some_and(|names| names.iter().filter_map(Value::as_str).any(|name| {
782 let name = name.to_ascii_lowercase();
783 if wildcard {
784 name.strip_prefix("*.").is_some_and(|tail| host.ends_with(&format!(".{tail}"))) || name.strip_prefix('.').is_some_and(|tail| host == tail || host.ends_with(&format!(".{tail}")))
785 } else {
786 name == host
787 }
788 }))
789 };
790 let candidates = || sites.iter().filter(|site| site["active"] == true && site["kind"] == "http" && site["role"] != "companion");
791 candidates().find(|site| answers(site, false)).or_else(|| candidates().find(|site| answers(site, true)))
794fn redirect_hosts(site: &Value, maps: &[Value]) -> Vec<String> {
795 let Some(target) = site["redirect"]["target"].as_str() else { return Vec::new() };
796 let rest = target.split_once("://").map_or(target, |(_, rest)| rest);
797 let host = rest.split(['/', ':']).next().unwrap_or("");
798 let host = host.split("$request").next().unwrap_or(host);
799 if host == "$host" || host == "$server_name" || host == "$http_host" {
800 return site["serverNames"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect();
801 }
802 if host.starts_with('$') {
803 // Only what the map says for this block's own names: the same map also answers for other sites.
804 let names: Vec<String> = site["serverNames"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect();
805 return maps.iter().filter(|map| map["variable"] == host).flat_map(|map| map["entries"].as_array().cloned().unwrap_or_default()).filter(|entry| entry["key"].as_str().is_some_and(|key| names.contains(&key.to_ascii_lowercase()))).filter_map(|entry| entry["value"].as_str().map(str::to_ascii_lowercase)).collect();
806 }
807 vec![host.to_ascii_lowercase()]
810/// Sorts blocks into what a person calls a web: `site`, a `companion` that only leads to a site (HTTP to HTTPS,
811/// www to the bare domain), a free-standing `redirect`, a `catch-all`, a `gateway` routing by map, and `stream`.
812pub fn assign_roles(sites: &mut [Value], maps: &[Value]) {
813 let names_of = |site: &Value| -> Vec<String> { site["serverNames"].as_array().into_iter().flatten().filter_map(Value::as_str).filter(|name| *name != "_").map(str::to_ascii_lowercase).collect() };
814 for site in sites.iter_mut() {
815 let unnamed = names_of(site).is_empty();
816 let has_proxy = site["proxyPass"].as_array().is_some_and(|list| !list.is_empty());
817 let role = if site["kind"] == "stream" {
818 "stream"
819 } else if unnamed {
820 if has_proxy { "gateway" } else { "catch-all" }
821 } else if site["type"] == "redirect" {
822 "redirect"
823 } else {
824 "site"
825 };
826 site["role"] = json!(role);
827 }
828 let mains: Vec<(usize, Vec<String>, Value)> = sites.iter().enumerate().filter(|(_, site)| site["role"] == "site").map(|(index, site)| (index, names_of(site), site["active"].clone())).collect();
829 let mut attached: Vec<(usize, Vec<String>, bool)> = Vec::new();
830 for index in 0..sites.len() {
831 if sites[index]["role"] != "redirect" {
832 continue;
833 }
834 let names = names_of(&sites[index]);
835 let hosts = redirect_hosts(&sites[index], maps);
836 let active = sites[index]["active"].clone();
837 let same_state = |entry: &&(usize, Vec<String>, Value)| entry.2 == active;
838 let main = mains.iter().filter(same_state).find(|(_, main_names, _)| main_names.iter().any(|name| names.contains(name))).or_else(|| mains.iter().filter(same_state).find(|(_, main_names, _)| main_names.iter().any(|name| hosts.contains(name))));
839 if let Some((main, main_names, _)) = main {
840 let to_https = sites[index]["redirect"]["target"].as_str().is_some_and(|target| target.starts_with("https://")) && !sites[index]["https"].as_bool().unwrap_or(false);
841 let extra = names.iter().filter(|name| !main_names.contains(name)).cloned().collect();
842 attached.push((*main, extra, to_https));
843 sites[index]["role"] = json!("companion");
844 sites[index]["companionOf"] = json!(format!("{}#{}", sites[*main]["path"].as_str().unwrap_or(""), sites[*main]["ordinal"]));
845 }
846 }
847 for (main, extra, to_https) in attached {
848 let aliases = sites[main]["aliases"].as_array().cloned().unwrap_or_default();
849 let mut merged: Vec<Value> = aliases;
850 for name in extra {
851 if !merged.iter().any(|existing| existing == &json!(name)) {
852 merged.push(json!(name));
853 }
854 }
855 sites[main]["aliases"] = json!(merged);
856 if to_https {
857 sites[main]["redirectsHttp"] = json!(true);
858 }
859 }
862/// Log paths from any level of the file. Values with variables and non-file targets cannot be read back.
863pub fn log_paths(nodes: &[Node], found: &mut BTreeSet<String>) {
864 for node in nodes {
865 if (node.name == "access_log" || node.name == "error_log") && node.args.first().is_some_and(|value| value.starts_with('/') && !value.contains('$')) {
866 found.insert(node.args[0].clone());
867 }
868 log_paths(node.children(), found);
869 }
872/// The same name on the same port in two enabled server blocks: NGINX keeps the first and ignores the other.
873pub fn conflicts(sites: &[Value]) -> Vec<Value> {
874 let mut owners: BTreeMap<(u64, String), Vec<String>> = BTreeMap::new();
875 for site in sites.iter().filter(|site| site["active"] == true && site["kind"] == "http") {
876 let label = format!("{}#{}", site["path"].as_str().unwrap_or(""), site["ordinal"]);
877 for port in site["ports"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
878 for name in site["serverNames"].as_array().into_iter().flatten().filter_map(Value::as_str) {
879 if !name.is_empty() && name != "_" {
880 owners.entry((port, name.to_ascii_lowercase())).or_default().push(label.clone());
881 }
882 }
883 }
884 }
885 owners.into_iter().filter(|(_, labels)| labels.len() > 1).map(|((port, name), labels)| json!({ "name": name, "port": port, "sites": labels })).collect()
888/// `host:port` or `unix:/path` targets behind proxy_pass and fastcgi_pass, with upstream names and map values expanded.
889pub fn backend_targets(sites: &[Value], pools: &[Value], maps: &[Value]) -> Vec<Value> {
890 fn add_value(value: &str, label: &str, pools: &[Value], maps: &[Value], depth: usize, found: &mut BTreeMap<String, BTreeSet<String>>) {
891 let (scheme, rest) = value.split_once("://").unwrap_or(("http", value));
892 if let Some(variable) = rest.split(['/', ':']).next().filter(|head| head.starts_with('$')) {
893 if depth < 4 {
894 let values: Vec<String> = maps.iter().filter(|map| map["variable"] == variable).flat_map(|map| map["entries"].as_array().cloned().unwrap_or_default()).filter_map(|entry| entry["value"].as_str().map(str::to_string)).collect();
895 for mapped in values {
896 add_value(&mapped, label, pools, maps, depth + 1, found);
897 }
898 }
899 return;
900 }
901 if rest.contains('$') {
902 return;
903 }
904 if let Some(socket) = rest.strip_prefix("unix:") {
905 let target = format!("unix:{}", socket.split(':').next().unwrap_or(socket));
906 if BACKEND.is_match(&target) {
907 found.entry(target).or_default().insert(label.to_string());
908 }
909 return;
910 }
911 let host = rest.split('/').next().unwrap_or("");
912 if let Some(pool) = pools.iter().find(|pool| pool["name"] == host) {
913 for member in pool["servers"].as_array().into_iter().flatten().filter_map(Value::as_str) {
914 let target = if member.starts_with("unix:") || member.contains(':') { member.to_string() } else { format!("{member}:80") };
915 if BACKEND.is_match(&target) {
916 found.entry(target).or_default().insert(label.to_string());
917 }
918 }
919 return;
920 }
921 let has_port = host.rsplit_once(':').is_some_and(|(_, port)| port.bytes().all(|b| b.is_ascii_digit()));
922 let target = if has_port { host.to_string() } else { format!("{host}:{}", if scheme == "https" { 443 } else { 80 }) };
923 if BACKEND.is_match(&target) {
924 found.entry(target).or_default().insert(label.to_string());
925 }
926 }
927 let mut found: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
928 for site in sites.iter().filter(|site| site["active"] != false) {
929 let label = site["serverNames"].as_array().and_then(|names| names.iter().filter_map(Value::as_str).find(|name| *name != "_")).or(site["path"].as_str()).unwrap_or("").to_string();
930 for key in ["fastcgiPass", "proxyPass"] {
931 for value in site[key].as_array().into_iter().flatten().filter_map(Value::as_str) {
932 add_value(value, &label, pools, maps, 0, &mut found);
933 }
934 }
935 }
936 found.into_iter().map(|(target, labels)| json!({ "target": target, "sites": labels })).collect()
939// ---------------------------------------------------------------------------------------------
940// Capabilities and layout
941// ---------------------------------------------------------------------------------------------
943#[derive(Debug, Clone, Copy, Default)]
944pub struct Capabilities {
945 pub version: (u32, u32, u32),
946 pub http3: bool,
949impl Capabilities {
950 /// `http2 on;` replaced the listen parameter in 1.25.1.
951 pub fn http2_directive(&self) -> bool {
952 self.version >= (1, 25, 1)
953 }
956pub fn parse_version(text: &str) -> (u32, u32, u32) {
957 static VERSION: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\d+)\.(\d+)\.(\d+)").unwrap());
958 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)))
961#[derive(Debug, Clone, PartialEq)]
962pub struct Layout {
963 pub kind: &'static str,
964 pub directory: &'static str,
965 pub enabled: Option<&'static str>,
966 pub suffix: &'static str,
969/// Where new sites go on this server, taken from what nginx.conf really includes, not from the distribution name.
970pub fn detect_layout(includes: &[String]) -> Option<Layout> {
971 let has = |part: &str| includes.iter().any(|include| include.contains(part));
972 if has("sites-enabled/") {
973 Some(Layout { kind: "sites", directory: "/etc/nginx/sites-available", enabled: Some("/etc/nginx/sites-enabled"), suffix: "" })
974 } else if has("conf.d/*.conf") {
975 Some(Layout { kind: "conf.d", directory: "/etc/nginx/conf.d", enabled: None, suffix: ".conf" })
976 } else if has("http.d/*.conf") {
977 Some(Layout { kind: "http.d", directory: "/etc/nginx/http.d", enabled: None, suffix: ".conf" })
978 } else {
979 None
980 }
983// ---------------------------------------------------------------------------------------------
984// Value checks: everything that reaches a configuration file passes through one of these.
985// ---------------------------------------------------------------------------------------------
987pub fn check_host(value: &str) -> CoreResult<String> {
988 if value.len() > 253 || !HOST.is_match(value) {
989 return Err(invalid(format!("'{value}' is not a valid domain name.")));
990 }
991 Ok(value.to_ascii_lowercase())
994pub fn check_path(value: &str) -> CoreResult<String> {
995 if !PATH.is_match(value) || value.contains("..") || value.contains("//") {
996 return Err(invalid(format!("'{value}' is not a valid absolute path.")));
997 }
998 Ok(value.trim_end_matches('/').to_string())
1001/// A web root is served to the world, so system directories are refused.
1002pub fn check_root(value: &str) -> CoreResult<String> {
1003 let path = check_path(value)?;
1004 const SYSTEM: [&str; 11] = ["/etc", "/root", "/proc", "/sys", "/dev", "/boot", "/bin", "/sbin", "/lib", "/usr", "/run"];
1005 if path.is_empty() || SYSTEM.iter().any(|system| path == *system || path.starts_with(&format!("{system}/"))) {
1006 return Err(invalid(format!("'{value}' cannot be a web root because it is a system directory.")));
1008 Ok(path)
1011pub fn check_proxy_url(value: &str) -> CoreResult<String> {
1012 let port_ok = value.split("://").nth(1).and_then(|rest| rest.split('/').next()).and_then(|host| host.rsplit_once(':')).is_none_or(|(_, port)| !port.bytes().all(|b| b.is_ascii_digit()) || port.parse::<u16>().is_ok_and(|p| p > 0));
1013 if !PROXY_URL.is_match(value) || !port_ok {
1014 return Err(invalid(format!("'{value}' is not a valid backend URL.")));
1016 Ok(value.to_string())
1019pub fn check_backend(value: &str) -> CoreResult<String> {
1020 if !BACKEND.is_match(value) {
1021 return Err(invalid(format!("'{value}' is not a valid backend address.")));
1023 Ok(value.to_string())
1026fn check_index(value: &str) -> CoreResult<Vec<String>> {
1027 let parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
1028 if parts.is_empty() || parts.len() > 8 || parts.iter().any(|part| !CHUNK.is_match(part)) {
1029 return Err(invalid("The index list may hold only plain file names."));
1031 Ok(parts)
1034fn check_duration(value: &str) -> CoreResult<String> {
1035 if !DURATION.is_match(value) {
1036 return Err(invalid(format!("'{value}' is not a valid timeout.")));
1038 Ok(value.to_string())
1041fn check_port(value: &Value, fallback: u16) -> CoreResult<u16> {
1042 match value {
1043 Value::Null => Ok(fallback),
1044 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.")),
1048fn strings(value: &Value) -> Vec<String> {
1049 match value {
1050 Value::Array(items) => items.iter().filter_map(Value::as_str).map(|item| item.trim().to_string()).filter(|item| !item.is_empty()).collect(),
1051 Value::String(text) => text.split(|c: char| c == ',' || c.is_whitespace()).map(str::to_string).filter(|item| !item.is_empty()).collect(),
1052 _ => Vec::new(),
1056fn text<'a>(value: &'a Value, key: &str) -> &'a str {
1057 value.get(key).and_then(Value::as_str).map_or("", str::trim)
1060fn on(value: &Value, key: &str) -> bool {
1061 value.get(key) == Some(&Value::Bool(true))
1064// ---------------------------------------------------------------------------------------------
1065// Generated sites
1066// ---------------------------------------------------------------------------------------------
1068#[derive(Debug, Clone)]
1069pub struct SiteRequest {
1070 pub kind: String,
1071 pub names: Vec<String>,
1072 pub port: u16,
1073 pub https_port: u16,
1074 pub ipv6: bool,
1075 pub root: String,
1076 pub create_root: bool,
1077 pub index: Vec<String>,
1078 pub php: String,
1079 pub backends: Vec<String>,
1080 pub method: String,
1081 pub proxy_url: String,
1082 pub connect_timeout: String,
1083 pub read_timeout: String,
1084 pub redirect_target: String,
1085 pub redirect_code: u16,
1086 pub certificate: String,
1087 pub certificate_key: String,
1088 pub https: bool,
1089 pub redirect_http: bool,
1090 pub http2: bool,
1091 pub http3: bool,
1092 pub acme: bool,
1093 pub security_headers: bool,
1094 pub modern_tls: bool,
1095 pub rate_limit: bool,
1096 pub template: String,
1099impl SiteRequest {
1100 pub fn from_value(input: &Value) -> CoreResult<Self> {
1101 let kind = text(input, "type").to_string();
1102 if !matches!(kind.as_str(), "static" | "spa" | "php" | "proxy" | "websocket" | "loadbalance" | "redirect" | "custom") {
1103 return Err(invalid("Unknown site type."));
1105 let names = strings(&input["serverNames"]).iter().map(|name| check_host(name)).collect::<CoreResult<Vec<_>>>()?;
1106 if names.is_empty() || names.len() > 32 {
1107 return Err(invalid("Enter between 1 and 32 domain names."));
1109 let optional = |key: &str, check: fn(&str) -> CoreResult<String>| if text(input, key).is_empty() { Ok(String::new()) } else { check(text(input, key)) };
1110 if kind == "custom" {
1111 return Ok(Self::custom(input, names, optional("root", check_root)?, optional("backend", check_proxy_url)?, optional("phpBackend", check_backend)?, check_port(&input["port"], 80)?));
1113 let uses_root = matches!(kind.as_str(), "static" | "spa" | "php");
1114 let root = if uses_root { check_root(text(input, "root"))? } else { String::new() };
1115 let index = if uses_root && !text(input, "index").is_empty() { check_index(text(input, "index"))? } else if kind == "php" { vec!["index.php".into(), "index.html".into()] } else { vec!["index.html".into()] };
1116 let php = if kind == "php" { check_backend(text(input, "phpBackend"))? } else { String::new() };
1117 let (mut backends, mut proxy_url, mut method) = (Vec::new(), String::new(), String::new());
1118 if kind == "loadbalance" {
1119 backends = strings(&input["backends"]).iter().map(|backend| check_backend(backend)).collect::<CoreResult<Vec<_>>>()?;
1120 if backends.len() < 2 || backends.len() > 32 {
1121 return Err(invalid("Load balancing needs between 2 and 32 backends."));
1123 method = if text(input, "method").is_empty() { "round_robin".into() } else { text(input, "method").to_string() };
1124 if !matches!(method.as_str(), "round_robin" | "least_conn" | "ip_hash") {
1125 return Err(invalid("Unknown load balancing method."));
1127 } else if matches!(kind.as_str(), "proxy" | "websocket") {
1128 proxy_url = check_proxy_url(text(input, "backend"))?;
1130 let redirect_target = if kind == "redirect" { check_proxy_url(text(input, "redirectTarget"))?.trim_end_matches('/').to_string() } else { String::new() };
1131 let redirect_code = if input["redirectCode"].as_u64() == Some(302) { 302 } else { 301 };
1132 let https = on(input, "https");
1133 let (certificate, certificate_key) = if https { (check_path(text(input, "certificate"))?, check_path(text(input, "certificateKey"))?) } else { Default::default() };
1134 let timeout = |key: &str| if text(input, key).is_empty() { Ok(String::new()) } else { check_duration(text(input, key)) };
1135 Ok(Self {
1136 names,
1137 port: check_port(&input["port"], 80)?,
1138 https_port: check_port(&input["httpsPort"], 443)?,
1139 ipv6: input.get("ipv6") != Some(&Value::Bool(false)),
1140 create_root: uses_root && on(input, "createRoot"),
1141 root,
1142 index,
1143 php,
1144 backends,
1145 method,
1146 proxy_url,
1147 connect_timeout: timeout("connectTimeout")?,
1148 read_timeout: timeout("readTimeout")?,
1149 redirect_target,
1150 redirect_code,
1151 certificate,
1152 certificate_key,
1153 https,
1154 redirect_http: https && on(input, "redirectHttp"),
1155 http2: https && on(input, "http2"),
1156 http3: https && on(input, "http3"),
1157 acme: on(input, "acme"),
1158 security_headers: on(input, "securityHeaders"),
1159 modern_tls: https && on(input, "modernTls"),
1160 rate_limit: on(input, "rateLimit"),
1161 template: String::new(),
1162 kind,
1163 })
1166 fn custom(input: &Value, names: Vec<String>, root: String, proxy_url: String, php: String, port: u16) -> Self {
1167 Self {
1168 kind: "custom".into(), names, port, https_port: 443, ipv6: false, create_root: !root.is_empty() && on(input, "createRoot"), root, index: Vec::new(), php, backends: Vec::new(), method: String::new(), proxy_url,
1169 connect_timeout: String::new(), read_timeout: String::new(), redirect_target: String::new(), redirect_code: 301, certificate: String::new(), certificate_key: String::new(), https: false, redirect_http: false, http2: false, http3: false, acme: false, security_headers: false, modern_tls: false, rate_limit: false,
1170 template: input["template"].as_str().unwrap_or("").to_string(),
1174 pub fn file_name(&self) -> String {
1175 self.names[0].replacen("*.", "wildcard.", 1)
1178 /// Identifier for the upstream and rate limit zone; dots are not allowed in a zone name by convention.
1179 fn slug(&self) -> String {
1180 self.file_name().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect()
1183 pub fn ports(&self) -> Vec<u16> {
1184 // With HTTPS the plain port stays in use too, by the redirect block or by the site itself.
1185 if self.https { vec![self.port, self.https_port] } else { vec![self.port] }
1189const MAX_TEMPLATE_BYTES: usize = 64 * 1024;
1191/// The administrator's own template with its placeholders filled in from checked values. It must parse and hold a server block;
1192/// `nginx -t` then judges the result, as for every other site.
1193pub fn render_template(request: &SiteRequest) -> CoreResult<String> {
1194 static PLACEHOLDER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\{\{\s*([A-Za-z_]*)\s*\}\}").unwrap());
1195 if request.template.trim().is_empty() || request.template.len() > MAX_TEMPLATE_BYTES || request.template.contains('\0') {
1196 return Err(invalid("A template must be text of 1 byte to 64 KiB."));
1198 let mut problem = None;
1199 let filled = PLACEHOLDER.replace_all(&request.template, |caps: &regex::Captures| {
1200 let (value, name) = match &caps[1] {
1201 "domain" => (request.names[0].clone(), "domain"),
1202 "names" => (request.names.join(" "), "names"),
1203 "port" => (request.port.to_string(), "port"),
1204 "root" => (request.root.clone(), "root"),
1205 "backend" => (request.proxy_url.clone(), "backend"),
1206 "php" => (request.php.clone(), "php"),
1207 _ => (String::new(), ""),
1208 };
1209 if value.is_empty() && problem.is_none() {
1210 problem = Some(if name.is_empty() { format!("The template uses an unknown placeholder {{{{{}}}}}.", &caps[1]) } else { format!("The template needs a value for {{{{{name}}}}}.") });
1212 value
1213 }).into_owned();
1214 if let Some(message) = problem {
1215 return Err(invalid(message));
1217 let nodes = parse(&filled).map_err(|message| invalid(format!("The template is not valid configuration text: {message}.")))?;
1218 if servers(&nodes).is_empty() {
1219 return Err(invalid("The template has no server block."));
1221 Ok(format!("{GENERATED_MARK}\n\n{filled}{}", if filled.ends_with('\n') { "" } else { "\n" }))
1224fn acme_lines() -> Vec<String> {
1225 vec![
1226 "location ^~ /.well-known/acme-challenge/ {".into(),
1227 format!(" root {ACME_ROOT};"),
1228 " default_type text/plain;".into(),
1229 "}".into(),
1233fn proxy_location(request: &SiteRequest, target: &str) -> Vec<String> {
1234 let mut lines = vec!["location / {".to_string(), format!(" proxy_pass {target};"), " proxy_http_version 1.1;".into(), " proxy_set_header Host $host;".into(), " proxy_set_header X-Real-IP $remote_addr;".into(), " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;".into(), " proxy_set_header X-Forwarded-Proto $scheme;".into()];
1235 if request.kind == "websocket" {
1236 lines.push(" proxy_set_header Upgrade $http_upgrade;".into());
1237 lines.push(" proxy_set_header Connection \"upgrade\";".into());
1239 if !request.connect_timeout.is_empty() {
1240 lines.push(format!(" proxy_connect_timeout {};", request.connect_timeout));
1242 if !request.read_timeout.is_empty() {
1243 lines.push(format!(" proxy_read_timeout {};", request.read_timeout));
1244 lines.push(format!(" proxy_send_timeout {};", request.read_timeout));
1246 lines.push("}".into());
1247 lines
1250fn content_lines(request: &SiteRequest) -> Vec<String> {
1251 let mut lines = Vec::new();
1252 let index = request.index.join(" ");
1253 match request.kind.as_str() {
1254 "static" | "spa" => {
1255 lines.push(format!("root {};", request.root));
1256 lines.push(format!("index {index};"));
1257 lines.push("location / {".into());
1258 lines.push(if request.kind == "spa" { " try_files $uri $uri/ /index.html;".into() } else { " try_files $uri $uri/ =404;".into() });
1259 lines.push("}".into());
1261 "php" => {
1262 lines.push(format!("root {};", request.root));
1263 lines.push(format!("index {index};"));
1264 lines.extend(["location / {".to_string(), " try_files $uri $uri/ /index.php?$query_string;".into(), "}".into()]);
1265 lines.extend(["location ~ \\.php$ {".to_string(), " try_files $uri =404;".into(), format!(" fastcgi_pass {};", request.php), " fastcgi_index index.php;".into(), " fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;".into(), " include fastcgi_params;".into(), "}".into()]);
1266 lines.extend(["location ~ /\\.(?!well-known) {".to_string(), " deny all;".into(), "}".into()]);
1268 "proxy" | "websocket" => lines.extend(proxy_location(request, &request.proxy_url)),
1269 "loadbalance" => lines.extend(proxy_location(request, &format!("http://chj_{}", request.slug()))),
1270 _ => lines.extend(["location / {".to_string(), format!(" return {} {}$request_uri;", request.redirect_code, request.redirect_target), "}".into()]),
1272 lines
1275fn server_block(out: &mut String, lines: &[String]) {
1276 out.push_str("server {\n");
1277 for line in lines {
1278 if line.is_empty() {
1279 out.push('\n');
1280 } else {
1281 out.push_str(" ");
1282 out.push_str(line);
1283 out.push('\n');
1286 out.push_str("}\n");
1289fn listen_lines(port: u16, suffix: &str, ipv6: bool) -> Vec<String> {
1290 let mut lines = vec![format!("listen {port}{suffix};")];
1291 if ipv6 {
1292 lines.push(format!("listen [::]:{port}{suffix};"));
1294 lines
1297/// The text of a new site. The text is rebuilt in Core from checked values and never accepted from the module.
1298pub fn render_site(request: &SiteRequest, capabilities: Capabilities) -> String {
1299 let mut out = format!("{GENERATED_MARK}\n\n");
1300 let slug = request.slug();
1301 if request.kind == "loadbalance" {
1302 out.push_str(&format!("upstream chj_{slug} {{\n"));
1303 if request.method != "round_robin" {
1304 out.push_str(&format!(" {};\n", request.method));
1306 for backend in &request.backends {
1307 out.push_str(&format!(" server {backend};\n"));
1309 out.push_str("}\n\n");
1311 if request.rate_limit {
1312 out.push_str(&format!("limit_req_zone $binary_remote_addr zone=chj_{slug}:10m rate=10r/s;\n\n"));
1314 let names = format!("server_name {};", request.names.join(" "));
1315 let mut main = Vec::new();
1316 if request.https {
1317 let legacy_h2 = request.http2 && !capabilities.http2_directive();
1318 let ssl_suffix = if legacy_h2 { " ssl http2" } else { " ssl" };
1319 main.extend(listen_lines(request.https_port, ssl_suffix, request.ipv6));
1320 if request.http3 {
1321 main.extend(listen_lines(request.https_port, " quic", request.ipv6));
1323 if !request.redirect_http {
1324 main.extend(listen_lines(request.port, "", request.ipv6));
1326 if request.http2 && capabilities.http2_directive() {
1327 main.push("http2 on;".into());
1329 main.push(names.clone());
1330 main.push(String::new());
1331 main.push(format!("ssl_certificate {};", request.certificate));
1332 main.push(format!("ssl_certificate_key {};", request.certificate_key));
1333 if request.modern_tls {
1334 main.push("ssl_protocols TLSv1.2 TLSv1.3;".into());
1336 } else {
1337 main.extend(listen_lines(request.port, "", request.ipv6));
1338 main.push(names.clone());
1340 main.push(String::new());
1341 if request.http3 {
1342 main.push(format!("add_header Alt-Svc 'h3=\":{}\"; ma=86400' always;", request.https_port));
1344 if request.security_headers {
1345 main.push("add_header X-Content-Type-Options nosniff always;".into());
1346 main.push("add_header X-Frame-Options SAMEORIGIN always;".into());
1347 main.push("add_header Referrer-Policy strict-origin-when-cross-origin always;".into());
1348 if request.https {
1349 main.push("add_header Strict-Transport-Security \"max-age=31536000\" always;".into());
1352 if request.rate_limit {
1353 main.push(format!("limit_req zone=chj_{slug} burst=20 nodelay;"));
1355 if request.acme && !(request.https && request.redirect_http) {
1356 main.extend(acme_lines());
1358 main.extend(content_lines(request));
1359 server_block(&mut out, &main);
1360 if request.https && request.redirect_http {
1361 out.push('\n');
1362 let mut plain = listen_lines(request.port, "", request.ipv6);
1363 plain.push(names);
1364 plain.push(String::new());
1365 if request.acme {
1366 plain.extend(acme_lines());
1368 let target = if request.https_port == 443 { "https://$host$request_uri".to_string() } else { format!("https://$host:{}$request_uri", request.https_port) };
1369 plain.extend(["location / {".to_string(), format!(" return 301 {target};"), "}".into()]);
1370 server_block(&mut out, &plain);
1372 out
1375// ---------------------------------------------------------------------------------------------
1376// Edits: the file is parsed again for every change and only the bytes of that directive change.
1377// ---------------------------------------------------------------------------------------------
1379fn edit_error(message: impl Into<String>) -> CoreError {
1380 CoreError::new("NGINX_EDIT_UNSUPPORTED", message.into())
1383fn nth_server(nodes: &[Node], ordinal: usize) -> CoreResult<&Node> {
1384 servers(nodes).into_iter().nth(ordinal).ok_or_else(|| CoreError::new("NGINX_CONFIG_CHANGED", "The site is no longer in this file; reload the list."))
1387fn parsed(text: &str) -> CoreResult<Vec<Node>> {
1388 parse(text).map_err(|message| edit_error(format!("The file could not be parsed for a graphical edit: {message}. Use the configuration editor.")))
1391/// The start of the line when the directive is alone on it.
1392fn line_span(text: &str, start: usize, end: usize) -> (usize, usize) {
1393 let line_start = text[..start].rfind('\n').map_or(0, |at| at + 1);
1394 let after = text[end..].find('\n').map_or(text.len(), |at| end + at + 1);
1395 if text[line_start..start].trim().is_empty() && text[end..after].trim().is_empty() {
1396 (line_start, after)
1397 } else {
1398 (start, end)
1402fn indent_of(text: &str, at: usize) -> String {
1403 let line_start = text[..at].rfind('\n').map_or(0, |found| found + 1);
1404 text[line_start..at].chars().take_while(|c| c.is_whitespace()).collect()
1407/// New lines at the top of a block, after `server_name` when there is one.
1408fn insert_lines(text: &str, block: &Node, lines: &[String]) -> String {
1409 let anchor = block.all("server_name").last().or(block.children().last());
1410 let (at, indent) = match (block.first("server_name"), anchor) {
1411 (Some(_), Some(node)) => {
1412 let after = text[node.end..].find('\n').map_or(text.len(), |found| node.end + found + 1);
1413 (after, indent_of(text, node.start))
1415 (None, Some(node)) => {
1416 let line_start = text[..node.start].rfind('\n').map_or(0, |found| found + 1);
1417 (line_start, indent_of(text, node.start))
1419 _ => {
1420 let open = text[block.start..block.end].find('{').map_or(block.start, |found| block.start + found + 1);
1421 (open, format!("{} ", indent_of(text, block.start)))
1423 };
1424 let mut inserted = String::new();
1425 if at > 0 && text.as_bytes().get(at - 1) != Some(&b'\n') {
1426 inserted.push('\n');
1428 for line in lines {
1429 inserted.push_str(&indent);
1430 inserted.push_str(line);
1431 inserted.push('\n');
1433 format!("{}{}{}", &text[..at], inserted, &text[at..])
1436/// Replaces, adds or (with no values) removes one directive of a block. Several of the same name are not guessed at.
1437fn set_directive(text: &str, block: &Node, name: &str, values: &[String]) -> CoreResult<String> {
1438 let existing: Vec<&Node> = block.all(name).collect();
1439 if existing.len() > 1 {
1440 return Err(edit_error(format!("{name} appears {} times in this block. Use the configuration editor.", existing.len())));
1442 let line = format!("{name} {};", values.join(" "));
1443 Ok(match (existing.first(), values.is_empty()) {
1444 (Some(node), true) => {
1445 let (from, to) = line_span(text, node.start, node.end);
1446 format!("{}{}", &text[..from], &text[to..])
1448 (Some(node), false) => format!("{}{}{}", &text[..node.start], line, &text[node.end..]),
1449 (None, true) => text.to_string(),
1450 (None, false) => insert_lines(text, block, &[line]),
1451 })
1454fn enable_https(mut text: String, ordinal: usize, https: &Value, capabilities: Capabilities) -> CoreResult<String> {
1455 let certificate = check_path(self::text(https, "certificate"))?;
1456 let key = check_path(self::text(https, "certificateKey"))?;
1457 let nodes = parsed(&text)?;
1458 let server = nth_server(&nodes, ordinal)?;
1459 let listens: Vec<&Node> = server.all("listen").collect();
1460 if listens.iter().any(|listen| listen.args.iter().any(|arg| arg == "ssl" || arg == "quic")) {
1461 return Err(edit_error("This site already has HTTPS."));
1463 if listens.is_empty() {
1464 return Err(edit_error("This site has no listen directive. Use the configuration editor."));
1466 let ipv6 = listens.iter().any(|listen| listen.args.first().is_some_and(|address| address.starts_with('[')));
1467 let wants_h2 = on(https, "http2");
1468 let suffix = if wants_h2 && !capabilities.http2_directive() { " ssl http2" } else { " ssl" };
1469 let mut lines = listen_lines(443, suffix, ipv6);
1470 if wants_h2 && capabilities.http2_directive() {
1471 lines.push("http2 on;".into());
1473 lines.push(format!("ssl_certificate {certificate};"));
1474 lines.push(format!("ssl_certificate_key {key};"));
1475 let redirect = on(https, "redirectHttp");
1476 let names: Vec<String> = server.all("server_name").flat_map(|node| node.args.clone()).collect();
1477 let plain_listens: Vec<String> = listens.iter().map(|listen| format!("listen {};", listen.args.join(" "))).collect();
1478 let has_acme = server.all("location").any(|location| location.args.iter().any(|arg| arg.contains("acme-challenge")));
1479 let server_end = server.end;
1480 if redirect {
1481 // Remove the plain listeners from this block; they move to the redirect block unchanged.
1482 let mut spans: Vec<(usize, usize)> = listens.iter().map(|listen| line_span(&text, listen.start, listen.end)).collect();
1483 spans.sort_unstable();
1484 let mut redirect_block = String::from("\n\nserver {\n");
1485 for line in &plain_listens {
1486 redirect_block.push_str(&format!(" {line}\n"));
1488 if !names.is_empty() {
1489 redirect_block.push_str(&format!(" server_name {};\n", names.join(" ")));
1491 if has_acme {
1492 for line in acme_lines() {
1493 redirect_block.push_str(&format!(" {line}\n"));
1496 redirect_block.push_str(" location / {\n return 301 https://$host$request_uri;\n }\n}");
1497 text.insert_str(server_end, &redirect_block);
1498 for (from, to) in spans.into_iter().rev() {
1499 text.replace_range(from..to, "");
1502 let nodes = parsed(&text)?;
1503 let server = nth_server(&nodes, ordinal)?;
1504 Ok(insert_lines(&text, server, &lines))
1507/// Applies graphical changes to one server block and keeps every other byte of the file.
1508pub fn apply_edit(original: &str, ordinal: usize, changes: &Value, capabilities: Capabilities) -> CoreResult<String> {
1509 let mut output = original.to_string();
1510 if changes.get("serverNames").is_some() {
1511 let names: Vec<String> = strings(&changes["serverNames"]).iter().map(|name| if name == "_" { Ok(name.clone()) } else { check_host(name) }).collect::<CoreResult<_>>()?;
1512 if names.is_empty() {
1513 return Err(invalid("Enter at least one domain name."));
1515 let nodes = parsed(&output)?;
1516 output = set_directive(&output, nth_server(&nodes, ordinal)?, "server_name", &names)?;
1518 if changes.get("root").is_some() {
1519 let value = text(changes, "root");
1520 let values = if value.is_empty() { Vec::new() } else { vec![check_root(value)?] };
1521 let nodes = parsed(&output)?;
1522 output = set_directive(&output, nth_server(&nodes, ordinal)?, "root", &values)?;
1524 if changes.get("index").is_some() {
1525 let value = text(changes, "index");
1526 let values = if value.is_empty() { Vec::new() } else { check_index(value)? };
1527 let nodes = parsed(&output)?;
1528 output = set_directive(&output, nth_server(&nodes, ordinal)?, "index", &values)?;
1530 if changes.get("proxyPass").is_some() {
1531 let value = check_proxy_url(text(changes, "proxyPass"))?;
1532 let nodes = parsed(&output)?;
1533 let location = root_location(nth_server(&nodes, ordinal)?).filter(|location| location.first("proxy_pass").is_some()).ok_or_else(|| edit_error("proxy_pass is not in location /. Use the configuration editor."))?;
1534 output = set_directive(&output, location, "proxy_pass", &[value])?;
1536 if let Some(redirect) = changes.get("redirect") {
1537 let target = check_proxy_url(text(redirect, "target"))?;
1538 let code = if redirect["code"].as_u64() == Some(302) { "302" } else { "301" };
1539 let nodes = parsed(&output)?;
1540 let server = nth_server(&nodes, ordinal)?;
1541 let holder = if server.first("return").is_some() { server } else { root_location(server).filter(|location| location.first("return").is_some()).ok_or_else(|| edit_error("This site has no redirect to change."))? };
1542 output = set_directive(&output, holder, "return", &[code.to_string(), format!("{}$request_uri", target.trim_end_matches('/'))])?;
1544 if on(changes, "acme") {
1545 let nodes = parsed(&output)?;
1546 let server = nth_server(&nodes, ordinal)?;
1547 if server.first("return").is_some() {
1548 return Err(edit_error("A return directive at server level answers before any location, so ACME validation cannot pass. Use the configuration editor."));
1550 if !server.all("location").any(|location| location.args.iter().any(|arg| arg.contains("acme-challenge"))) {
1551 output = insert_lines(&output, server, &acme_lines());
1554 if let Some(https) = changes.get("https").filter(|value| value.is_object()) {
1555 output = enable_https(output, ordinal, https, capabilities)?;
1557 parsed(&output)?;
1558 Ok(output)
1561#[cfg(test)]
1562mod tests {
1563 use super::*;
1565 const HAND_WRITTEN: &str = "# keep this comment\nupstream api { least_conn; server 10.0.0.1:3000; server 10.0.0.2:3000; }\nserver {\n listen 80 default_server;\n listen [::]:80 default_server;\n server_name example.com www.example.com; # names\n root /var/www/example;\n index index.html;\n my_custom_directive \"a;b{c}\" ${var}x;\n location / { try_files $uri =404; }\n location /api/ { proxy_pass http://api; proxy_set_header Upgrade $http_upgrade; }\n access_log /var/log/nginx/example.log combined;\n}\nserver {\n listen 8080;\n server_name other.example.com;\n return 301 https://example.com$request_uri;\n}\n";
1567 fn modern() -> Capabilities {
1568 Capabilities { version: (1, 26, 0), http3: true }
1571 fn request(extra: Value) -> SiteRequest {
1572 let mut input = json!({ "type": "static", "serverNames": "example.com, www.example.com", "root": "/var/www/example.com" });
1573 for (key, value) in extra.as_object().unwrap() {
1574 input[key] = value.clone();
1576 SiteRequest::from_value(&input).unwrap()
1579 #[test]
1580 fn parser_keeps_positions_quotes_and_unknown_directives() {
1581 let nodes = parse(HAND_WRITTEN).unwrap();
1582 let server = servers(&nodes)[0];
1583 let custom = server.first("my_custom_directive").unwrap();
1584 assert_eq!(custom.args, ["a;b{c}", "${var}x"]);
1585 assert_eq!(&HAND_WRITTEN[custom.start..custom.end], "my_custom_directive \"a;b{c}\" ${var}x;");
1586 for bad in ["server {", "server { listen 80 }", "}", "listen 80", "a \"b;"] {
1587 assert!(parse(bad).is_err(), "{bad}");
1589 assert!(parse("content_by_lua_block { if x then y = { 1 } end }\nserver { listen 80; }").is_ok());
1592 #[test]
1593 fn inventory_recognises_site_types() {
1594 let (sites, pools) = file_inventory("/etc/nginx/conf.d/a.conf", HAND_WRITTEN).unwrap();
1595 assert_eq!(sites.len(), 2);
1596 // A proxied /api does not make this web site a reverse proxy.
1597 assert_eq!(sites[0]["type"], "static");
1598 assert_eq!(sites[0]["proxyPass"][0], "http://api");
1599 assert_eq!(sites[0]["serverNames"][1], "www.example.com");
1600 assert_eq!(sites[0]["defaultServer"], true);
1601 assert_eq!(sites[0]["websocket"], true);
1602 assert_eq!(sites[1]["type"], "redirect");
1603 assert_eq!(sites[1]["redirect"]["code"], "301");
1604 assert_eq!(pools[0]["method"], "least_conn");
1605 let (stream, _) = file_inventory("/etc/nginx/x.conf", "stream { server { listen 53 udp; proxy_pass 10.0.0.1:53; } }").unwrap();
1606 assert_eq!(stream[0]["kind"], "stream");
1609 #[test]
1610 fn conflicts_and_backends_come_from_the_inventory() {
1611 let (mut first, pools) = file_inventory("/etc/nginx/conf.d/a.conf", HAND_WRITTEN).unwrap();
1612 let (second, _) = file_inventory("/etc/nginx/conf.d/b.conf", "server { listen 80; server_name EXAMPLE.com; proxy_pass http://[::1]:9000; fastcgi_pass unix:/run/php/x.sock; }").unwrap();
1613 first.extend(second);
1614 for site in &mut first {
1615 site["active"] = json!(true);
1617 let found = conflicts(&first);
1618 assert_eq!(found.len(), 1);
1619 assert_eq!(found[0]["name"], "example.com");
1620 let targets: Vec<String> = backend_targets(&first, &pools, &[]).iter().map(|item| item["target"].as_str().unwrap().to_string()).collect();
1621 assert!(targets.contains(&"10.0.0.1:3000".to_string()) && targets.contains(&"10.0.0.2:3000".to_string()), "{targets:?}");
1622 assert!(targets.contains(&"[::1]:9000".to_string()) && targets.contains(&"unix:/run/php/x.sock".to_string()), "{targets:?}");
1625 fn loaded(files: &[(&str, &str)]) -> (ConfigSet, BTreeSet<String>) {
1626 let sources: Vec<(String, String)> = files.iter().map(|(path, text)| (path.to_string(), text.to_string())).collect();
1627 let (set, errors) = ConfigSet::new(&sources);
1628 assert!(errors.is_empty(), "{errors:?}");
1629 let active = sources.iter().map(|(path, _)| path.clone()).collect();
1630 (set, active)
1633 /// The shape of a real TLS and HTTP proxy: routes live in map files, nothing in server_name.
1634 #[test]
1635 fn proxy_routes_come_from_included_maps_and_stream_is_recognised() {
1636 let (set, active) = loaded(&[
1637 ("/etc/nginx/nginx.conf", "events {}\nhttp { include /etc/nginx/conf.d/*.conf; }\ninclude /etc/nginx/stream-proxy.conf;\n"),
1638 ("/etc/nginx/conf.d/proxy-http.conf", "map $host $http_backend { hostnames; default 192.0.2.9:80; include proxy-routes/http.map; }\nmap $http_upgrade $proxy_connection { default upgrade; '' close; }\nserver { listen 80 default_server; server_name _; location / { proxy_pass http://$http_backend; } }\n"),
1639 ("/etc/nginx/proxy-routes/http.map", "ch-j.test 192.0.2.1:80;\nmail.ch-j.test 192.0.2.2:80;\n"),
1640 ("/etc/nginx/stream-proxy.conf", "stream { upstream reject { server unix:/run/reject.sock; } map $ssl_preread_server_name $tls_backend { hostnames; default reject; include proxy-routes/https.map; } server { listen 443; ssl_preread on; proxy_pass $tls_backend; } server { listen 443 udp; proxy_pass 192.0.2.1:443; } }\n"),
1641 ("/etc/nginx/proxy-routes/https.map", "ch-j.test 192.0.2.1:443;\n"),
1642 ]);
1643 let (mut sites, mut pools) = (Vec::new(), Vec::new());
1644 for path in &active {
1645 let (found, upstreams) = set.file_inventory(path, &active);
1646 sites.extend(found);
1647 pools.extend(upstreams);
1649 for site in &mut sites {
1650 site["active"] = json!(true);
1652 let maps = set.maps(&active);
1653 assign_roles(&mut sites, &maps);
1654 let found = routes(&sites, &maps);
1655 let hosts: Vec<(&str, &str, &str)> = found.iter().map(|route| (route["host"].as_str().unwrap(), route["backend"].as_str().unwrap(), route["protocol"].as_str().unwrap())).collect();
1656 assert!(hosts.contains(&("ch-j.test", "192.0.2.1:80", "http")), "{hosts:?}");
1657 assert!(hosts.contains(&("mail.ch-j.test", "192.0.2.2:80", "http")), "{hosts:?}");
1658 assert!(hosts.contains(&("ch-j.test", "192.0.2.1:443", "tls")), "{hosts:?}");
1659 assert!(!found.iter().any(|route| route["variable"] == "$proxy_connection"), "not a routing map");
1660 let role = |kind: &str| sites.iter().filter(|site| site["role"] == kind).count();
1661 assert_eq!((role("gateway"), role("stream"), role("site")), (1, 2, 0));
1662 let targets: Vec<String> = backend_targets(&sites, &pools, &maps).iter().map(|item| item["target"].as_str().unwrap().to_string()).collect();
1663 for expected in ["192.0.2.1:80", "192.0.2.2:80", "192.0.2.9:80", "192.0.2.1:443", "unix:/run/reject.sock"] {
1664 assert!(targets.contains(&expected.to_string()), "{expected}: {targets:?}");
1666 let udp = sites.iter().find(|site| site["listen"][0] == "443 udp").unwrap();
1667 assert_eq!(udp["ports"], json!([443]));
1668 assert_eq!(udp["tcpPorts"], json!([]));
1671 fn route_fixture() -> (Vec<Value>, Vec<Value>, Vec<Value>, BTreeMap<String, String>) {
1672 let http = "# IPv4 domain routing.\na.test 192.0.2.1:80;\nwww.a.test 192.0.2.1:80; # www\n# Second group.\nb.test 192.0.2.2:80;\n\n.wild.test 192.0.2.3:80;\n~^re[0-9]+\\.test$ 192.0.2.4:80;\n";
1673 let tls = "# IPv4 domain routing.\na.test 192.0.2.1:443;\nwww.a.test 192.0.2.1:443;\nb.test 192.0.2.2:443;\n";
1674 let files = [
1675 ("/etc/nginx/nginx.conf", "events {}\nhttp { include /etc/nginx/conf.d/*.conf; }\nstream { map $ssl_preread_server_name $tls_backend { hostnames; default reject; include proxy-routes/https.map; } upstream reject { server unix:/run/r.sock; } server { listen 443; proxy_pass $tls_backend; } }\n"),
1676 ("/etc/nginx/conf.d/p.conf", "map $host $http_backend {\n hostnames;\n default \"\";\n include /etc/nginx/proxy-routes/http.map;\n}\nserver { listen 80; server_name _; location / { proxy_pass http://$http_backend; } }\n"),
1677 ("/etc/nginx/proxy-routes/http.map", http),
1678 ("/etc/nginx/proxy-routes/https.map", tls),
1679 ];
1680 let (set, active) = loaded(&files);
1681 let (mut sites, mut pools) = (Vec::new(), Vec::new());
1682 for path in &active {
1683 let (found, upstreams) = set.file_inventory(path, &active);
1684 sites.extend(found);
1685 pools.extend(upstreams);
1687 for site in &mut sites {
1688 site["active"] = json!(true);
1690 let texts = files.iter().map(|(path, text)| (path.to_string(), text.to_string())).collect();
1691 (sites, set.maps(&active), pools, texts)
1694 fn after(change: Value) -> CoreResult<(BTreeMap<String, String>, Vec<Value>)> {
1695 let (sites, maps, pools, mut texts) = route_fixture();
1696 let (actions, warnings) = route_actions(&sites, &maps, &pools, &change)?;
1697 for action in &actions {
1698 let text = texts[&action.file].clone();
1699 texts.insert(action.file.clone(), apply_route_action(&text, action)?);
1701 Ok((texts, warnings))
1704 const HTTP_MAP: &str = "/etc/nginx/proxy-routes/http.map";
1705 const TLS_MAP: &str = "/etc/nginx/proxy-routes/https.map";
1707 #[test]
1708 fn route_change_touches_only_its_rule_in_both_files() {
1709 let (original, ..) = (route_fixture().3,);
1710 let (texts, warnings) = after(json!({ "host": "www.a.test", "mode": "update", "http": "192.0.2.9:80", "tls": "192.0.2.9:443" })).unwrap();
1711 assert_eq!(texts[HTTP_MAP], original[HTTP_MAP].replace("www.a.test 192.0.2.1:80; # www", "www.a.test 192.0.2.9:80; # www"));
1712 assert_eq!(texts[TLS_MAP], original[TLS_MAP].replace("www.a.test 192.0.2.1:443;", "www.a.test 192.0.2.9:443;"));
1713 assert!(warnings.is_empty(), "{warnings:?}");
1716 #[test]
1717 fn new_route_joins_its_backend_group_and_removal_leaves_the_rest() {
1718 let (texts, _) = after(json!({ "host": "new.test", "mode": "add", "http": "192.0.2.1:80", "tls": "192.0.2.1:443" })).unwrap();
1719 assert!(texts[HTTP_MAP].contains("www.a.test 192.0.2.1:80; # www\nnew.test 192.0.2.1:80;\n# Second group."), "{}", texts[HTTP_MAP]);
1720 assert!(texts[TLS_MAP].contains("www.a.test 192.0.2.1:443;\nnew.test 192.0.2.1:443;\nb.test"), "{}", texts[TLS_MAP]);
1721 let (original, ..) = (route_fixture().3,);
1722 let (texts, _) = after(json!({ "host": "b.test", "mode": "remove", "http": null, "tls": null })).unwrap();
1723 assert_eq!(texts[HTTP_MAP], original[HTTP_MAP].replace("b.test 192.0.2.2:80;\n", ""));
1724 assert_eq!(texts[TLS_MAP], original[TLS_MAP].replace("b.test 192.0.2.2:443;\n", ""));
1727 #[test]
1728 fn route_edits_refuse_what_they_cannot_be_sure_of() {
1729 assert_eq!(after(json!({ "host": "a.test", "mode": "add", "http": "192.0.2.1:80" })).unwrap_err().code, "NGINX_ROUTE_EXISTS");
1730 for bad in ["192.0.2.1:80; evil", "$http_host", "http://x", ""] {
1731 if bad.is_empty() { continue; }
1732 assert_eq!(after(json!({ "host": "n.test", "mode": "add", "http": bad })).unwrap_err().code, "NGINX_SITE_INVALID", "{bad}");
1734 for bad in ["a b", "a.test; x", "~regex", ""] {
1735 assert_eq!(after(json!({ "host": bad, "mode": "add", "http": "192.0.2.1:80" })).unwrap_err().code, "NGINX_SITE_INVALID", "{bad}");
1737 assert!(after(json!({ "host": "n.test", "mode": "add", "tls": "reject" })).is_ok(), "an upstream group of the configuration is a valid target");
1738 // Twice the same name: which one wins is a human decision.
1739 let (sites, mut maps, pools, _) = route_fixture();
1740 for map in &mut maps {
1741 let copy = map["entries"][1].clone();
1742 map["entries"].as_array_mut().unwrap().push(copy);
1744 let key = maps[0]["entries"][1]["key"].as_str().unwrap().to_string();
1745 let code = route_actions(&sites, &maps, &pools, &json!({ "host": key, "mode": "update", "http": "192.0.2.1:80", "tls": "192.0.2.1:443" })).unwrap_err().code;
1746 assert_eq!(code, "NGINX_EDIT_UNSUPPORTED");
1749 #[test]
1750 fn route_warnings_explain_consequences() {
1751 let code = |warnings: &[Value]| warnings.iter().map(|item| item["code"].as_str().unwrap().to_string()).collect::<Vec<_>>();
1752 assert_eq!(code(&after(json!({ "host": "only.test", "mode": "add", "http": "192.0.2.1:80" })).unwrap().1), ["one-protocol"]);
1753 let (_, warnings) = after(json!({ "host": "x.wild.test", "mode": "add", "http": "192.0.2.1:80", "tls": "192.0.2.1:443" })).unwrap();
1754 assert_eq!(warnings[0]["code"], "overrides-rule");
1755 assert_eq!(warnings[0]["rule"], ".wild.test");
1758 #[test]
1759 fn the_winning_rule_follows_nginx_priorities() {
1760 let map = json!({ "hostnames": true, "entries": [
1761 { "key": "default", "kind": "default", "value": "d" }, { "key": "~^api", "kind": "regex", "value": "r" }, { "key": ".a.test", "kind": "wildcard", "value": "w1" },
1762 { "key": "*.deep.a.test", "kind": "wildcard", "value": "w2" }, { "key": "exact.a.test", "kind": "exact", "value": "e" }, { "key": "mail.*", "kind": "wildcard", "value": "w3" } ] });
1763 for (host, value) in [("EXACT.a.test", "e"), ("a.test", "w1"), ("x.a.test", "w1"), ("x.deep.a.test", "w2"), ("mail.b.test", "w3"), ("api.b.test", "r"), ("other.test", "d")] {
1764 assert_eq!(winning_rule(&map, host).unwrap()["value"], value, "{host}");
1768 #[test]
1769 fn domain_path_names_the_file_and_rule() {
1770 let (sites, maps, ..) = route_fixture();
1771 let rules = domain_rules("a.test", &sites, &maps);
1772 let http = rules.iter().find(|rule| rule["protocol"] == "http").unwrap();
1773 assert_eq!((http["ruleFile"].as_str().unwrap(), http["ruleKey"].as_str().unwrap(), http["backend"].as_str().unwrap()), (HTTP_MAP, "a.test", "192.0.2.1:80"));
1774 let unknown = domain_rules("unknown.test", &sites, &maps);
1775 assert!(unknown.iter().all(|rule| rule["ruleKind"] == "default"), "{unknown:?}");
1778 #[test]
1779 fn a_new_rule_goes_into_a_map_block_when_it_has_no_include() {
1780 let (set, active) = loaded(&[("/etc/nginx/nginx.conf", "http { map $host $b {\n hostnames;\n default \"\";\n}\nserver { listen 80; server_name _; location / { proxy_pass http://$b; } } }\n")]);
1781 let (mut sites, _) = set.file_inventory("/etc/nginx/nginx.conf", &active);
1782 sites[0]["active"] = json!(true);
1783 let maps = set.maps(&active);
1784 let (actions, _) = route_actions(&sites, &maps, &[], &json!({ "host": "a.test", "mode": "add", "http": "192.0.2.1:80" })).unwrap();
1785 let text = apply_route_action(set.files[0].2.as_str(), &actions[0]).unwrap();
1786 assert!(text.contains("default \"\";\n a.test 192.0.2.1:80;\n}"), "{text}");
1789 #[test]
1790 fn templates_fill_checked_values_and_refuse_unknown_placeholders() {
1791 let site = |template: &str| SiteRequest::from_value(&json!({ "type": "custom", "serverNames": "a.test b.test", "root": "/var/www/a", "port": 8080, "template": template })).unwrap();
1792 let text = render_template(&site("server { listen {{port}}; server_name {{names}}; root {{ root }}; my_directive {{domain}}; }")).unwrap();
1793 assert!(text.contains("listen 8080; server_name a.test b.test; root /var/www/a; my_directive a.test;"), "{text}");
1794 assert_eq!(file_inventory("/x", &text).unwrap().0[0]["generated"], true);
1795 for bad in ["server { listen {{nope}}; }", "server { proxy_pass {{backend}}; }", "listen 80;", "server {", ""] {
1796 assert_eq!(render_template(&site(bad)).unwrap_err().code, "NGINX_SITE_INVALID", "{bad}");
1798 assert!(SiteRequest::from_value(&json!({ "type": "custom", "serverNames": "a.test", "root": "/etc", "template": "x" })).is_err(), "values are still checked");
1801 /// The shape of a real web server: locations in snippets, canonical redirects, catch-all blocks.
1802 #[test]
1803 fn includes_are_read_and_redirects_belong_to_their_site() {
1804 let (set, active) = loaded(&[
1805 ("/etc/nginx/nginx.conf", "events {}\nhttp { map $host $canonical { a.test a.test; www.a.test a.test; } include sites-enabled/*; }\n"),
1806 ("/etc/nginx/snippets/api.conf", "location /api/ { proxy_pass http://unix:/run/api.sock:/; }\nlocation ~ \\.php$ { include snippets/php.conf; }\n"),
1807 ("/etc/nginx/snippets/php.conf", "fastcgi_pass unix:/run/php/php-fpm.sock;\n"),
1808 ("/etc/nginx/sites-enabled/main.conf", "server { listen 80 default_server; server_name _; return 444; }\nserver { listen 80; server_name a.test; return 301 https://a.test$request_uri; }\nserver { listen 80; server_name www.a.test; return 301 https://$canonical$request_uri; }\nserver { listen 443 ssl; server_name a.test; ssl_certificate /c.pem; root /var/www/a; include snippets/api.conf; }\nserver { listen 80; server_name old.test; return 301 https://elsewhere.example$request_uri; }\nserver { listen 443 ssl; server_name other.test; ssl_certificate /c.pem; root /var/www/o; }\nserver { listen 80; server_name other.test; return 301 https://$canonical$request_uri; }\n"),
1809 ]);
1810 let (mut sites, _) = set.file_inventory("/etc/nginx/sites-enabled/main.conf", &active);
1811 for site in &mut sites {
1812 site["active"] = json!(true);
1814 let maps = set.maps(&active);
1815 assign_roles(&mut sites, &maps);
1816 let roles: Vec<&str> = sites.iter().map(|site| site["role"].as_str().unwrap()).collect();
1817 assert_eq!(roles, ["catch-all", "companion", "companion", "site", "redirect", "site", "companion"]);
1818 assert_eq!(sites[6]["companionOf"], "/etc/nginx/sites-enabled/main.conf#5", "a shared map does not pull it to the first site");
1819 assert_eq!(sites[5]["redirectsHttp"], true);
1820 assert_eq!(sites[3]["type"], "php", "php comes from a nested include");
1821 assert_eq!(sites[3]["ordinal"], 3, "the position stays the one in the raw file");
1822 assert_eq!(sites[3]["locations"], 2);
1823 assert_eq!(sites[3]["proxyPass"][0], "http://unix:/run/api.sock:/");
1824 assert_eq!(sites[3]["redirectsHttp"], true);
1825 assert_eq!(sites[3]["aliases"], json!(["www.a.test"]));
1826 let targets: Vec<String> = backend_targets(&sites, &[], &maps).iter().map(|item| item["target"].as_str().unwrap().to_string()).collect();
1827 assert_eq!(targets, ["unix:/run/api.sock", "unix:/run/php/php-fpm.sock"]);
1830 #[test]
1831 fn layout_follows_real_includes() {
1832 assert_eq!(detect_layout(&["/etc/nginx/conf.d/*.conf".into(), "/etc/nginx/sites-enabled/*".into()]).unwrap().kind, "sites");
1833 assert_eq!(detect_layout(&["/etc/nginx/conf.d/*.conf".into()]).unwrap().directory, "/etc/nginx/conf.d");
1834 assert_eq!(detect_layout(&["/etc/nginx/http.d/*.conf".into()]).unwrap().kind, "http.d");
1835 assert!(detect_layout(&["mime.types".into()]).is_none());
1836 let nodes = parse("events {}\nhttp { include mime.types; include /etc/nginx/conf.d/*.conf; }\n").unwrap();
1837 assert_eq!(http_includes(&nodes).len(), 2);
1840 #[test]
1841 fn hostile_values_never_reach_the_text() {
1842 for bad in ["a.com; }", "a b", "a.com\nserver", "$host", "-x", ""] {
1843 assert!(check_host(bad).is_err(), "{bad}");
1845 for bad in ["/etc/nginx", "/var/www/../../etc", "relative", "/root/x", "/var/www/a;b", "/usr/share/x", "/"] {
1846 assert!(check_root(bad).is_err(), "{bad}");
1848 for bad in ["ftp://x", "http://a b", "http://a;", "http://a:99999", "http://a/$b", "http://a/{x}"] {
1849 assert!(check_proxy_url(bad).is_err(), "{bad}");
1851 assert!(check_proxy_url("http://127.0.0.1:3000/").is_ok());
1852 assert!(check_backend("unix:/run/php/php8.3-fpm.sock").is_ok());
1853 assert!(check_backend("127.0.0.1:9000;").is_err());
1854 assert!(SiteRequest::from_value(&json!({ "type": "shell", "serverNames": "a.com" })).is_err());
1857 #[test]
1858 fn every_template_renders_a_parsable_site() {
1859 let templates = [
1860 json!({ "type": "static" }),
1861 json!({ "type": "spa" }),
1862 json!({ "type": "php", "phpBackend": "unix:/run/php/php8.3-fpm.sock" }),
1863 json!({ "type": "proxy", "backend": "http://127.0.0.1:3000" }),
1864 json!({ "type": "websocket", "backend": "http://127.0.0.1:3000", "readTimeout": "3600s" }),
1865 json!({ "type": "loadbalance", "backends": ["10.0.0.1:80", "10.0.0.2:80"], "method": "least_conn" }),
1866 json!({ "type": "redirect", "redirectTarget": "https://example.org/" }),
1867 ];
1868 for template in templates {
1869 for https in [false, true] {
1870 let mut extra = template.clone();
1871 if https {
1872 extra["https"] = json!(true);
1873 extra["certificate"] = json!("/etc/ssl/a.pem");
1874 extra["certificateKey"] = json!("/etc/ssl/a.key");
1875 extra["redirectHttp"] = json!(true);
1876 extra["http2"] = json!(true);
1878 extra["securityHeaders"] = json!(true);
1879 extra["rateLimit"] = json!(true);
1880 extra["acme"] = json!(true);
1881 let site = request(extra);
1882 let rendered = render_site(&site, modern());
1883 let (sites, _) = file_inventory("/etc/nginx/conf.d/a.conf", &rendered).unwrap_or_else(|e| panic!("{e}\n{rendered}"));
1884 assert_eq!(sites.len(), if https { 2 } else { 1 }, "{rendered}");
1885 assert_eq!(sites[0]["generated"], true);
1886 assert_eq!(sites[0]["https"], https, "{rendered}");
1887 assert_eq!(sites[sites.len() - 1]["acme"], true, "{rendered}");
1888 assert_ne!(sites[0]["root"], ACME_ROOT, "{rendered}");
1893 #[test]
1894 fn http_versions_follow_the_server_version() {
1895 let site = request(json!({ "https": true, "certificate": "/c.pem", "certificateKey": "/k.pem", "http2": true }));
1896 assert!(render_site(&site, Capabilities { version: (1, 24, 0), http3: false }).contains("listen 443 ssl http2;"));
1897 let current = render_site(&site, modern());
1898 assert!(current.contains("listen 443 ssl;") && current.contains("http2 on;"), "{current}");
1901 #[test]
1902 fn edits_keep_everything_they_do_not_change() {
1903 let edited = apply_edit(HAND_WRITTEN, 0, &json!({ "root": "/srv/new", "serverNames": ["example.org"] }), modern()).unwrap();
1904 assert_eq!(edited, HAND_WRITTEN.replace("root /var/www/example;", "root /srv/new;").replace("server_name example.com www.example.com;", "server_name example.org;"));
1905 let removed = apply_edit(HAND_WRITTEN, 0, &json!({ "index": "" }), modern()).unwrap();
1906 assert_eq!(removed, HAND_WRITTEN.replace(" index index.html;\n", ""));
1907 let added = apply_edit(HAND_WRITTEN, 1, &json!({ "root": "/srv/added" }), modern()).unwrap();
1908 assert!(added.contains("server_name other.example.com;\n root /srv/added;\n"), "{added}");
1909 assert_eq!(apply_edit(HAND_WRITTEN, 9, &json!({ "root": "/srv/x" }), modern()).unwrap_err().code, "NGINX_CONFIG_CHANGED");
1910 assert_eq!(apply_edit(HAND_WRITTEN, 0, &json!({ "root": "/etc/x" }), modern()).unwrap_err().code, "NGINX_SITE_INVALID");
1911 assert_eq!(apply_edit("server { listen 80; root /a; root /b; }", 0, &json!({ "root": "/srv/x" }), modern()).unwrap_err().code, "NGINX_EDIT_UNSUPPORTED");
1914 #[test]
1915 fn enabling_https_moves_plain_listeners_to_a_redirect_block() {
1916 let changes = json!({ "https": { "certificate": "/etc/ssl/a.pem", "certificateKey": "/etc/ssl/a.key", "redirectHttp": true, "http2": true } });
1917 let edited = apply_edit(HAND_WRITTEN, 0, &changes, modern()).unwrap();
1918 let (sites, _) = file_inventory("/x", &edited).unwrap();
1919 assert_eq!(sites.len(), 3, "{edited}");
1920 assert_eq!(sites[0]["https"], true);
1921 assert_eq!(sites[0]["http2"], true);
1922 assert_eq!(sites[0]["ports"], json!([443]));
1923 assert_eq!(sites[1]["ports"], json!([80]));
1924 assert_eq!(sites[1]["defaultServer"], true);
1925 assert_eq!(sites[1]["type"], "redirect");
1926 assert!(edited.contains("my_custom_directive \"a;b{c}\" ${var}x;") && edited.contains("# keep this comment"));
1927 assert_eq!(apply_edit(&edited, 0, &changes, modern()).unwrap_err().code, "NGINX_EDIT_UNSUPPORTED");
1930 #[test]
1931 fn acme_location_is_added_once_and_refused_behind_a_server_return() {
1932 let once = apply_edit(HAND_WRITTEN, 0, &json!({ "acme": true }), modern()).unwrap();
1933 assert!(once.contains(ACME_ROOT));
1934 assert_eq!(apply_edit(&once, 0, &json!({ "acme": true }), modern()).unwrap(), once);
1935 assert_eq!(apply_edit(HAND_WRITTEN, 1, &json!({ "acme": true }), modern()).unwrap_err().code, "NGINX_EDIT_UNSUPPORTED");
1938 #[test]
1939 fn acme_root_matches_the_remote_helper() {
1940 assert!(crate::core::matrix::script("nginx.sh").contains(&format!("acme_root={ACME_ROOT}")));
SHA-2563353a39218d8e4d339c64441f912a0b756cb031f7074b4ed0b7b5ce1cb025b1e

CH-J Proprietary Software License 1.14 · ソースは CH-J Proprietary Software License 1.14 に基づいて提供されています。公開されていても、ライセンス条項が変わったり、追加の権利が付与されたりすることはありません。

アーカイブの SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
ファイル