diff --git a/.env.example b/.env.example index ca94060..ddd7347 100644 --- a/.env.example +++ b/.env.example @@ -56,5 +56,5 @@ INIT_ADMIN_PASSWORD=ChangeMe123! # ------------------------------ # Frontend # ------------------------------ -# Host port mapped to frontend container port 80. +# Host port mapped to frontend container port 8080. FRONTEND_PORT=5173 diff --git a/.github/workflows/proxy-profile-validation.yml b/.github/workflows/proxy-profile-validation.yml new file mode 100644 index 0000000..152c68e --- /dev/null +++ b/.github/workflows/proxy-profile-validation.yml @@ -0,0 +1,35 @@ +name: Proxy Profile Validation + +on: + push: + branches: ["main", "master", "development"] + paths: + - "frontend/**" + - "ops/profiles/prod/**" + - "ops/scripts/validate_proxy_profile.sh" + - ".github/workflows/proxy-profile-validation.yml" + - "README.md" + - ".env.example" + - "ops/.env.example" + pull_request: + paths: + - "frontend/**" + - "ops/profiles/prod/**" + - "ops/scripts/validate_proxy_profile.sh" + - ".github/workflows/proxy-profile-validation.yml" + - "README.md" + - ".env.example" + - "ops/.env.example" + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Validate proxy profile and mixed-content guardrails + run: bash ops/scripts/validate_proxy_profile.sh diff --git a/README.md b/README.md index e1c57a4..b787e60 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC, - [API Error Format](#api-error-format) - [`pg_stat_statements` Requirement](#pg_stat_statements-requirement) - [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance) +- [Production Proxy Profile](#production-proxy-profile) - [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test) - [Dependency Exception Flow](#dependency-exception-flow) - [Troubleshooting](#troubleshooting) @@ -372,6 +373,21 @@ For production, serve frontend and API under the same public origin via reverse This prevents mixed-content and CORS issues. +## Production Proxy Profile + +A secure, repeatable production profile is included: + +- `ops/profiles/prod/.env.production.example` +- `ops/profiles/prod/nginx/nexapg.conf` +- `docs/deployment/proxy-production-profile.md` + +Highlights: + +- explicit CORS recommendations per environment (`dev`, `staging`, `prod`) +- required reverse-proxy header forwarding for backend context +- API path forwarding (`/api/` -> backend) +- mixed-content prevention guidance for HTTPS deployments + ## PostgreSQL Compatibility Smoke Test Run manually against one DSN: diff --git a/docs/deployment/proxy-production-profile.md b/docs/deployment/proxy-production-profile.md new file mode 100644 index 0000000..1ab45af --- /dev/null +++ b/docs/deployment/proxy-production-profile.md @@ -0,0 +1,78 @@ +# Production Proxy Profile (HTTPS) + +This profile defines a secure and repeatable NexaPG deployment behind a reverse proxy. + +## Included Profile Files + +- `ops/profiles/prod/.env.production.example` +- `ops/profiles/prod/nginx/nexapg.conf` + +## CORS Recommendations by Environment + +| Environment | Recommended `CORS_ORIGINS` | Notes | +|---|---|---| +| `dev` | `*` or local explicit origins | `*` is acceptable only for local/dev usage. | +| `staging` | Exact staging UI origins | Example: `https://staging-monitor.example.com` | +| `prod` | Exact production UI origin(s) only | No wildcard; use comma-separated HTTPS origins if needed. | + +Examples: + +```env +# dev only +CORS_ORIGINS=* + +# staging +CORS_ORIGINS=https://staging-monitor.example.com + +# prod +CORS_ORIGINS=https://monitor.example.com +``` + +## Reverse Proxy Requirements + +For stable auth, CORS, and request context handling, forward these headers to backend: + +- `Host` +- `X-Real-IP` +- `X-Forwarded-For` +- `X-Forwarded-Proto` +- `X-Forwarded-Host` +- `X-Forwarded-Port` + +Also forward API paths: + +- `/api/` -> backend service (`:8000`) + +## Mixed-Content Prevention + +NexaPG frontend is designed to avoid mixed-content in HTTPS mode: + +- Build/runtime default API base is relative (`/api/v1`) +- `frontend/src/api.js` upgrades `http` API URL to `https` when page runs on HTTPS + +Recommended production setting: + +```env +VITE_API_URL=/api/v1 +``` + +## Validation Checklist + +1. Open app over HTTPS and verify: + - login request is `https://.../api/v1/auth/login` + - no browser mixed-content errors in console +2. Verify CORS behavior: + - allowed origin works + - unknown origin is blocked +3. Verify backend receives forwarded protocol: + - proxied responses succeed with no redirect/proto issues + +## CI Validation + +`Proxy Profile Validation` workflow runs static guardrail checks: + +- relative `VITE_API_URL` default +- required API proxy path in frontend NGINX config +- required forwarded headers +- HTTPS mixed-content guard in frontend API resolver +- production profile forbids wildcard CORS diff --git a/ops/.env.example b/ops/.env.example index 124c479..5963a29 100644 --- a/ops/.env.example +++ b/ops/.env.example @@ -49,7 +49,7 @@ INIT_ADMIN_PASSWORD=ChangeMe123! # ------------------------------ # Frontend # ------------------------------ -# Host port mapped to frontend container port 80. +# Host port mapped to frontend container port 8080. FRONTEND_PORT=5173 # Base API URL used at frontend build time. # For reverse proxy + SSL, keep this relative to avoid mixed-content issues. diff --git a/ops/profiles/prod/.env.production.example b/ops/profiles/prod/.env.production.example new file mode 100644 index 0000000..12786e3 --- /dev/null +++ b/ops/profiles/prod/.env.production.example @@ -0,0 +1,48 @@ +# NexaPG production profile (reverse proxy + HTTPS) +# Copy to .env and adjust values for your environment. + +# ------------------------------ +# Application +# ------------------------------ +APP_NAME=NexaPG Monitor +ENVIRONMENT=prod +LOG_LEVEL=INFO + +# ------------------------------ +# Core Database +# ------------------------------ +DB_NAME=nexapg +DB_USER=nexapg +DB_PASSWORD=change_me +DB_PORT=5433 + +# ------------------------------ +# Backend +# ------------------------------ +BACKEND_PORT=8000 +JWT_SECRET_KEY=replace_with_long_random_secret +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_MINUTES=15 +JWT_REFRESH_TOKEN_MINUTES=10080 +ENCRYPTION_KEY=REPLACE_WITH_FERNET_KEY + +# Production CORS: +# - no wildcard +# - set exact public UI origin(s) +CORS_ORIGINS=https://monitor.example.com + +POLL_INTERVAL_SECONDS=30 +ALERT_ACTIVE_CONNECTION_RATIO_MIN_TOTAL_CONNECTIONS=5 +ALERT_ROLLBACK_RATIO_WINDOW_MINUTES=15 +ALERT_ROLLBACK_RATIO_MIN_TOTAL_TRANSACTIONS=100 +ALERT_ROLLBACK_RATIO_MIN_ROLLBACKS=10 + +INIT_ADMIN_EMAIL=admin@example.com +INIT_ADMIN_PASSWORD=ChangeMe123! + +# ------------------------------ +# Frontend +# ------------------------------ +# Keep frontend API base relative to avoid HTTPS mixed-content. +FRONTEND_PORT=5173 +VITE_API_URL=/api/v1 diff --git a/ops/profiles/prod/nginx/nexapg.conf b/ops/profiles/prod/nginx/nexapg.conf new file mode 100644 index 0000000..5496b88 --- /dev/null +++ b/ops/profiles/prod/nginx/nexapg.conf @@ -0,0 +1,49 @@ +# NGINX reverse proxy profile for NexaPG (HTTPS). +# Replace monitor.example.com and certificate paths. + +server { + listen 80; + server_name monitor.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name monitor.example.com; + + ssl_certificate /etc/letsencrypt/live/monitor.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/monitor.example.com/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # Baseline security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Frontend app + location / { + proxy_pass http://127.0.0.1:5173; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } + + # API forwarding to backend + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } +} diff --git a/ops/scripts/validate_proxy_profile.sh b/ops/scripts/validate_proxy_profile.sh new file mode 100644 index 0000000..8a57282 --- /dev/null +++ b/ops/scripts/validate_proxy_profile.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[proxy-profile] validating reverse-proxy and mixed-content guardrails" + +require_pattern() { + local file="$1" + local pattern="$2" + local message="$3" + if ! grep -Eq "$pattern" "$file"; then + echo "[proxy-profile] FAIL: $message ($file)" + exit 1 + fi +} + +# Frontend should default to relative API base in container builds. +require_pattern "frontend/Dockerfile" "ARG VITE_API_URL=/api/v1" \ + "VITE_API_URL default must be relative (/api/v1)" + +# Frontend runtime proxy should forward /api with forward headers. +require_pattern "frontend/nginx.conf" "location /api/" \ + "frontend nginx must proxy /api/" +require_pattern "frontend/nginx.conf" "proxy_set_header X-Forwarded-Proto" \ + "frontend nginx must set X-Forwarded-Proto" +require_pattern "frontend/nginx.conf" "proxy_set_header X-Forwarded-For" \ + "frontend nginx must set X-Forwarded-For" +require_pattern "frontend/nginx.conf" "proxy_set_header Host" \ + "frontend nginx must forward Host" + +# Mixed-content guard in frontend API client. +require_pattern "frontend/src/api.js" "window\\.location\\.protocol === \"https:\".*parsed\\.protocol === \"http:\"" \ + "frontend api client must contain HTTPS mixed-content protection" + +# Production profile must not use wildcard CORS. +require_pattern "ops/profiles/prod/.env.production.example" "^CORS_ORIGINS=https://[^*]+$" \ + "production profile must use explicit HTTPS CORS origins" + +echo "[proxy-profile] PASS"