प्रकाशित सोर्स
सोर्स ZIP डाउनलोड करें
CH-J Tools
किसी विशेष ऐप्लिकेशन रिलीज़ की डायरेक्टरी और फ़ाइलें देखें।
CH-J / Server Manager
1704 फ़ाइलें
0.1.2-beta
जोड़ा गया: · बिल्ड किया गया:
1
//! NGINX configuration as text: a position-keeping parser, the site inventory, generated sites and edits2
//! that touch only the requested directives. Nothing here talks to a server.4
use crate::core::error::{CoreError, CoreResult};5
use once_cell::sync::Lazy;6
use regex::Regex;7
use serde_json::{json, Value};8
use std::collections::{BTreeMap, BTreeSet};10
/// Webroot for ACME challenges; the same path is in `nginx.sh`.11
pub const ACME_ROOT: &str = "/var/www/chj-acme";12
pub const GENERATED_MARK: &str = "# Managed by CH-J Server Manager. Manual changes are kept.";13
const MAX_DEPTH: usize = 32;15
static 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());16
static PATH: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/[A-Za-z0-9._/-]{1,255}$").unwrap());17
static 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());18
static BACKEND: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(unix:/[A-Za-z0-9._/-]{1,255}|([A-Za-z0-9._-]+|\[[0-9A-Fa-f:]+\]):[0-9]{1,5})$").unwrap());19
static CHUNK: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._-]{1,64}$").unwrap());20
static DURATION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[0-9]{1,5}(ms|s|m)?$").unwrap());22
fn invalid(message: impl Into<String>) -> CoreError {23
CoreError::new("NGINX_SITE_INVALID", message.into())24
}26
// ---------------------------------------------------------------------------------------------27
// Parser28
// ---------------------------------------------------------------------------------------------30
#[derive(Debug, Clone)]31
pub 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>>,38
}40
impl 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
}52
}54
struct Parser<'a> {55
text: &'a str,56
bytes: &'a [u8],57
at: usize,58
}60
impl 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
}192
}194
pub fn parse(text: &str) -> Result<Vec<Node>, String> {195
Parser { text, bytes: text.as_bytes(), at: 0 }.block(0)196
}198
/// Server blocks of a file in document order. The position in this list identifies a site in the file.199
fn 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
found209
}211
fn in_stream_block(nodes: &[Node], server: &Node) -> bool {212
nodes.iter().any(|node| node.name == "stream" && node.start <= server.start && server.end <= node.end)213
}215
fn 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
found225
}227
/// Include patterns of the `http` context, or of the file itself for fragments.228
pub 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
found236
}238
// ---------------------------------------------------------------------------------------------239
// Inventory240
// ---------------------------------------------------------------------------------------------242
fn 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()253
}255
/// Directives of a server block and of the locations in it, nested locations included.256
fn 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
}265
}267
fn 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
values279
}281
fn root_location(server: &Node) -> Option<&Node> {282
server.all("location").find(|location| location.args.len() == 1 && location.args[0] == "/")283
}285
fn is_redirect(node: &Node) -> bool {286
node.args.first().is_some_and(|code| matches!(code.as_str(), "301" | "302" | "303" | "307" | "308"))287
}289
/// What the site is, judged by who answers `/`: a proxied /api does not turn a web site into a reverse proxy.290
fn 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"315
}317
/// The site's own root: a root inside a location (such as the ACME one) only serves that location.318
fn 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())321
}323
/// Every file NGINX loaded. Includes are expanded for reading only: edits always work on the raw text of one file.324
pub struct ConfigSet {325
files: Vec<(String, Vec<Node>, String)>,326
base: String,327
}329
impl 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
out366
}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
copy372
}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
found397
}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
None414
}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
site432
})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
found465
}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
}485
}487
fn 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
}497
}499
fn 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
})542
}544
fn count_locations(block: &Node) -> usize {545
block.all("location").map(|location| 1 + count_locations(location)).sum()546
}548
/// One file on its own, for code that has no whole configuration at hand.549
pub 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()])))555
}557
const HOST_SOURCES: [&str; 4] = ["$host", "$http_host", "$server_name", "$ssl_preread_server_name"];559
fn 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" })562
}564
fn 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))))567
}569
/// Which domain goes where, from the maps that an enabled proxy_pass really uses.570
pub 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
found580
}582
/// The rule NGINX picks for a host: exact name, longest leading wildcard, longest trailing wildcard, first regular583
/// expression, then the default. Regular expressions use this program's engine, which may differ from NGINX in rare cases.584
pub 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()619
}621
/// One change of one rule in one file. The value `None` removes the rule.622
#[derive(Debug, Clone)]623
pub 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,631
}633
fn 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())637
}639
/// A backend in a route: an address, or an upstream group the configuration already defines.640
fn 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)645
}647
/// Turns a requested change into rule edits. Anything the program cannot be sure about is refused with a pointer to the648
/// editor: several routing maps, the same name twice, or a rule that is not a plain name.649
pub 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))706
}708
fn 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
None721
}723
/// Changes one rule in the text of its file. Comments, blank lines and every other rule keep their bytes.724
pub 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)762
}764
/// The routing rule of a domain in each protocol, for showing its whole path.765
pub 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
found775
}777
/// A site whose server_name answers for the host, when no routing map decides.778
pub 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 == host787
}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)))792
}794
fn 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()]808
}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`.812
pub 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
}860
}862
/// Log paths from any level of the file. Values with variables and non-file targets cannot be read back.863
pub 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
}870
}872
/// The same name on the same port in two enabled server blocks: NGINX keeps the first and ignores the other.873
pub 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()886
}888
/// `host:port` or `unix:/path` targets behind proxy_pass and fastcgi_pass, with upstream names and map values expanded.889
pub 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()937
}939
// ---------------------------------------------------------------------------------------------940
// Capabilities and layout941
// ---------------------------------------------------------------------------------------------943
#[derive(Debug, Clone, Copy, Default)]944
pub struct Capabilities {945
pub version: (u32, u32, u32),946
pub http3: bool,947
}949
impl 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
}954
}956
pub 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)))959
}961
#[derive(Debug, Clone, PartialEq)]962
pub struct Layout {963
pub kind: &'static str,964
pub directory: &'static str,965
pub enabled: Option<&'static str>,966
pub suffix: &'static str,967
}969
/// Where new sites go on this server, taken from what nginx.conf really includes, not from the distribution name.970
pub 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
None980
}981
}983
// ---------------------------------------------------------------------------------------------984
// Value checks: everything that reaches a configuration file passes through one of these.985
// ---------------------------------------------------------------------------------------------987
pub 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())992
}994
pub 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())999
}1001
/// A web root is served to the world, so system directories are refused.1002
pub 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.")));1007
}1008
Ok(path)1009
}1011
pub 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.")));1015
}1016
Ok(value.to_string())1017
}1019
pub 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.")));1022
}1023
Ok(value.to_string())1024
}1026
fn 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."));1030
}1031
Ok(parts)1032
}1034
fn check_duration(value: &str) -> CoreResult<String> {1035
if !DURATION.is_match(value) {1036
return Err(invalid(format!("'{value}' is not a valid timeout.")));1037
}1038
Ok(value.to_string())1039
}1041
fn 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.")),1045
}1046
}1048
fn 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(),1053
}1054
}1056
fn text<'a>(value: &'a Value, key: &str) -> &'a str {1057
value.get(key).and_then(Value::as_str).map_or("", str::trim)1058
}1060
fn on(value: &Value, key: &str) -> bool {1061
value.get(key) == Some(&Value::Bool(true))1062
}1064
// ---------------------------------------------------------------------------------------------1065
// Generated sites1066
// ---------------------------------------------------------------------------------------------1068
#[derive(Debug, Clone)]1069
pub 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,1097
}1099
impl 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."));1104
}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."));1108
}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)?));1112
}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."));1122
}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."));1126
}1127
} else if matches!(kind.as_str(), "proxy" | "websocket") {1128
proxy_url = check_proxy_url(text(input, "backend"))?;1129
}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
})1164
}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(),1171
}1172
}1174
pub fn file_name(&self) -> String {1175
self.names[0].replacen("*.", "wildcard.", 1)1176
}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()1181
}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] }1186
}1187
}1189
const 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.1193
pub 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."));1197
}1198
let mut problem = None;1199
let filled = PLACEHOLDER.replace_all(&request.template, |caps: ®ex::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}}}}}.") });1211
}1212
value1213
}).into_owned();1214
if let Some(message) = problem {1215
return Err(invalid(message));1216
}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."));1220
}1221
Ok(format!("{GENERATED_MARK}\n\n{filled}{}", if filled.ends_with('\n') { "" } else { "\n" }))1222
}1224
fn 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(),1230
]1231
}1233
fn 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());1238
}1239
if !request.connect_timeout.is_empty() {1240
lines.push(format!(" proxy_connect_timeout {};", request.connect_timeout));1241
}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));1245
}1246
lines.push("}".into());1247
lines1248
}1250
fn 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());1260
}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()]);1267
}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()]),1271
}1272
lines1273
}1275
fn 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');1284
}1285
}1286
out.push_str("}\n");1287
}1289
fn 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};"));1293
}1294
lines1295
}1297
/// The text of a new site. The text is rebuilt in Core from checked values and never accepted from the module.1298
pub 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));1305
}1306
for backend in &request.backends {1307
out.push_str(&format!(" server {backend};\n"));1308
}1309
out.push_str("}\n\n");1310
}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"));1313
}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));1322
}1323
if !request.redirect_http {1324
main.extend(listen_lines(request.port, "", request.ipv6));1325
}1326
if request.http2 && capabilities.http2_directive() {1327
main.push("http2 on;".into());1328
}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());1335
}1336
} else {1337
main.extend(listen_lines(request.port, "", request.ipv6));1338
main.push(names.clone());1339
}1340
main.push(String::new());1341
if request.http3 {1342
main.push(format!("add_header Alt-Svc 'h3=\":{}\"; ma=86400' always;", request.https_port));1343
}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());1350
}1351
}1352
if request.rate_limit {1353
main.push(format!("limit_req zone=chj_{slug} burst=20 nodelay;"));1354
}1355
if request.acme && !(request.https && request.redirect_http) {1356
main.extend(acme_lines());1357
}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());1367
}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);1371
}1372
out1373
}1375
// ---------------------------------------------------------------------------------------------1376
// Edits: the file is parsed again for every change and only the bytes of that directive change.1377
// ---------------------------------------------------------------------------------------------1379
fn edit_error(message: impl Into<String>) -> CoreError {1380
CoreError::new("NGINX_EDIT_UNSUPPORTED", message.into())1381
}1383
fn 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."))1385
}1387
fn 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.")))1389
}1391
/// The start of the line when the directive is alone on it.1392
fn 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)1399
}1400
}1402
fn 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()1405
}1407
/// New lines at the top of a block, after `server_name` when there is one.1408
fn 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))1414
}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))1418
}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)))1422
}1423
};1424
let mut inserted = String::new();1425
if at > 0 && text.as_bytes().get(at - 1) != Some(&b'\n') {1426
inserted.push('\n');1427
}1428
for line in lines {1429
inserted.push_str(&indent);1430
inserted.push_str(line);1431
inserted.push('\n');1432
}1433
format!("{}{}{}", &text[..at], inserted, &text[at..])1434
}1436
/// Replaces, adds or (with no values) removes one directive of a block. Several of the same name are not guessed at.1437
fn 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())));1441
}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..])1447
}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
})1452
}1454
fn 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."));1462
}1463
if listens.is_empty() {1464
return Err(edit_error("This site has no listen directive. Use the configuration editor."));1465
}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());1472
}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"));1487
}1488
if !names.is_empty() {1489
redirect_block.push_str(&format!(" server_name {};\n", names.join(" ")));1490
}1491
if has_acme {1492
for line in acme_lines() {1493
redirect_block.push_str(&format!(" {line}\n"));1494
}1495
}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, "");1500
}1501
}1502
let nodes = parsed(&text)?;1503
let server = nth_server(&nodes, ordinal)?;1504
Ok(insert_lines(&text, server, &lines))1505
}1507
/// Applies graphical changes to one server block and keeps every other byte of the file.1508
pub 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."));1514
}1515
let nodes = parsed(&output)?;1516
output = set_directive(&output, nth_server(&nodes, ordinal)?, "server_name", &names)?;1517
}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)?;1523
}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)?;1529
}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])?;1535
}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('/'))])?;1543
}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."));1549
}1550
if !server.all("location").any(|location| location.args.iter().any(|arg| arg.contains("acme-challenge"))) {1551
output = insert_lines(&output, server, &acme_lines());1552
}1553
}1554
if let Some(https) = changes.get("https").filter(|value| value.is_object()) {1555
output = enable_https(output, ordinal, https, capabilities)?;1556
}1557
parsed(&output)?;1558
Ok(output)1559
}1561
#[cfg(test)]1562
mod 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 }1569
}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();1575
}1576
SiteRequest::from_value(&input).unwrap()1577
}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}");1588
}1589
assert!(parse("content_by_lua_block { if x then y = { 1 } end }\nserver { listen 80; }").is_ok());1590
}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");1607
}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);1616
}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:?}");1623
}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)1631
}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);1648
}1649
for site in &mut sites {1650
site["active"] = json!(true);1651
}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:?}");1665
}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!([]));1669
}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);1686
}1687
for site in &mut sites {1688
site["active"] = json!(true);1689
}1690
let texts = files.iter().map(|(path, text)| (path.to_string(), text.to_string())).collect();1691
(sites, set.maps(&active), pools, texts)1692
}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)?);1700
}1701
Ok((texts, warnings))1702
}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:?}");1714
}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", ""));1725
}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}");1733
}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}");1736
}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);1743
}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");1747
}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");1756
}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}");1765
}1766
}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:?}");1776
}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}");1787
}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}");1797
}1798
assert!(SiteRequest::from_value(&json!({ "type": "custom", "serverNames": "a.test", "root": "/etc", "template": "x" })).is_err(), "values are still checked");1799
}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);1813
}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"]);1828
}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);1838
}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}");1844
}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}");1847
}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}");1850
}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());1855
}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);1877
}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}");1889
}1890
}1891
}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}");1899
}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");1912
}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");1928
}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");1936
}1938
#[test]1939
fn acme_root_matches_the_remote_helper() {1940
assert!(crate::core::matrix::script("nginx.sh").contains(&format!("acme_root={ACME_ROOT}")));1941
}1942
}SHA-256
3353a39218d8e4d339c64441f912a0b756cb031f7074b4ed0b7b5ce1cb025b1eCH-J Proprietary Software License 1.14 · सोर्स CH-J Proprietary Software License 1.14 के तहत उपलब्ध कराया जाता है। इसकी उपलब्धता लाइसेंस की शर्तें नहीं बदलती और न ही अतिरिक्त अधिकार देती है।
आर्काइव SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f