Developer tools

nginx configuration

nginx configuration parsed as nginx's own reader does, quotes, `${var}`, escapes and comments, printed back, includes followed with globs. Servers, locations and upstreams listed, which block a host and path reach by nginx's order, and the usual mistakes: the alias slash trap, add_header dropping the parent's, $uri in redirects, `if` in a location, old TLS, duplicate names.

Version 1.0.0 · 6 functions · 1 crate

#nginx#configuration#web#lint

Functions

What use nginx_conf.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use nginx_conf_parse, nginx_conf_print from nginx_conf (Quillo 0.6).

nginx_conf_parse(text: str) → listor an error

Parses configuration text to a tree: each node is { directive, args, line, block } or { comment, line }. Errors name the line, as nginx -t does.

nginx_conf_print(tree: list) → stror an error

Prints a tree back as configuration, four spaces to a level; parsing the output gives the same tree.

nginx_conf_load(path: str) → anyor an error

Reads a configuration file and follows its includes, globs too, relative to that file's folder as nginx does for its main file: { tree, files, problems }, each node saying which file it came from.

nginx_conf_servers(tree: list) → anyor an error

Every server block: listen addresses, names, TLS, root and its locations; and every upstream, with its backends.

nginx_conf_match(tree: list, port: int, host: str, path: str) → anyor an error

Which server and location a request reaches, by nginx's rules: exact match, then the longest prefix, then regexes in order unless ^~ stopped them. regex locations are matched only as far as a plain test allows: see regex_note.

nginx_conf_lint(tree: list) → listor an error

Common mistakes: [{ line, rule, message }], for the alias slash trap, add_header that drops the parent's, $uri in redirects, if in a location, old TLS, a TLS server with no certificate, and more. Duplicate server names are found too.

Get it

$ quillo install nginx_conf               # nginx_conf.qwer into this folder$ quillo install nginx_conf --example     # and its example, nginx_conf.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/nginx_conf.qwer$ curl -O https://quillolang.org/plugins/files/nginx_conf.qwe$ curl -O https://quillolang.org/plugins/files/nginx_conf_sample.conf$ curl -O https://quillolang.org/plugins/files/nginx_conf_sample_shop.inc$ curl -O https://quillolang.org/plugins/files/nginx_conf_sample_zz.inc

Then, in your program, all of it or only what you call:

main.qwe
use nginx_conf.qweruse nginx_conf_parse, nginx_conf_print from nginx_conf      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }

Example

nginx_conf.qwe
// A web server's configuration looked at from a program: read with its includes, the sites it// serves, which block a request lands in, the usual mistakes, and the file written back.////   quillo run nginx_conf.qwe      then open http://localhost:8080/load, /servers, /match, /lint, /print, /flat and /brokenuse "nginx_conf.qwer"route GET /load {    let c = nginx_conf_load("nginx_conf_sample.conf")?    let http = c.tree | filter((n) => n.directive == "http")    {        files: c.files, problems: c.problems, top: c.tree | map((n) => n.directive ?? "#"),        in_http: http[0].block | map((n) => n.directive),        from: http[0].block[len(http[0].block) - 1].file,    }}route GET /servers {    let c = nginx_conf_load("nginx_conf_sample.conf")?    let s = nginx_conf_servers(c.tree)?    {        names: s.servers | map((x) => [x.names, x.listen | map((l) => l.port), x.tls]),        locations: s.servers[0].locations | map((l) => [l.kind, l.path, l.proxy_pass ?? l.alias ?? l.return]),        upstream: s.upstreams[0],    }}route GET /match {    let tree = nginx_conf_load("nginx_conf_sample.conf")?.tree    {        exact: nginx_conf_match(tree, 8443, "shop.example", "/health")?,        api: nginx_conf_match(tree, 8443, "SHOP.example", "/api/users/1")?,        stop: nginx_conf_match(tree, 8443, "shop.example", "/images/a.gif")?,        wild: nginx_conf_match(tree, 8080, "eu.shop.example", "/")?.server,        fallback: nginx_conf_match(tree, 8080, "other.example", "/")?,        no_port: nginx_conf_match(tree, 9999, "x", "/").error,    }}route GET /lint = nginx_conf_lint(nginx_conf_load("nginx_conf_sample.conf")?.tree)? | map((x) => `${x.line} ${x.rule}: ${x.message}`)let TEXT = join([    "# site",    "server{listen 80;server_name a.example;",    "  location ~ \"^/x{3}$\" { return 200 'say \"hi\"'; }",    "  rewrite ^/a\\.b$ /c last; # trailing note",    "  set $v \"tab\\there\";",    "  location / { root /var/${host}/x; }",    "}",    "",], "\n")route GET /print {    let tree = nginx_conf_parse(TEXT)?    let out = nginx_conf_print(tree)?    {        out: out,        args: [tree[1].block[2].args, tree[1].block[3].args, tree[1].block[5].args],        again: nginx_conf_print(nginx_conf_parse(out)?)? == out,        lines: [tree[0].line, tree[1].line, tree[1].block[2].line],    }}// The whole configuration in one file, includes in place, for a look or for `nginx -t`.route GET /flat = respond(200, nginx_conf_print(nginx_conf_load("nginx_conf_sample.conf")?.tree)?, { "content-type": "text/plain" })route GET /broken = {    open: nginx_conf_parse("server {").error,    close: nginx_conf_parse("a b;\n}\n").error,    semi: nginx_conf_parse("listen 80").error,    quote: nginx_conf_parse("a \"b;\n").error,    after: nginx_conf_parse("a \"b\"c;").error,    empty: nginx_conf_parse(";").error,    no_file: nginx_conf_load("nothing.conf").error,    bad_node: nginx_conf_print([{ args: ["x"] }]).error,}
$ quillo run nginx_conf.qwe$ curl 'localhost:8080/load'$ curl 'localhost:8080/servers'$ curl 'localhost:8080/match'$ curl 'localhost:8080/lint'$ curl 'localhost:8080/print'$ curl 'localhost:8080/flat'$ curl 'localhost:8080/broken'

