refactor: restructure nginx config into per-app files #1

Merged
naomi merged 13 commits from feat/restructure into main 2026-03-07 02:05:29 -08:00
2 changed files with 276 additions and 14 deletions
Showing only changes of commit 55fcab69a1 - Show all commits
+53
View File
@@ -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
+223 -14
View File
@@ -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
# ──────────────────────────────────────────────────────────────────
# 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