diff --git a/.env.example b/.env.example index 7579d6e..a7b3bdc 100644 --- a/.env.example +++ b/.env.example @@ -71,3 +71,13 @@ GID=1000 # Grafana admin password GRAFANA_PASSWORD=your-secure-password + +# =========================================== +# External Monitoring (Optional) +# =========================================== +# Set LOKI_URL to connect Promtail to an external Loki instance +# instead of running a local Loki container. +# Use 'make start-monitoring-external' to start in external mode. + +# LOKI_URL=https://loki.provider.com/loki/api/v1/push +# LOKI_TENANT_ID=nginx-security diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3c741e2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish + +on: + push: + branches: [master] + tags: ['v*'] + +env: + IMAGE_NAME: viktorpalchynskyi/nginx-security + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..41c32d1 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,54 @@ +name: Verify + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + IMAGE_NAME: viktorpalchynskyi/nginx-security + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image for testing + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: ${{ env.IMAGE_NAME }}:test + cache-from: type=gha + cache-to: ${{ github.event.pull_request.head.repo.fork != true && 'type=gha,mode=max' || '' }} + + - name: Start test environment + run: | + docker network create test-net + docker run -d --name httpbin --network test-net kennethreitz/httpbin:latest + docker run -d --name nginx-security \ + --network test-net \ + -e BACKEND=http://httpbin:80 \ + -p 8080:8080 \ + ${{ env.IMAGE_NAME }}:test + echo "Waiting for nginx to start..." + sleep 15 + curl -sf http://localhost:8080/healthz || (docker logs nginx-security && exit 1) + + - name: Run security tests + run: ./scripts/test-security.sh localhost:8080 http + + - name: Run false positive tests + run: ./scripts/test-false-positives.sh localhost:8080 http + + - name: Stop test environment + if: always() + run: | + docker stop nginx-security httpbin 2>/dev/null || true + docker rm nginx-security httpbin 2>/dev/null || true + docker network rm test-net 2>/dev/null || true diff --git a/Makefile b/Makefile index 4c2aeff..855c879 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run run-ssl stop-standalone health-standalone start stop restart test test-security test-false-pos logs logs-audit status bans unban-all health clean start-monitoring stop-monitoring health-monitoring +.PHONY: help build run run-ssl stop-standalone health-standalone start stop restart test test-security test-false-pos logs logs-audit status bans unban-all health clean start-monitoring stop-monitoring health-monitoring start-monitoring-external health-monitoring-external IMAGE_NAME ?= neolab/nginx-security IMAGE_TAG ?= latest @@ -21,9 +21,11 @@ help: @echo " make restart - Restart all services" @echo "" @echo " Monitoring (optional add-on):" - @echo " make start-monitoring - Start monitoring stack" - @echo " make stop-monitoring - Stop monitoring stack" - @echo " make health-monitoring - Check monitoring health" + @echo " make start-monitoring - Start full monitoring stack (standalone)" + @echo " make start-monitoring-external - Start monitoring (external Loki/Prometheus)" + @echo " make stop-monitoring - Stop monitoring stack" + @echo " make health-monitoring - Check monitoring health (standalone)" + @echo " make health-monitoring-external - Check monitoring health (external)" @echo "" @echo " Testing:" @echo " make test - Run all tests" @@ -104,21 +106,47 @@ restart: # =========================================== start-monitoring: - docker compose -f docker-compose.monitoring.yml up -d + docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d @echo "Connecting nginx-security to monitoring network..." @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true @echo "Waiting for monitoring services to start..." @sleep 15 @$(MAKE) health-monitoring +start-monitoring-external: + @if [ -z "$(LOKI_URL)" ]; then \ + echo "ERROR: LOKI_URL is not set."; \ + echo "Set it in .env or export it: export LOKI_URL=https://loki.provider.com/loki/api/v1/push"; \ + exit 1; \ + fi + @echo "Starting external monitoring mode..." + @echo " Loki URL: $$(echo '$(LOKI_URL)' | sed -E 's|://[^@]*@|://***@|')" + @docker compose -f docker-compose.monitoring.yml stop loki grafana 2>/dev/null || true + @docker compose -f docker-compose.monitoring.yml rm -f loki grafana 2>/dev/null || true + docker compose -f docker-compose.monitoring.yml up -d nginx-exporter crowdsec promtail + @echo "Connecting nginx-security to monitoring network..." + @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true + @echo "Waiting for monitoring services to start..." + @sleep 15 + @$(MAKE) health-monitoring-external + stop-monitoring: + docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana down docker compose -f docker-compose.monitoring.yml down health-monitoring: - @echo "Checking monitoring health..." + @echo "Checking monitoring health (standalone)..." @curl -sf http://localhost:9113/metrics > /dev/null 2>&1 && echo "nginx-exporter: OK" || echo "nginx-exporter: FAIL" @curl -sf http://localhost:6060/metrics > /dev/null 2>&1 && echo "crowdsec: OK" || echo "crowdsec: FAIL" @curl -sf http://localhost:3100/ready > /dev/null 2>&1 && echo "loki: OK" || echo "loki: FAIL" + @curl -sf http://localhost:3000/api/health > /dev/null 2>&1 && echo "grafana: OK" || echo "grafana: FAIL" + +health-monitoring-external: + @echo "Checking monitoring health (external)..." + @curl -sf http://localhost:9113/metrics > /dev/null 2>&1 && echo "nginx-exporter: OK" || echo "nginx-exporter: FAIL" + @curl -sf http://localhost:6060/metrics > /dev/null 2>&1 && echo "crowdsec: OK" || echo "crowdsec: FAIL" + @docker ps --format '{{.Names}}' | grep -q promtail && echo "promtail: RUNNING" || echo "promtail: NOT RUNNING" + @docker ps --format '{{.Names}}' | grep -q loki && echo "WARNING: loki is running (should not be in external mode)" || echo "loki: NOT RUNNING (expected)" # =========================================== # Testing diff --git a/README.md b/README.md index 7120ec5..8eb8d05 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,185 @@ # Nginx Security Stack -Production-ready security layer for web applications. Single Docker image with WAF, rate limiting, and OWASP protection. +Reverse proxy with built-in WAF, rate limiting, and OWASP protection. Single Docker image — put it in front of your backend. -**Features**: -- OWASP ModSecurity CRS v4 (WAF with 15 custom rules) -- Rate limiting per IP (auth: 5 req/min, API: 100 req/min) -- SQL injection, XSS, SSRF, path traversal protection -- Scanner/bot detection (sqlmap, nikto, nmap, burpsuite) -- HTTPS backend proxy support (for backends on HTTPS:8443) -- JSON audit logging for all blocked requests -- Optional monitoring: CrowdSec IPS, Loki, Grafana +## 1. Run -## Prerequisites - -- Docker 20.10+ -- 512MB RAM minimum (1GB recommended with monitoring) -- SSL certificates (for HTTPS termination, optional) - -## Quick Start +The only required setting is `BACKEND` — the URL of your application. ```bash -# 1. Build the image -make build - -# 2. Run with your backend docker run -d --name nginx-security \ -e BACKEND=http://your-app:3000 \ -p 80:8080 \ - neolab/nginx-security - -# 3. Verify -curl http://localhost/healthz + viktorpalchynskyi/nginx-security ``` -### Cloudbankin Deployment (HTTPS backend) +Check that it works: ```bash -docker run -d --name nginx-security \ - -e BACKEND=https://paapapayperf.uat.cloudbankin.com:8443 \ - -e PROXY_SSL=on \ - -e PROXY_SSL_VERIFY=off \ - -p 80:8080 -p 443:8443 \ - neolab/nginx-security +curl http://localhost/healthz ``` -### With SSL Termination +Done. WAF and rate limiting are enabled by default. + +## 2. Add HTTPS (optional) + +Provide your SSL certificate and key: ```bash docker run -d --name nginx-security \ -e BACKEND=http://your-app:3000 \ - -p 80:8080 -p 443:8443 \ + -e NGINX_ALWAYS_TLS_REDIRECT=on \ -v /path/to/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro \ -v /path/to/privkey.pem:/etc/nginx/certs/privkey.pem:ro \ - neolab/nginx-security + -p 80:8080 -p 443:8443 \ + viktorpalchynskyi/nginx-security ``` -## Development Mode - -Uses docker-compose with httpbin test backend: +If your backend is on HTTPS, add: ```bash -cp .env.example .env -make start # Starts nginx-waf + crowdsec + httpbin -make test # Run security + false positive tests -make stop # Stop everything + -e BACKEND=https://your-app:8443 \ + -e PROXY_SSL=on \ ``` -## Environment Variables +## 3. Add Monitoring with Grafana (optional) -| Variable | Default | Description | -|----------|---------|-------------| -| `BACKEND` | `http://localhost:80` | Backend URL to proxy to | -| `PORT` | `8080` | HTTP listen port | -| `SSL_PORT` | `8443` | HTTPS listen port | -| `SERVER_NAME` | `_` | Nginx server_name | -| `MODSEC_RULE_ENGINE` | `On` | WAF mode: `On`, `DetectionOnly`, `Off` | -| `PARANOIA` | `1` | OWASP CRS paranoia level (1-4, higher = stricter) | -| `ANOMALY_INBOUND` | `5` | Inbound anomaly score threshold | -| `ANOMALY_OUTBOUND` | `4` | Outbound anomaly score threshold | -| `PROXY_TIMEOUT` | `60` | Backend proxy timeout (seconds) | -| `PROXY_SSL` | `off` | Enable SSL to backend (`on`/`off`) | -| `PROXY_SSL_VERIFY` | `off` | Verify backend SSL cert (`on`/`off`) | -| `PROXY_SSL_PROTOCOLS` | `TLSv1.2 TLSv1.3` | Allowed SSL protocols to backend | -| `SET_REAL_IP_FROM` | — | Trusted proxy CIDR (for LB/CDN) | -| `REAL_IP_HEADER` | `X-Forwarded-For` | Header containing real client IP | +Starts Loki + Promtail + Grafana + nginx-exporter + CrowdSec alongside the nginx-security container. -## Add Monitoring (Optional) - -The monitoring stack runs as separate containers alongside the main image: +**With make:** ```bash -# Start CrowdSec + Loki + Promtail + nginx-exporter -docker compose -f docker-compose.monitoring.yml up -d - -# With Grafana dashboards -docker compose -f docker-compose.monitoring.yml --profile grafana up -d - -# With CrowdSec firewall bouncer (IP blocking) -docker compose -f docker-compose.monitoring.yml --profile bouncer up -d - -# Check health -make health-monitoring - -# Stop -make stop-monitoring +make start-monitoring ``` -Monitoring services share logs via host-mounted `./logs/` volume. - -**Ports**: nginx-exporter (9113), CrowdSec (6060), Loki (3100), Grafana (3000) - -### Connect to Existing Grafana (Loki Data Source) - -If Cloudbankin already has Grafana, add Loki as a data source instead of running a separate Grafana: +**With docker compose:** ```bash -# Start monitoring without Grafana -docker compose -f docker-compose.monitoring.yml up -d +docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d +docker network connect nginx-security-monitoring nginx-security ``` -In your existing Grafana, add data source: -- **Type**: Loki -- **URL**: `http://:3100` -- **Access**: Server - -Example queries: -- All blocked requests: `{job="nginx", type="access"} |= "403"` -- ModSecurity audit events: `{job="modsecurity"}` -- Rate-limited requests: `{job="nginx", type="access"} |= "429"` +After startup: -## Configuration Reference +1. Open Grafana at `http://localhost:3000` (login: `admin` / `admin`) +2. Go to **Dashboards** → **Import** → upload [`monitoring/dashboards/nginx-security.json`](monitoring/dashboards/nginx-security.json) +3. Select **Prometheus** and **Loki** data sources when prompted -### Paranoia Levels - -| Level | Description | Use Case | -|-------|-------------|----------| -| 1 | Standard detection, minimal false positives | Production (default) | -| 2 | Extended detection, some false positives | Sensitive APIs | -| 3 | Aggressive detection, more false positives | High-security | -| 4 | Maximum detection, many false positives | Testing/audit only | - -### Rate Limiting - -| Zone | Rate | Burst | Endpoints | -|------|------|-------|-----------| -| `auth` | 5 req/min | 3 | Login, register, verify, OTP, authentication | -| `api` | 100 req/min | 20 | `/api/*`, `/graphql` | - -Auth rate limiting matches Cloudbankin URL patterns: -- `/api/auth/login` -- `/cloudbankin/api/v1/public/los/login` -- `/cloudbankin/api/v1/authentication` -- `/cloudbankin/api/v1/public/los/borrower-login-verify` - -### Custom WAF Rules (15 rules) - -- Advanced SQL injection detection -- XSS (script, javascript:, event handlers) -- Command injection with path traversal -- Path traversal (plain, URL-encoded, double-encoded) -- Sensitive file access (.env, .git, /etc/passwd) -- Null byte injection -- Scanner/bot User-Agent detection -- SSRF protection (internal IPs, cloud metadata) -- HTTP method restriction (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD) - -## Verification +Check health: ```bash -# Health check -curl http://localhost:8080/healthz - -# WAF test (should return 403) -curl "http://localhost:8080/?id=1'+OR+'1'='1" +make health-monitoring +``` -# Rate limit test (6th request should return 429) -for i in {1..6}; do - curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:8080/api/auth/login -done +Stop: -# Run full test suite (requires development mode) -make test-security -make test-false-pos +```bash +make stop-monitoring ``` -## Troubleshooting +### Connect to an Existing Grafana -### False Positives (Legitimate Requests Blocked) +If you already have Grafana, Prometheus, and Loki — use external mode. Only Promtail, nginx-exporter, and CrowdSec are started. -```bash -# 1. Check which rule is blocking -docker exec nginx-waf tail -100 /var/log/modsecurity/audit.log | jq '.transaction.messages[].ruleId' +**With make:** -# 2. Add exclusion to config/modsecurity/exclusions.conf: -# SecRuleRemoveById +```bash +# Set your Loki URL in .env or export it +export LOKI_URL=https://your-loki:3100/loki/api/v1/push -# 3. Rebuild image -make build +make start-monitoring-external ``` -### Emergency WAF Disable +**With docker compose:** ```bash -# Switch to detection-only mode (logs but doesn't block) -docker run -d --name nginx-security \ - -e BACKEND=http://your-app:3000 \ - -e MODSEC_RULE_ENGINE=DetectionOnly \ - -p 80:8080 \ - neolab/nginx-security +export LOKI_URL=https://your-loki:3100/loki/api/v1/push -# Disable WAF completely -# -e MODSEC_RULE_ENGINE=Off +docker compose -f docker-compose.monitoring.yml up -d nginx-exporter crowdsec promtail +docker network connect nginx-security-monitoring nginx-security ``` -### Load Balancer / CDN Configuration - -When behind a load balancer, set these to get real client IPs for rate limiting: +Then add scrape targets to your Prometheus (see [`monitoring/prometheus-scrape-config.yml`](monitoring/prometheus-scrape-config.yml)): -```bash -docker run -d --name nginx-security \ - -e BACKEND=http://your-app:3000 \ - -e SET_REAL_IP_FROM=10.0.0.0/8 \ - -e REAL_IP_HEADER=X-Forwarded-For \ - -p 80:8080 \ - neolab/nginx-security +```yaml +scrape_configs: + - job_name: 'nginx-waf' + static_configs: + - targets: [':9113'] + - job_name: 'crowdsec' + static_configs: + - targets: [':6060'] ``` -## Architecture +Import the dashboard [`monitoring/dashboards/nginx-security.json`](monitoring/dashboards/nginx-security.json) into your Grafana. + +### Useful Loki Queries ``` - ┌─────────────────────────────────────────┐ - │ neolab/nginx-security │ - Client ──────► │ Nginx + ModSecurity CRS v4 │ ──────► Backend - (HTTP/HTTPS) │ ┌─────────┐ ┌──────────┐ ┌────────┐ │ (HTTP/HTTPS) - │ │ Rate │─►│ WAF │─►│ Proxy │ │ - │ │ Limiter │ │(15 rules)│ │ │ │ - │ └─────────┘ └──────────┘ └────────┘ │ - └────────────────────┬────────────────────┘ - │ logs (volume mount) - ┌────────────────────┴────────────────────┐ - │ Monitoring (optional, separate) │ - │ CrowdSec │ Loki │ Promtail │ Grafana │ - └─────────────────────────────────────────┘ +{job="nginx", type="access"} |= "403" # Blocked by WAF +{job="nginx", type="access"} |= "429" # Rate limited +{job="modsecurity"} # WAF audit events ``` -## File Structure +## Reference -``` -cloud-native-nginx/ -├── Dockerfile # Single-image build -├── .dockerignore # Build context exclusions -├── Makefile # All commands -├── docker-compose.yml # Development env (with httpbin) -├── docker-compose.monitoring.yml # Monitoring stack (optional) -├── .env.example # Environment variable template -│ -├── config/ -│ ├── nginx/ -│ │ └── default.conf.template # Nginx config (rate limiting, proxy) -│ ├── modsecurity/ -│ │ ├── custom-rules.conf # 15 custom WAF rules -│ │ └── exclusions.conf # False positive exclusions -│ ├── crowdsec/ # CrowdSec IPS config -│ ├── loki/ -│ │ └── loki-config.yml # Log aggregation config -│ └── promtail/ -│ └── promtail-config.yml # Log collector config -│ -├── scripts/ -│ ├── test-security.sh # Attack tests (SQLi, XSS, SSRF, etc.) -│ └── test-false-positives.sh # Legitimate traffic tests -│ -├── docs/ -│ ├── QUICK_REFERENCE.md # Command reference -│ └── EMERGENCY.md # Emergency procedures -│ -├── certs/ # SSL certificates (not in image) -├── logs/ # Shared log volume -│ ├── nginx/ -│ └── modsecurity/ -└── monitoring/ - └── prometheus-scrape-config.yml # Prometheus config template -``` +### Environment Variables -## Docs +All variables have sensible defaults. You only need to set `BACKEND`. -- [Quick Reference](docs/QUICK_REFERENCE.md) - All commands and manual operations -- [Emergency Procedures](docs/EMERGENCY.md) - Incident response playbook +| Variable | Default | Description | +|----------|---------|-------------| +| **Core** | | | +| `BACKEND` | `http://localhost:80` | **Required.** Backend URL to proxy to | +| `PORT` | `8080` | HTTP listen port | +| `SSL_PORT` | `8443` | HTTPS listen port | +| `SERVER_NAME` | `_` | Nginx server_name | +| `PROXY_TIMEOUT` | `60` | Backend proxy timeout (seconds) | +| `SERVER_TOKENS` | `off` | Show nginx version in headers (`on`/`off`) | +| **WAF** | | | +| `MODSEC_RULE_ENGINE` | `On` | `On` = blocking, `DetectionOnly` = logging only, `Off` = disabled | +| `PARANOIA` | `1` | OWASP CRS paranoia level 1-4 (higher = stricter) | +| `ANOMALY_INBOUND` | `5` | Inbound anomaly score threshold | +| `ANOMALY_OUTBOUND` | `4` | Outbound anomaly score threshold | +| `MODSEC_AUDIT_LOG` | `/var/log/modsecurity/audit.log` | Path to WAF audit log | +| `MODSEC_AUDIT_LOG_FORMAT` | `JSON` | Audit log format (`JSON` or `Native`) | +| **HTTPS backend** | | | +| `PROXY_SSL` | `off` | Enable SSL to backend (`on`/`off`) | +| `PROXY_SSL_VERIFY` | `off` | Verify backend SSL certificate (`on`/`off`) | +| `PROXY_SSL_PROTOCOLS` | `TLSv1.2 TLSv1.3` | Allowed SSL protocols to backend | +| **Real IP** | | | +| `SET_REAL_IP_FROM` | — | Trusted proxy CIDR for LB/CDN (e.g. `10.0.0.0/8`) | +| `REAL_IP_HEADER` | `X-Forwarded-For` | Header containing real client IP | +| **SSL termination** | | | +| `SSL_CERT_FILE` | `/etc/nginx/certs/fullchain.pem` | Path to SSL certificate | +| `SSL_CERT_KEY_FILE` | `/etc/nginx/certs/privkey.pem` | Path to SSL private key | +| `SSL_PROTOCOLS` | `TLSv1.2 TLSv1.3` | Allowed TLS protocols | +| `SSL_CIPHERS` | *(base image default)* | Allowed SSL ciphers | +| `SSL_PREFER_CIPHERS` | `on` | Prefer server ciphers over client (`on`/`off`) | +| `SSL_DH_BITS` | `2048` | DH parameters size (`2048` or `4096`) | +| `SSL_OCSP_STAPLING` | `on` | OCSP stapling (`on`/`off`) | +| `SSL_VERIFY` | `off` | Verify client certificate (`on`/`off`) | +| `SSL_VERIFY_DEPTH` | `1` | Client certificate chain verification depth | +| `NGINX_ALWAYS_TLS_REDIRECT` | `off` | Redirect all HTTP to HTTPS (`on`/`off`) | + +### Volumes + +All optional. The image works out of the box. + +| Container Path | What It Does | +|----------------|-------------| +| `/etc/nginx/certs/` | SSL certificates for HTTPS termination | +| `/var/log/nginx/` | Nginx access and error logs | +| `/var/log/modsecurity/` | WAF audit logs (JSON) | +| `/etc/nginx/templates/conf.d/default.conf.template` | Custom nginx config (override rate limits, locations) | +| `/etc/modsecurity.d/owasp-crs/rules/RESPONSE-999-CUSTOM.conf` | Custom WAF rules | +| `/etc/modsecurity.d/owasp-crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` | WAF rule exclusions (false positive fixes) | + +### Ports + +| Port | Description | +|------|-------------| +| 8080 | HTTP | +| 8443 | HTTPS | +| 9113 | nginx-exporter metrics (sidecar) | +| 6060 | CrowdSec metrics (sidecar) | diff --git a/config/promtail/promtail-config.yml b/config/promtail/promtail-config.yml index 8db5082..02c7db7 100644 --- a/config/promtail/promtail-config.yml +++ b/config/promtail/promtail-config.yml @@ -6,7 +6,8 @@ positions: filename: /tmp/positions.yaml clients: - - url: http://loki:3100/loki/api/v1/push + - url: ${LOKI_URL} + tenant_id: ${LOKI_TENANT_ID} scrape_configs: # Nginx access logs diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml index 4ae7586..9d4103b 100644 --- a/docker-compose.monitoring.yml +++ b/docker-compose.monitoring.yml @@ -2,9 +2,14 @@ # Reads logs from shared host volumes (./logs/) # # Usage: -# docker compose -f docker-compose.monitoring.yml up -d -# docker compose -f docker-compose.monitoring.yml --profile bouncer up -d # with IP blocking -# docker compose -f docker-compose.monitoring.yml --profile grafana up -d # with dashboards +# Standalone (full stack): +# docker compose -f docker-compose.monitoring.yml --profile standalone up -d +# External (connect to provider's Loki/Prometheus): +# LOKI_URL=https://loki.provider.com/loki/api/v1/push docker compose -f docker-compose.monitoring.yml up -d +# With Grafana dashboards: +# docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d +# With CrowdSec bouncer: +# docker compose -f docker-compose.monitoring.yml --profile standalone --profile bouncer up -d services: # =========================================== @@ -68,11 +73,13 @@ services: memory: 128M # =========================================== - # Loki - Log Aggregation + # Loki - Log Aggregation (standalone mode only) # =========================================== loki: image: grafana/loki:3.3.2 container_name: loki + profiles: + - standalone ports: - "${LOKI_PORT:-3100}:3100" volumes: @@ -98,11 +105,12 @@ services: volumes: - ./config/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro - ./logs:/var/log/app:ro - command: -config.file=/etc/promtail/promtail-config.yml + command: -config.file=/etc/promtail/promtail-config.yml -config.expand-env=true + environment: + - LOKI_URL=${LOKI_URL:-http://loki:3100/loki/api/v1/push} + - LOKI_TENANT_ID=${LOKI_TENANT_ID:-} networks: - monitoring-net - depends_on: - - loki restart: unless-stopped # =========================================== @@ -148,8 +156,6 @@ services: - grafana-data:/var/lib/grafana networks: - monitoring-net - depends_on: - - loki restart: unless-stopped profiles: - grafana diff --git a/monitoring/dashboards/README.md b/monitoring/dashboards/README.md new file mode 100644 index 0000000..fe59beb --- /dev/null +++ b/monitoring/dashboards/README.md @@ -0,0 +1,54 @@ +# Grafana Dashboard — Nginx Security Stack + +Pre-built Grafana dashboard for monitoring the Nginx Security Stack. + +## Import Instructions + +### Via Grafana UI + +1. Open Grafana → **Dashboards** → **New** → **Import** +2. Click **Upload dashboard JSON file** and select `nginx-security.json` +3. Select your **Prometheus** and **Loki** data sources when prompted +4. Click **Import** + +### Via Grafana API + +```bash +curl -X POST http://admin:password@grafana-host:3000/api/dashboards/import \ + -H 'Content-Type: application/json' \ + -d @nginx-security.json +``` + +## Prerequisites + +The dashboard requires two data sources configured in Grafana: + +| Data Source | Type | Purpose | +|---|---|---| +| Prometheus | `prometheus` | nginx-exporter metrics (port 9113) + CrowdSec metrics (port 6060) | +| Loki | `loki` | Nginx access/error logs + ModSecurity audit logs | + +Add scrape targets to Prometheus using `monitoring/prometheus-scrape-config.yml`. + +## Dashboard Panels + +### Status Row +- **Nginx Status** — UP/DOWN indicator +- **Active Connections** — current active connections +- **Request Rate** — requests per second (5m average) +- **CrowdSec Active Bans** — current active ban decisions +- **CrowdSec Alerts** — alerts in last 24h +- **Attack Rate** — CrowdSec scenario triggers per second + +### Metrics Charts +- **Request Rate Over Time** — HTTP requests/s timeseries +- **Nginx Connections** — active, reading, writing, waiting connections +- **CrowdSec Active Decisions** — ban/captcha decisions by reason +- **CrowdSec Attack Rate** — scenario overflows and alerts rate + +### Log Panels +- **Nginx Access Logs** — all access log entries +- **Nginx Error Logs** — error log entries +- **ModSecurity Audit Logs** — WAF audit events (JSON) +- **Blocked Requests (403)** — requests blocked by WAF +- **Rate Limited Requests (429)** — requests blocked by rate limiter diff --git a/monitoring/dashboards/nginx-security.json b/monitoring/dashboards/nginx-security.json new file mode 100644 index 0000000..1ab1334 --- /dev/null +++ b/monitoring/dashboards/nginx-security.json @@ -0,0 +1,336 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "Prometheus data source for nginx-exporter and CrowdSec metrics", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + }, + { + "name": "DS_LOKI", + "label": "Loki", + "description": "Loki data source for nginx and ModSecurity logs", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki" + } + ], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus" }, + { "type": "datasource", "id": "loki", "name": "Loki" }, + { "type": "panel", "id": "timeseries", "name": "Time series" }, + { "type": "panel", "id": "stat", "name": "Stat" }, + { "type": "panel", "id": "logs", "name": "Logs" }, + { "type": "panel", "id": "table", "name": "Table" } + ], + "id": null, + "uid": "nginx-security-overview", + "title": "Nginx Security Stack", + "description": "Overview of Nginx WAF, CrowdSec IPS, and security events", + "tags": ["nginx", "security", "waf", "crowdsec", "modsecurity"], + "timezone": "browser", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "id": 1, + "title": "Nginx Status", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "nginx_up", + "legendFormat": "Nginx" + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } }, "type": "value" } + ], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + }, + "overrides": [] + } + }, + { + "id": 2, + "title": "Active Connections", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "nginx_connections_active", + "legendFormat": "Active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 100 }, { "color": "red", "value": 500 }] } + }, + "overrides": [] + } + }, + { + "id": 3, + "title": "Request Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "rate(nginx_http_requests_total[5m])", + "legendFormat": "req/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 100 }, { "color": "red", "value": 500 }] } + }, + "overrides": [] + } + }, + { + "id": 4, + "title": "CrowdSec Active Bans", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "sum(cs_active_decisions) or vector(0)", + "legendFormat": "Bans" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 20 }] } + }, + "overrides": [] + } + }, + { + "id": 5, + "title": "CrowdSec Alerts", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "sum(increase(cs_alerts_total[24h])) or vector(0)", + "legendFormat": "24h" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 10 }, { "color": "red", "value": 50 }] } + }, + "overrides": [] + } + }, + { + "id": 6, + "title": "Attack Rate (CrowdSec)", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "sum(rate(cs_bucket_overflowed_total[5m])) or vector(0)", + "legendFormat": "attacks/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.1 }, { "color": "red", "value": 1 }] } + }, + "overrides": [] + } + }, + { + "id": 10, + "title": "Request Rate Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "rate(nginx_http_requests_total[5m])", + "legendFormat": "Requests/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 20 } + }, + "overrides": [] + } + }, + { + "id": 11, + "title": "Nginx Connections", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { "expr": "nginx_connections_active", "legendFormat": "Active" }, + { "expr": "nginx_connections_reading", "legendFormat": "Reading" }, + { "expr": "nginx_connections_writing", "legendFormat": "Writing" }, + { "expr": "nginx_connections_waiting", "legendFormat": "Waiting" } + ], + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 10 } + }, + "overrides": [] + } + }, + { + "id": 12, + "title": "CrowdSec Active Decisions", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { "expr": "cs_active_decisions{action=\"ban\"}", "legendFormat": "Ban - {{reason}}" }, + { "expr": "cs_active_decisions{action=\"captcha\"}", "legendFormat": "Captcha - {{reason}}" } + ], + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "bars", "fillOpacity": 50 } + }, + "overrides": [] + } + }, + { + "id": 13, + "title": "CrowdSec Attack Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { "expr": "rate(cs_bucket_overflowed_total[5m])", "legendFormat": "{{name}}" }, + { "expr": "rate(cs_alerts_total[5m])", "legendFormat": "Alerts" } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 20 } + }, + "overrides": [] + } + }, + { + "id": 20, + "title": "Nginx Access Logs", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"access\"}", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 21, + "title": "Nginx Error Logs", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"error\"}", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 22, + "title": "ModSecurity Audit Logs", + "type": "logs", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"modsecurity\"}", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 23, + "title": "Blocked Requests (403)", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"access\"} |= \"403\"", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 24, + "title": "Rate Limited Requests (429)", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"access\"} |= \"429\"", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + } + ], + "refresh": "30s", + "schemaVersion": 39, + "style": "dark", + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "version": 1 +}