Source

nginx_conf.qwer
//! nginx configuration parsed to a tree and printed back, includes followed, `server` and `location` blocks listed, the block a request lands in worked out, and common mistakes noticed.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::path::{Path, PathBuf};const MAX_DEPTH: usize = 64;const MAX_INCLUDES: usize = 32;#[derive(Clone, Debug)]enum Tok {    Word(String),    Semi,    Open,    Close,    Comment(String),}/// As nginx's own ngx_conf_read_token: `{` and `;` end a word, unless the `{` is `${`; `}` only counts alone.fn tokens(text: &str) -> Result<Vec<(Tok, usize)>, String> {    let c: Vec<char> = text.chars().collect();    let mut i = 0;    let mut line = 1;    let mut out = vec![];    while i < c.len() {        let ch = c[i];        if ch == '\n' {            line += 1;            i += 1;        } else if ch.is_whitespace() {            i += 1;        } else if ch == '#' {            let start = i + 1;            while i < c.len() && c[i] != '\n' {                i += 1;            }            out.push((Tok::Comment(c[start..i].iter().collect::<String>().trim_end().to_string()), line));        } else if ch == ';' {            out.push((Tok::Semi, line));            i += 1;        } else if ch == '{' {            out.push((Tok::Open, line));            i += 1;        } else if ch == '}' {            out.push((Tok::Close, line));            i += 1;        } else if ch == '"' || ch == '\'' {            let at = line;            i += 1;            let mut w = String::new();            loop {                if i >= c.len() {                    return Err(format!("line {}: a quote is never closed", at));                }                if c[i] == ch {                    i += 1;                    break;                }                if c[i] == '\\' && i + 1 < c.len() {                    i += esc(&c, i, &mut w);                    continue;                }                if c[i] == '\n' {                    line += 1;                }                w.push(c[i]);                i += 1;            }            if i < c.len() && !c[i].is_whitespace() && c[i] != ';' && c[i] != '{' && c[i] != ')' {                return Err(format!("line {}: unexpected `{}` after a closing quote", line, c[i]));            }            out.push((Tok::Word(w), at));        } else {            let mut w = String::new();            let mut var = false;            while i < c.len() {                let x = c[i];                if x == '{' && var {                    // `${name}` is one word.                    w.push(x);                    i += 1;                    while i < c.len() && c[i] != '}' {                        w.push(c[i]);                        i += 1;                    }                    if i < c.len() {                        w.push('}');                        i += 1;                    }                    var = false;                    continue;                }                var = false;                if x.is_whitespace() || x == ';' || x == '{' {                    break;                }                if x == '\\' && i + 1 < c.len() {                    i += esc(&c, i, &mut w);                    continue;                }                if x == '$' {                    var = true;                }                w.push(x);                i += 1;            }            out.push((Tok::Word(w), line));        }    }    Ok(out)}/// One backslash escape, as nginx undoes them: only `\"`, `\'`, `\\`, `\t`, `\r` and `\n`; others stay (`\.` in a regex).fn esc(c: &[char], i: usize, w: &mut String) -> usize {    match c[i + 1] {        '"' | '\'' | '\\' => { w.push(c[i + 1]); 2 }        't' => { w.push('\t'); 2 }        'r' => { w.push('\r'); 2 }        'n' => { w.push('\n'); 2 }        _ => { w.push('\\'); 1 }    }}fn block(toks: &[(Tok, usize)], at: &mut usize, depth: usize, file: Option<&str>) -> Result<Vec<Value>, String> {    if depth > MAX_DEPTH {        return Err("blocks are nested too deep".to_string());    }    let mut out = vec![];    let mut words: Vec<String> = vec![];    let mut first_line = 0;    while *at < toks.len() {        let (t, line) = &toks[*at];        *at += 1;        match t {            Tok::Comment(c) => {                if words.is_empty() {                    out.push(json!({ "comment": c, "line": line }));                }            }            Tok::Word(w) => {                if words.is_empty() {                    first_line = *line;                }                words.push(w.clone());            }            Tok::Semi => {                if words.is_empty() {                    return Err(format!("line {}: unexpected `;`", line));                }                out.push(node(&mut words, first_line, file, None));            }            Tok::Open => {                if words.is_empty() {                    return Err(format!("line {}: unexpected `{{`", line));                }                let kids = block(toks, at, depth + 1, file)?;                out.push(node(&mut words, first_line, file, Some(kids)));            }            Tok::Close => {                if !words.is_empty() {                    return Err(format!("line {}: `{}` has no `;`", first_line, words[0]));                }                if depth == 0 {                    return Err(format!("line {}: unexpected `}}`", line));                }                return Ok(out);            }        }    }    if !words.is_empty() {        return Err(format!("line {}: `{}` has no `;`", first_line, words[0]));    }    if depth > 0 {        return Err("the file ends inside a block: a `}` is missing".to_string());    }    Ok(out)}fn node(words: &mut Vec<String>, line: usize, file: Option<&str>, kids: Option<Vec<Value>>) -> Value {    let args: Vec<String> = words.drain(1..).collect();    let name = words.remove(0);    let mut m = Map::new();    m.insert("directive".into(), json!(name));    m.insert("args".into(), json!(args));    m.insert("line".into(), json!(line));    if let Some(f) = file {        m.insert("file".into(), json!(f));    }    if let Some(k) = kids {        m.insert("block".into(), Value::Array(k));    }    Value::Object(m)}fn parse(text: &str, file: Option<&str>) -> Result<Vec<Value>, String> {    let t = tokens(text.trim_start_matches('\u{feff}'))?;    let mut at = 0;    block(&t, &mut at, 0, file)}/// Parses configuration text to a tree: each node is `{ directive, args, line, block }` or `{ comment, line }`. Errors name the line, as `nginx -t` does.pub fn nginx_conf_parse(text: &str) -> Result<Vec<Value>, String> {    parse(text, None)}fn quoted(a: &str) -> String {    let plain = !a.is_empty()        && !a.chars().any(|c| c.is_whitespace() || c == ';' || c == '"' || c == '\'')        && !a.replace("${", "").contains('{')        && !a.starts_with('#')        && a != "}";    let keep_slash = |rest: &str| !matches!(rest.chars().next(), Some('"') | Some('\'') | Some('\\') | Some('t') | Some('r') | Some('n') | None);    let body = |q: char| {        let mut out = String::new();        for (i, c) in a.char_indices() {            match c {                '\\' if keep_slash(&a[i + 1..]) => out.push('\\'),                '\\' => out.push_str("\\\\"),                '\n' => out.push_str("\\n"),                '\t' => out.push_str("\\t"),                '\r' => out.push_str("\\r"),                c if c == q => { out.push('\\'); out.push(c); }                c => out.push(c),            }        }        out    };    if plain {        return body('\u{0}');    }    let q = if a.contains('"') && !a.contains('\'') { '\'' } else { '"' };    format!("{}{}{}", q, body(q), q)}fn print(nodes: &[Value], depth: usize, out: &mut String) -> Result<(), String> {    if depth > MAX_DEPTH {        return Err("blocks are nested too deep".to_string());    }    let pad = "    ".repeat(depth);    for (i, n) in nodes.iter().enumerate() {        if let Some(c) = n.get("comment").and_then(Value::as_str) {            out.push_str(&format!("{}#{}\n", pad, c));            continue;        }        let name = n.get("directive").and_then(Value::as_str).ok_or("a node needs a `directive` or a `comment`")?;        // A blank line before a block, unless it opens its parent or a comment sits on it.        if n.get("block").is_some() && i > 0 && nodes[i - 1].get("comment").is_none() {            out.push('\n');        }        out.push_str(&pad);        // In a `map`, the first word may be anything, even empty.        out.push_str(&quoted(name));        for a in n.get("args").and_then(Value::as_array).into_iter().flatten() {            let t = a.as_str().map(str::to_string).unwrap_or_else(|| a.to_string());            out.push(' ');            out.push_str(&quoted(&t));        }        match n.get("block").and_then(Value::as_array) {            Some(b) => {                out.push_str(" {\n");                print(b, depth + 1, out)?;                out.push_str(&pad);                out.push_str("}\n");            }            None => out.push_str(";\n"),        }    }    Ok(())}/// Prints a tree back as configuration, four spaces to a level; parsing the output gives the same tree.pub fn nginx_conf_print(tree: Vec<serde_json::Value>) -> Result<String, String> {    let mut out = String::new();    print(&tree, 0, &mut out)?;    Ok(out)}/// `*`, `?` and `[a-z]`, enough for `sites-enabled/*` and `conf.d/*.conf`.fn glob_match(p: &[char], t: &[char]) -> bool {    match p.first() {        None => t.is_empty(),        Some('*') => (0..=t.len()).any(|i| glob_match(&p[1..], &t[i..])),        Some('?') => !t.is_empty() && glob_match(&p[1..], &t[1..]),        Some('[') => {            let Some(end) = p.iter().position(|c| *c == ']') else { return !t.is_empty() && t[0] == '[' && glob_match(&p[1..], &t[1..]) };            if t.is_empty() {                return false;            }            let (neg, set) = if p[1] == '!' || p[1] == '^' { (true, &p[2..end]) } else { (false, &p[1..end]) };            let mut hit = false;            let mut i = 0;            while i < set.len() {                if i + 2 < set.len() && set[i + 1] == '-' {                    hit |= set[i] <= t[0] && t[0] <= set[i + 2];                    i += 3;                } else {                    hit |= set[i] == t[0];                    i += 1;                }            }            hit != neg && glob_match(&p[end + 1..], &t[1..])        }        Some(c) => !t.is_empty() && *c == t[0] && glob_match(&p[1..], &t[1..]),    }}fn expand(pattern: &Path) -> Vec<PathBuf> {    let text = pattern.to_string_lossy();    if !text.contains(['*', '?', '[']) {        return vec![pattern.to_path_buf()];    }    let mut found: Vec<PathBuf> = vec![PathBuf::new()];    for part in pattern.components() {        let part = part.as_os_str().to_string_lossy().into_owned();        let mut next = vec![];        for base in &found {            if part.contains(['*', '?', '[']) {                let p: Vec<char> = part.chars().collect();                if let Ok(rd) = std::fs::read_dir(if base.as_os_str().is_empty() { Path::new(".") } else { base.as_path() }) {                    for e in rd.flatten() {                        let name = e.file_name().to_string_lossy().into_owned();                        // As glob(3): a dot file needs the dot written.                        if (!name.starts_with('.') || part.starts_with('.')) && glob_match(&p, &name.chars().collect::<Vec<_>>()) {                            next.push(base.join(name));                        }                    }                }            } else {                next.push(base.join(&part));            }        }        found = next;    }    found.sort();    found.into_iter().filter(|f| f.is_file()).collect()}struct Load {    base: PathBuf,    files: Vec<String>,    problems: Vec<String>,}fn splice(nodes: Vec<Value>, stack: &mut Vec<PathBuf>, l: &mut Load) -> Result<Vec<Value>, String> {    let mut out = vec![];    for mut n in nodes {        if let Some(b) = n.get_mut("block").map(Value::take) {            let list = serde_json::from_value::<Vec<Value>>(b).unwrap_or_default();            n["block"] = Value::Array(splice(list, stack, l)?);            out.push(n);            continue;        }        if n.get("directive").and_then(Value::as_str) != Some("include") {            out.push(n);            continue;        }        let arg = n["args"][0].as_str().unwrap_or("").to_string();        let path = Path::new(&arg);        let full = if path.is_absolute() { path.to_path_buf() } else { l.base.join(path) };        let matched = expand(&full);        let globbed = arg.contains(['*', '?', '[']);        if matched.is_empty() && globbed {            l.problems.push(format!("include {} matches no file", arg));        }        let at = format!("{}:{}", n["file"].as_str().unwrap_or(""), n["line"]);        for f in matched {            out.extend(read_file_tree(&f, stack, l, Some(&at))?);        }    }    Ok(out)}fn read_file_tree(path: &Path, stack: &mut Vec<PathBuf>, l: &mut Load, from: Option<&str>) -> Result<Vec<Value>, String> {    if stack.len() >= MAX_INCLUDES {        return Err(format!("includes go deeper than {}", MAX_INCLUDES));    }    let shown = path.to_string_lossy().into_owned();    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());    if stack.contains(&canon) {        return Err(format!("{} includes itself", shown));    }    let text = match std::fs::read_to_string(path) {        Ok(t) => t,        Err(e) => {            return Err(match from {                Some(f) => format!("{}: cannot read include {}: {}", f, shown, e),                None => format!("cannot read {}: {}", shown, e),            })        }    };    l.files.push(shown.clone());    stack.push(canon);    let tree = parse(&text, Some(&shown)).map_err(|e| format!("{}: {}", shown, e))?;    let out = splice(tree, stack, l);    stack.pop();    out}/// Reads a configuration file and follows its `include`s, globs too, relative to that file's folder as nginx does for its main file: `{ tree, files, problems }`, each node saying which file it came from.pub fn nginx_conf_load(path: &str) -> Result<Value, String> {    let p = Path::new(path);    let mut l = Load { base: p.parent().unwrap_or(Path::new(".")).to_path_buf(), files: vec![], problems: vec![] };    let tree = read_file_tree(p, &mut vec![], &mut l, None)?;    Ok(json!({ "tree": tree, "files": l.files, "problems": l.problems }))}fn dname(n: &Value) -> &str {    n.get("directive").and_then(Value::as_str).unwrap_or("")}fn args(n: &Value) -> Vec<&str> {    n["args"].as_array().into_iter().flatten().filter_map(Value::as_str).collect()}fn kids(n: &Value) -> &[Value] {    n.get("block").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])}fn find_all<'a>(nodes: &'a [Value], name: &str, out: &mut Vec<&'a Value>, depth: usize) {    if depth > MAX_DEPTH {        return;    }    for n in nodes {        if dname(n) == name {            out.push(n);        }        find_all(kids(n), name, out, depth + 1);    }}fn servers_of(tree: &[Value]) -> Vec<&Value> {    let mut out = vec![];    find_all(tree, "server", &mut out, 0);    // `server` inside `upstream` is a backend; those have no block.    out.retain(|n| n.get("block").is_some());    out}fn first_arg<'a>(block: &'a [Value], name: &str) -> Option<&'a str> {    block.iter().find(|n| dname(n) == name).and_then(|n| n["args"][0].as_str())}fn location_parts(n: &Value) -> (String, String) {    let a = args(n);    match a.as_slice() {        [m, p, ..] => (m.to_string(), p.to_string()),        [p] => {            // Written with no space: `location ~^/x`, `location =/x`, `location ^~/x`.            for m in ["^~", "~*", "=", "~"] {                if let Some(r) = p.strip_prefix(m) {                    return (m.to_string(), r.to_string());                }            }            (String::new(), p.to_string())        }        _ => (String::new(), String::new()),    }}fn location_list(block: &[Value], out: &mut Vec<Value>, depth: usize) {    if depth > MAX_DEPTH {        return;    }    for l in block.iter().filter(|n| dname(n) == "location") {        let (m, p) = location_parts(l);        let b = kids(l);        let kind = match m.as_str() {            "=" => "exact",            "^~" => "prefix, no regex after",            "~" => "regex",            "~*" => "regex, any case",            _ => "prefix",        };        out.push(json!({            "path": p, "modifier": m, "kind": kind, "line": l["line"],            "proxy_pass": first_arg(b, "proxy_pass"), "fastcgi_pass": first_arg(b, "fastcgi_pass"), "root": first_arg(b, "root"), "alias": first_arg(b, "alias"),            "try_files": b.iter().find(|n| dname(n) == "try_files").map(args),            "return": b.iter().find(|n| dname(n) == "return").map(args),        }));        location_list(b, out, depth + 1);    }}fn listen_of(n: &Value) -> Value {    let a = args(n);    let addr = a.first().copied().unwrap_or("");    let digits = addr.rsplit(':').next().unwrap_or(addr);    let port = if addr.starts_with("unix:") { Value::Null } else if digits.chars().all(|c| c.is_ascii_digit()) && !digits.is_empty() && !addr.ends_with(']') { digits.parse::<i64>().map(Value::from).unwrap_or(Value::Null) } else { json!(80) };    json!({ "address": addr, "port": port, "ssl": a.contains(&"ssl"), "http2": a.contains(&"http2"), "default": a.contains(&"default_server") || a.contains(&"default") })}/// Every `server` block: listen addresses, names, TLS, root and its locations; and every `upstream`, with its backends.pub fn nginx_conf_servers(tree: Vec<serde_json::Value>) -> Result<Value, String> {    let servers: Vec<Value> = servers_of(&tree).into_iter().map(|s| {        let b = kids(s);        let mut listen: Vec<Value> = b.iter().filter(|n| dname(n) == "listen").map(listen_of).collect();        if listen.is_empty() {            listen.push(json!({ "address": "*:80", "port": 80, "ssl": false, "http2": false, "default": false }));        }        let names: Vec<&str> = b.iter().filter(|n| dname(n) == "server_name").flat_map(args).collect();        let mut locations = vec![];        location_list(b, &mut locations, 0);        json!({            "line": s["line"], "file": s.get("file"), "listen": listen, "names": names,            "tls": listen.iter().any(|l| l["ssl"] == json!(true)), "certificate": first_arg(b, "ssl_certificate"),            "root": first_arg(b, "root"), "locations": locations,        })    }).collect();    let mut ups = vec![];    find_all(&tree, "upstream", &mut ups, 0);    let upstreams: Vec<Value> = ups.into_iter().map(|u| json!({        "name": u["args"][0], "line": u["line"],        "servers": kids(u).iter().filter(|n| dname(n) == "server").map(|n| json!({ "address": n["args"][0], "options": args(n)[1..].to_vec() })).collect::<Vec<_>>(),        "balance": kids(u).iter().find(|n| ["least_conn", "ip_hash", "hash", "random"].contains(&dname(n))).map(dname),    })).collect();    Ok(json!({ "servers": servers, "upstreams": upstreams }))}fn name_score(pattern: &str, host: &str) -> Option<(u8, usize)> {    let host = host.to_lowercase();    let p = pattern.to_lowercase();    // nginx's order: exact, longest leading wildcard, longest trailing wildcard, regex.    if p == host {        return Some((4, p.len()));    }    if let Some(r) = p.strip_prefix("*.") {        if host.ends_with(&format!(".{}", r)) {            return Some((3, r.len()));        }    }    if let Some(r) = p.strip_prefix('.') {        if host == r || host.ends_with(&format!(".{}", r)) {            return Some((3, r.len()));        }    }    if let Some(r) = p.strip_suffix(".*") {        if host.starts_with(&format!("{}.", r)) {            return Some((2, r.len()));        }    }    None}/// Which `server` and `location` a request reaches, by nginx's rules: exact match, then the longest prefix, then regexes in order unless `^~` stopped them. `regex` locations are matched only as far as a plain test allows: see `regex_note`.pub fn nginx_conf_match(tree: Vec<serde_json::Value>, port: i64, host: &str, path: &str) -> Result<Value, String> {    let servers = servers_of(&tree);    let on_port: Vec<&Value> = servers.iter().copied().filter(|s| {        let l: Vec<i64> = kids(s).iter().filter(|n| dname(n) == "listen").map(listen_of).filter_map(|x| x["port"].as_i64()).collect();        if l.is_empty() { port == 80 } else { l.contains(&port) }    }).collect();    if on_port.is_empty() {        return Err(format!("no server listens on port {}", port));    }    let mut best: Option<(&Value, (u8, usize))> = None;    for s in &on_port {        for n in kids(s).iter().filter(|n| dname(n) == "server_name").flat_map(args) {            if let Some(score) = name_score(n, host) {                if best.as_ref().map_or(true, |b| score > b.1) {                    best = Some((s, score));                }            }        }    }    let default = on_port.iter().copied().find(|s| kids(s).iter().filter(|n| dname(n) == "listen").any(|n| { let l = listen_of(n); l["default"] == json!(true) && l["port"] == json!(port) })).unwrap_or(on_port[0]);    let by_default = best.is_none();    let server = best.map_or(default, |b| b.0);    let block = kids(server);    let locs: Vec<&Value> = block.iter().filter(|n| dname(n) == "location").collect();    let mut prefix: Option<(&Value, usize, bool)> = None;    for l in &locs {        let (m, p) = location_parts(l);        if m == "=" && p == path {            return Ok(answer(server, Some(l), by_default, "exact", false));        }        if (m.is_empty() || m == "^~") && path.starts_with(p.as_str()) && prefix.map_or(true, |x| p.len() > x.1) {            prefix = Some((l, p.len(), m == "^~"));        }    }    let stop = prefix.map_or(false, |p| p.2);    let regexes = locs.iter().any(|l| { let m = location_parts(l).0; m == "~" || m == "~*" });    Ok(answer(server, prefix.map(|p| p.0), by_default, if prefix.is_some() { "longest prefix" } else { "none" }, regexes && !stop))}fn answer(server: &Value, loc: Option<&Value>, by_default: bool, how: &str, regex_note: bool) -> Value {    let names: Vec<&str> = kids(server).iter().filter(|n| dname(n) == "server_name").flat_map(args).collect();    json!({        "server": names.first().copied().unwrap_or(""), "server_line": server["line"], "default_server": by_default,        "location": loc.map(|l| { let (m, p) = location_parts(l); if m.is_empty() { p } else { format!("{} {}", m, p) } }),        "how": how,        "regex_note": if regex_note { Value::String("regex locations are tried after this and may win; they are not run here".to_string()) } else { Value::Null },    })}fn note(out: &mut Vec<Value>, line: &Value, rule: &str, message: String) {    out.push(json!({ "line": line, "rule": rule, "message": message }));}fn lint_directive(n: &Value, ctx: &str, siblings: &[Value], out: &mut Vec<Value>) {    let a = args(n);    let name = dname(n);    let line = &n["line"];    match name {        "ssl_protocols" => for bad in ["SSLv2", "SSLv3", "TLSv1", "TLSv1.1"] {            if a.contains(&bad) {                note(out, line, "weak-tls", format!("ssl_protocols allows {}: use TLSv1.2 and TLSv1.3", bad));            }        },        "server_tokens" if a.first() == Some(&"on") => note(out, line, "server-tokens", "server_tokens on tells every visitor the version".to_string()),        "autoindex" if a.first() == Some(&"on") => note(out, line, "autoindex", "autoindex on lists folders to anyone".to_string()),        "return" | "rewrite" => {            if a.iter().skip(1).any(|x| x.contains("$uri") || x.contains("$document_uri")) {                note(out, line, "crlf-injection", format!("{} uses $uri, which is decoded: a %0d%0a in the path splits the header; use $request_uri", name));            }        }        "proxy_pass" | "fastcgi_pass" | "uwsgi_pass" if ctx == "if" => note(out, line, "if-is-evil", format!("{} inside `if` does not do what it looks like: use a location or map", name)),        "proxy_pass" => {            let t = a.first().copied().unwrap_or("");            let after = t.split_once("://").map_or("", |x| x.1);            if ctx.starts_with("location ~") && after.contains('/') && !t.contains('$') {                note(out, line, "proxy-pass-regex", "proxy_pass with a URI part cannot be used in a regex location: nginx refuses it".to_string());            }            if t.contains('$') && after.split_once('/').map_or(false, |x| !x.1.is_empty()) {                note(out, line, "proxy-pass-variable", "with variables in proxy_pass, the URI is taken as written: the location part is not replaced".to_string());            }        }        "alias" => {            let loc = ctx.strip_prefix("location ").unwrap_or("");            let t = a.first().copied().unwrap_or("");            if !loc.is_empty() && !loc.starts_with('~') && !loc.ends_with('/') && t.ends_with('/') {                note(out, line, "alias-traversal", format!("location {} has no final slash and its alias has one: {}../ walks out of the folder", loc, loc));            }            if loc.ends_with('/') && !t.ends_with('/') {                note(out, line, "alias-slash", format!("location {} ends in a slash and its alias does not: paths run together", loc));            }        }        "root" if ctx.starts_with("location") && matches!(a.first().copied(), Some("/") | Some("/root") | Some("/etc") | Some("/home")) => {            note(out, line, "root-too-wide", format!("root {} serves the system's own files", a[0]));        }        "add_header" => {            if ["X-Frame-Options", "Content-Security-Policy", "Strict-Transport-Security"].iter().any(|h| a.first().map_or(false, |x| x.eq_ignore_ascii_case(h))) && a.last() != Some(&"always") {                note(out, line, "header-not-always", format!("add_header {} needs `always`, or error pages go without it", a[0]));            }        }        "if" if ctx.starts_with("location") => {            if kids(n).iter().filter(|k| k.get("directive").is_some()).any(|k| !["return", "rewrite"].contains(&dname(k))) {                note(out, line, "if-is-evil", "`if` in a location is safe only with return or rewrite".to_string());            }        }        "client_max_body_size" if a.first() == Some(&"0") => note(out, line, "no-body-limit", "client_max_body_size 0 takes uploads of any size".to_string()),        "server" if n.get("block").is_some() => {            let b = kids(n);            let tls = b.iter().filter(|x| dname(x) == "listen").any(|x| args(x).contains(&"ssl"));            if tls && !b.iter().any(|x| dname(x) == "ssl_certificate") && !siblings.iter().any(|x| dname(x) == "ssl_certificate") {                note(out, line, "tls-no-certificate", "this server listens with ssl and has no ssl_certificate".to_string());            }            if !b.iter().any(|x| dname(x) == "server_name") {                note(out, line, "no-server-name", "no server_name: this block answers only as a default".to_string());            }        }        _ => {}    }}fn lint_block(nodes: &[Value], ctx: &str, headers_above: bool, out: &mut Vec<Value>, depth: usize) {    if depth > MAX_DEPTH {        return;    }    let own = nodes.iter().any(|n| dname(n) == "add_header");    if own && headers_above {        let line = &nodes.iter().find(|n| dname(n) == "add_header").unwrap()["line"];        note(out, line, "add-header-replaces", "add_header here drops every add_header of the block above: they do not add up".to_string());    }    for n in nodes {        lint_directive(n, ctx, nodes, out);        if n.get("block").is_some() {            let inner = match dname(n) {                "location" => {                    let (m, p) = location_parts(n);                    if m.is_empty() { format!("location {}", p) } else { format!("location {} {}", m, p) }                }                d => d.to_string(),            };            lint_block(kids(n), &inner, own || headers_above, out, depth + 1);        }    }}/// Common mistakes: `[{ line, rule, message }]`, for the alias slash trap, `add_header` that drops the parent's, `$uri` in redirects, `if` in a location, old TLS, a TLS server with no certificate, and more. Duplicate server names are found too.pub fn nginx_conf_lint(tree: Vec<serde_json::Value>) -> Result<Vec<Value>, String> {    let mut out = vec![];    lint_block(&tree, "", false, &mut out, 0);    let mut seen: Vec<(i64, String, Value)> = vec![];    for s in servers_of(&tree) {        let b = kids(s);        let mut ports: Vec<i64> = b.iter().filter(|n| dname(n) == "listen").map(listen_of).filter_map(|x| x["port"].as_i64()).collect();        if ports.is_empty() {            ports.push(80);        }        for name in b.iter().filter(|n| dname(n) == "server_name").flat_map(args) {            for p in &ports {                match seen.iter().find(|x| x.0 == *p && x.1 == name) {                    Some(x) => out.push(json!({ "line": s["line"], "rule": "duplicate-server", "message": format!("server_name {} on port {} is already on line {}: nginx ignores this one", name, p, x.2) })),                    None => seen.push((*p, name.to_string(), s["line"].clone())),                }            }        }    }    out.sort_by_key(|x| x["line"].as_i64().unwrap_or(0));    Ok(out)}
Thread
49Useful? Like it, or add to its thread below.

Thread 0 posts

On nginx configuration: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.