diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..4609a9a --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,53 @@ +name: Test nginx configuration + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run static analysis + run: bash test.sh + + syntax-check: + name: nginx Syntax Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install nginx + run: | + sudo apt-get update -q + sudo apt-get install -y nginx-full + + - name: Deploy config to /etc/nginx + run: sudo cp -a nginx/nginx/. /etc/nginx/ + + - name: Create stub SSL certificates + run: | + openssl req -x509 -newkey rsa:2048 -keyout /tmp/stub.key \ + -out /tmp/stub.pem -days 1 -nodes -subj '/CN=stub' + + while IFS= read -r dir; do + sudo mkdir -p "$dir" + sudo cp /tmp/stub.pem "$dir/fullchain.pem" + sudo cp /tmp/stub.key "$dir/privkey.pem" + done < <(grep -rh 'ssl_certificate ' /etc/nginx/sites-available/ \ + | grep -v '#' \ + | grep -oP '/etc/letsencrypt/live/[^\s/]+' \ + | sort -u) + + - name: Run nginx syntax check + run: sudo nginx -t diff --git a/test.sh b/test.sh index a9b991d..d93fdae 100755 --- a/test.sh +++ b/test.sh @@ -1,24 +1,233 @@ #!/bin/bash -CONF="configs/prod.conf" +# Nginx configuration static analysis test suite. +# Usage: bash test.sh [nginx-dir] +# Defaults to nginx/nginx relative to the repo root. -# Extract server_name values in order, ignoring commented lines -mapfile -t domains < <(grep -oP '^\s*server_name\s+\K[^;]+' "$CONF") +NGINX_DIR="${1:-nginx/nginx}" +PASS=0 +FAIL=0 -sorted=($(printf "%s\n" "${domains[@]}" | sort)) +pass() { + printf " PASS: %s\n" "$1" + ((PASS++)) +} -# Print the sorted list for debugging -echo "Auditing servers:" -printf "%s " "${sorted[@]}" +fail() { + printf " FAIL: %s\n" "$1" + ((FAIL++)) +} + +echo "=== nginx config static analysis ===" +echo "Directory: $NGINX_DIR" echo "" -for i in "${!domains[@]}"; do - if [[ "${domains[$i]}" != "${sorted[$i]}" ]]; then - echo "Domain list is not sorted alphabetically." - echo "First out-of-order entry: '${domains[$i]}' (should be '${sorted[$i]}')" - exit 1 +# ────────────────────────────────────────────────────────────────── +# 1. No deprecated TLS versions +# ────────────────────────────────────────────────────────────────── +echo "--- TLS version check ---" +deprecated=$(grep -rnP 'TLSv1(?!\.[23])' "$NGINX_DIR" --include="*.conf" 2>/dev/null || true) +if [ -n "$deprecated" ]; then + fail "Deprecated TLS versions (TLSv1 or TLSv1.1) found:" + printf '%s\n' "$deprecated" | sed 's/^/ /' +else + pass "No deprecated TLS versions" +fi +echo "" + +# ────────────────────────────────────────────────────────────────── +# 2. No duplicate literal server_name values across all site configs +# ────────────────────────────────────────────────────────────────── +echo "--- Duplicate server_name check ---" +duplicates=$(grep -rh --include="*.conf" 'server_name' "$NGINX_DIR/sites-available/" \ + | grep -v '^\s*#' \ + | sed 's/.*server_name\s*//' \ + | sed 's/\s*;//' \ + | tr ' ' '\n' \ + | grep -vP '^\s*$|^_$|^~|^\*\.' \ + | sort | uniq -d) +if [ -n "$duplicates" ]; then + fail "Duplicate server_name values:" + printf '%s\n' "$duplicates" | sed 's/^/ /' +else + pass "No duplicate server_name values" +fi +echo "" + +# ────────────────────────────────────────────────────────────────── +# 3. Every sites-available/*.conf has a sites-enabled symlink +# ────────────────────────────────────────────────────────────────── +echo "--- sites-enabled coverage check ---" +missing_links=0 +for conf in "$NGINX_DIR/sites-available/"*.conf; do + name=$(basename "$conf") + if [ ! -L "$NGINX_DIR/sites-enabled/$name" ]; then + fail "No sites-enabled symlink for: $name" + missing_links=1 fi done +[ "$missing_links" -eq 0 ] && pass "All sites-available configs have sites-enabled symlinks" +echo "" -echo "All server_name entries are sorted alphabetically." -exit 0 \ No newline at end of file +# ────────────────────────────────────────────────────────────────── +# 4. No broken symlinks in sites-enabled +# ────────────────────────────────────────────────────────────────── +echo "--- Broken symlink check ---" +broken=0 +for link in "$NGINX_DIR/sites-enabled/"*; do + [ -L "$link" ] || continue + if [ ! -e "$link" ]; then + fail "Broken symlink: $(basename "$link")" + broken=1 + fi +done +[ "$broken" -eq 0 ] && pass "No broken symlinks in sites-enabled" +echo "" + +# ────────────────────────────────────────────────────────────────── +# 5. No orphaned sites-enabled symlinks (no matching sites-available file) +# ────────────────────────────────────────────────────────────────── +echo "--- Orphaned symlink check ---" +orphaned=0 +for link in "$NGINX_DIR/sites-enabled/"*.conf; do + [ -L "$link" ] || continue + name=$(basename "$link") + if [ ! -f "$NGINX_DIR/sites-available/$name" ]; then + fail "Orphaned sites-enabled symlink: $name" + orphaned=1 + fi +done +[ "$orphaned" -eq 0 ] && pass "No orphaned sites-enabled symlinks" +echo "" + +# ────────────────────────────────────────────────────────────────── +# 6. Any port-80 listener must also have a port-443 listener in the +# same file (HTTP-only serving is not acceptable for real sites) +# ────────────────────────────────────────────────────────────────── +echo "--- HTTP-only server block check ---" +http_only_errors=0 +for conf in "$NGINX_DIR/sites-available/"*.conf; do + [ "$(basename "$conf")" = "default" ] && continue + has_80=$(grep -cP 'listen\s.*\b80\b' "$conf" 2>/dev/null || true) + has_443=$(grep -c 'listen 443' "$conf" 2>/dev/null || true) + if [ "${has_80:-0}" -gt 0 ] && [ "${has_443:-0}" -eq 0 ]; then + fail "$(basename "$conf"): listens on port 80 but has no port-443 listener" + http_only_errors=1 + fi +done +[ "$http_only_errors" -eq 0 ] && pass "No HTTP-only server blocks in custom sites" +echo "" + +# ────────────────────────────────────────────────────────────────── +# 7. ssl_certificate and ssl_certificate_key counts match per file +# ────────────────────────────────────────────────────────────────── +echo "--- SSL certificate directive pairing check ---" +ssl_errors=0 +for conf in "$NGINX_DIR/sites-available/"*.conf; do + certs=$(grep -cP 'ssl_certificate\b(?!_key)' "$conf" 2>/dev/null || echo 0) + keys=$(grep -c 'ssl_certificate_key' "$conf" 2>/dev/null || echo 0) + if [ "$certs" != "$keys" ]; then + fail "$(basename "$conf"): $certs ssl_certificate vs $keys ssl_certificate_key (must match)" + ssl_errors=1 + fi +done +[ "$ssl_errors" -eq 0 ] && pass "All ssl_certificate directives are correctly paired" +echo "" + +# ────────────────────────────────────────────────────────────────── +# 8. All plain-HTTP proxy_pass targets are local +# (https:// proxy_pass is permitted for intentional external proxying, +# e.g. CDN reverse-proxying to object storage over TLS) +# ────────────────────────────────────────────────────────────────── +echo "--- proxy_pass locality check ---" +external=$(grep -rn 'proxy_pass\s\+http://' "$NGINX_DIR/sites-available/" \ + | grep -v '#' \ + | grep -vP 'proxy_pass\s+http://(127\.0\.0\.1|localhost|0\.0\.0\.0)' || true) +if [ -n "$external" ]; then + fail "Plain-HTTP proxy_pass to non-local target found:" + printf '%s\n' "$external" | sed 's/^/ /' +else + pass "All plain-HTTP proxy_pass targets are local" +fi +echo "" + +# ────────────────────────────────────────────────────────────────── +# 9. All SSL cert paths use /etc/letsencrypt/live/ +# ────────────────────────────────────────────────────────────────── +echo "--- SSL certificate path convention check ---" +nonstandard_certs=$(grep -rn 'ssl_certificate' "$NGINX_DIR/sites-available/" \ + | grep -v '#' \ + | grep -vP '/etc/letsencrypt/live/' || true) +if [ -n "$nonstandard_certs" ]; then + fail "SSL certs not under /etc/letsencrypt/live/:" + printf '%s\n' "$nonstandard_certs" | sed 's/^/ /' +else + pass "All SSL certificate paths use /etc/letsencrypt/live/" +fi +echo "" + +# ────────────────────────────────────────────────────────────────── +# 10. ssl_certificate uses fullchain.pem, ssl_certificate_key uses privkey.pem +# ────────────────────────────────────────────────────────────────── +echo "--- SSL certificate filename convention check ---" +cert_name_errors=0 +wrong_certs=$(grep -rnP 'ssl_certificate\b(?!_key)' "$NGINX_DIR/sites-available/" \ + | grep -v '#' \ + | grep -v 'fullchain\.pem' || true) +wrong_keys=$(grep -rn 'ssl_certificate_key' "$NGINX_DIR/sites-available/" \ + | grep -v '#' \ + | grep -v 'privkey\.pem' || true) +if [ -n "$wrong_certs" ]; then + fail "ssl_certificate not using fullchain.pem:" + printf '%s\n' "$wrong_certs" | sed 's/^/ /' + cert_name_errors=1 +fi +if [ -n "$wrong_keys" ]; then + fail "ssl_certificate_key not using privkey.pem:" + printf '%s\n' "$wrong_keys" | sed 's/^/ /' + cert_name_errors=1 +fi +[ "$cert_name_errors" -eq 0 ] && pass "All SSL certs use fullchain.pem / privkey.pem" +echo "" + +# ────────────────────────────────────────────────────────────────── +# 11. No server_name directives use raw IP addresses +# ────────────────────────────────────────────────────────────────── +echo "--- IP address as server_name check ---" +ip_names=$(grep -rh --include="*.conf" 'server_name' "$NGINX_DIR/sites-available/" \ + | grep -v '#' \ + | sed 's/.*server_name\s*//' \ + | sed 's/\s*;//' \ + | tr ' ' '\n' \ + | grep -P '^\d{1,3}(\.\d{1,3}){3}$' || true) +if [ -n "$ip_names" ]; then + fail "server_name uses raw IP addresses:" + printf '%s\n' "$ip_names" | sed 's/^/ /' +else + pass "No server_name directives use raw IP addresses" +fi +echo "" + +# ────────────────────────────────────────────────────────────────── +# 12. No conf.d files conflict with built-in nginx conf.d conventions +# (i.e. no stray default.conf or catch-all templates left over) +# ────────────────────────────────────────────────────────────────── +echo "--- conf.d stray file check ---" +stray=$(find "$NGINX_DIR/conf.d" -name "*.conf" \ + | grep -vP '/(logging|tuning|cloudflare_ips)\.conf$' || true) +if [ -n "$stray" ]; then + fail "Unexpected files in conf.d (only logging.conf, tuning.conf, cloudflare_ips.conf expected):" + printf '%s\n' "$stray" | sed 's/^/ /' +else + pass "conf.d contains only expected files" +fi +echo "" + +# ────────────────────────────────────────────────────────────────── +# Summary +# ────────────────────────────────────────────────────────────────── +echo "===================================" +printf "Results: %d passed, %d failed\n" "$PASS" "$FAIL" +echo "===================================" +[ "$FAIL" -gt 0 ] && exit 1 +exit 0