diff --git a/NGINX_SETUP_QUICK.md b/NGINX_SETUP_QUICK.md new file mode 100644 index 0000000..22e626f --- /dev/null +++ b/NGINX_SETUP_QUICK.md @@ -0,0 +1,84 @@ +# Quick Start: Nginx Setup for DigiServer v2 + +## Pre-requisites +- SSL certificates in `./data/nginx-ssl/cert.pem` and `./data/nginx-ssl/key.pem` +- Docker and Docker Compose installed +- Port 80 and 443 available + +## Quick Setup (3 steps) + +### 1. Generate Self-Signed Certificates +```bash +./generate_nginx_certs.sh localhost 365 +``` + +### 2. Update Nginx Configuration +- Edit `nginx.conf` to set your domain: + ```nginx + server_name localhost; # Change to your domain + ``` + +### 3. Start Docker Compose +```bash +docker-compose up -d +``` + +## Verification + +### Check if Nginx is running +```bash +docker ps | grep nginx +``` + +### Test HTTP → HTTPS redirect +```bash +curl -L http://localhost +``` + +### Test HTTPS (with self-signed cert) +```bash +curl -k https://localhost +``` + +### View logs +```bash +docker logs digiserver-nginx +docker exec digiserver-nginx tail -f /var/log/nginx/access.log +``` + +## Using Production Certificates + +### Option A: Let's Encrypt (Free) +1. Install certbot: `apt-get install certbot` +2. Generate cert: `certbot certonly --standalone -d your-domain.com` +3. Copy cert: `cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ./data/nginx-ssl/cert.pem` +4. Copy key: `cp /etc/letsencrypt/live/your-domain.com/privkey.pem ./data/nginx-ssl/key.pem` +5. Fix permissions: `sudo chown 101:101 ./data/nginx-ssl/*` +6. Reload: `docker exec digiserver-nginx nginx -s reload` + +### Option B: Commercial Certificate +1. Place your certificate files in `./data/nginx-ssl/cert.pem` and `./data/nginx-ssl/key.pem` +2. Fix permissions: `sudo chown 101:101 ./data/nginx-ssl/*` +3. Reload: `docker exec digiserver-nginx nginx -s reload` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Port 80/443 in use | `sudo netstat -tlnp \| grep :80` or `:443` | +| Certificate permission denied | `sudo chown 101:101 ./data/nginx-ssl/*` | +| Nginx won't start | `docker logs digiserver-nginx` | +| Connection refused | Check firewall: `sudo ufw allow 80/tcp && sudo ufw allow 443/tcp` | + +## File Locations +- Main config: `./nginx.conf` +- SSL certs: `./data/nginx-ssl/` +- Logs: `./data/nginx-logs/` +- Custom domains: `./nginx-custom-domains.conf` (auto-generated) + +## Next: Production Setup +1. Update `.env` with your DOMAIN and EMAIL +2. Configure HTTPS settings in admin panel +3. Run: `python nginx_manager.py generate` +4. Test: `docker exec digiserver-nginx nginx -t` +5. Reload: `docker exec digiserver-nginx nginx -s reload` diff --git a/PROXY_FIX_SETUP.md b/PROXY_FIX_SETUP.md new file mode 100644 index 0000000..659f200 --- /dev/null +++ b/PROXY_FIX_SETUP.md @@ -0,0 +1,56 @@ +# ProxyFix Middleware Setup - DigiServer v2 + +## Overview +ProxyFix middleware is now properly configured in the Flask app to handle reverse proxy headers from Nginx (or Caddy). This ensures correct handling of: +- **X-Real-IP**: Client's real IP address +- **X-Forwarded-For**: List of IPs in the proxy chain +- **X-Forwarded-Proto**: Original protocol (http/https) +- **X-Forwarded-Host**: Original hostname + +## Configuration Details + +### Flask App (app/app.py) +```python +from werkzeug.middleware.proxy_fix import ProxyFix + +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) +``` + +**Parameters:** +- `x_for=1`: Trust one proxy for X-Forwarded-For header +- `x_proto=1`: Trust proxy for X-Forwarded-Proto header +- `x_host=1`: Trust proxy for X-Forwarded-Host header +- `x_port=1`: Trust proxy for X-Forwarded-Port header + +### Config Settings (app/config.py) + +```python +# Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware) +TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16') +PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https') +``` + +## Testing ProxyFix + +### 1. Test Real Client IP +```bash +docker exec digiserver-app flask shell +>>> from flask import request +>>> request.remote_addr # Should show client IP +``` + +### 2. Test URL Scheme +```bash +docker exec digiserver-app flask shell +>>> from flask import url_for +>>> url_for('auth.login', _external=True) # Should use https:// +``` + +## Verification Checklist + +- [x] ProxyFix imported in app.py +- [x] app.wsgi_app wrapped with ProxyFix +- [x] TRUSTED_PROXIES configured +- [x] PREFERRED_URL_SCHEME set to 'https' +- [x] SESSION_COOKIE_SECURE=True in ProductionConfig +- [x] Nginx headers configured correctly diff --git a/app/app.py b/app/app.py index 42e3e51..6c6120c 100755 --- a/app/app.py +++ b/app/app.py @@ -4,6 +4,7 @@ Modern Flask application with blueprint architecture """ import os from flask import Flask, render_template +from werkzeug.middleware.proxy_fix import ProxyFix from dotenv import load_dotenv from app.config import DevelopmentConfig, ProductionConfig, TestingConfig @@ -37,6 +38,10 @@ def create_app(config_name=None): app.config.from_object(config) + # Apply ProxyFix middleware for reverse proxy (Nginx/Caddy) + # This ensures proper handling of X-Forwarded-* headers + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) + # Initialize extensions db.init_app(app) bcrypt.init_app(app) diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index 62fe38a..3898fab 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -11,6 +11,7 @@ from app.extensions import db, bcrypt from app.models import User, Player, Group, Content, ServerLog, Playlist, HTTPSConfig from app.utils.logger import log_action from app.utils.caddy_manager import CaddyConfigGenerator +from app.utils.nginx_config_reader import get_nginx_status admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -870,10 +871,14 @@ def https_config(): db.session.commit() log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)') + # Get Nginx configuration status + nginx_status = get_nginx_status() + return render_template('admin/https_config.html', config=config, is_https_active=is_https_active, - current_host=current_host) + current_host=current_host, + nginx_status=nginx_status) except Exception as e: log_action('error', f'Error loading HTTPS config page: {str(e)}') flash('Error loading HTTPS configuration page.', 'danger') diff --git a/app/config.py b/app/config.py index bcda914..16c162d 100755 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,11 @@ class Config: SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' + # Reverse proxy trust (for Nginx/Caddy with ProxyFix middleware) + # These are set by werkzeug.middleware.proxy_fix + TRUSTED_PROXIES = os.getenv('TRUSTED_PROXIES', '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16') + PREFERRED_URL_SCHEME = os.getenv('PREFERRED_URL_SCHEME', 'https') + # Cache SEND_FILE_MAX_AGE_DEFAULT = 300 # 5 minutes for static files diff --git a/app/templates/admin/https_config.html b/app/templates/admin/https_config.html index a3012f1..7eb2b7a 100644 --- a/app/templates/admin/https_config.html +++ b/app/templates/admin/https_config.html @@ -160,6 +160,95 @@ + +
+

🔧 Nginx Reverse Proxy Status

+ {% if nginx_status.available %} +
+
+ Status: + ✅ Nginx Configured +
+ +
+ Configuration Path: + {{ nginx_status.path }} +
+ + {% if nginx_status.ssl_enabled %} +
+ SSL/TLS: + 🔒 Enabled +
+ {% else %} +
+ SSL/TLS: + âš ī¸ Not Configured +
+ {% endif %} + + {% if nginx_status.http_ports %} +
+ HTTP Ports: + {{ nginx_status.http_ports|join(', ') }} +
+ {% endif %} + + {% if nginx_status.https_ports %} +
+ HTTPS Ports: + {{ nginx_status.https_ports|join(', ') }} +
+ {% endif %} + + {% if nginx_status.server_names %} +
+ Server Names: + {% for name in nginx_status.server_names %} + {{ name }}{% if not loop.last %}
{% endif %} + {% endfor %} +
+ {% endif %} + + {% if nginx_status.upstream_servers %} +
+ Upstream Servers: + {% for server in nginx_status.upstream_servers %} + {{ server }}{% if not loop.last %}
{% endif %} + {% endfor %} +
+ {% endif %} + + {% if nginx_status.ssl_protocols %} +
+ SSL Protocols: + {{ nginx_status.ssl_protocols|join(', ') }} +
+ {% endif %} + + {% if nginx_status.client_max_body_size %} +
+ Max Body Size: + {{ nginx_status.client_max_body_size }} +
+ {% endif %} + + {% if nginx_status.gzip_enabled %} +
+ Gzip Compression: + ✅ Enabled +
+ {% endif %} +
+ {% else %} +
+

âš ī¸ Nginx configuration not accessible

+

Error: {{ nginx_status.error|default('Unknown error') }}

+

Path checked: {{ nginx_status.path }}

+
+ {% endif %} +
+

â„šī¸ Important Information

@@ -466,6 +555,45 @@ color: #0066cc; } +.nginx-status-card { + background: linear-gradient(135deg, #f0f7ff 0%, #e7f3ff 100%); + border-left: 5px solid #0066cc; + margin-bottom: 30px; +} + +.nginx-status-card h2 { + color: #0066cc; + margin-top: 0; +} + +.nginx-status-content { + padding: 10px 0; +} + +.status-item { + padding: 12px; + background: white; + border-radius: 4px; + margin-bottom: 10px; + border-left: 3px solid #0066cc; + font-size: 14px; +} + +.status-item strong { + display: inline-block; + min-width: 150px; + color: #333; +} + +.status-item code { + background: #f0f7ff; + padding: 4px 8px; + border-radius: 3px; + font-family: 'Courier New', monospace; + color: #0066cc; + word-break: break-all; +} + .info-sections { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); diff --git a/app/utils/nginx_config_reader.py b/app/utils/nginx_config_reader.py new file mode 100644 index 0000000..b8f4d6c --- /dev/null +++ b/app/utils/nginx_config_reader.py @@ -0,0 +1,120 @@ +"""Nginx configuration reader utility.""" +import os +import re +from typing import Dict, List, Optional, Any + + +class NginxConfigReader: + """Read and parse Nginx configuration files.""" + + def __init__(self, config_path: str = '/etc/nginx/nginx.conf'): + """Initialize Nginx config reader.""" + self.config_path = config_path + self.config_content = None + self.is_available = os.path.exists(config_path) + + if self.is_available: + try: + with open(config_path, 'r') as f: + self.config_content = f.read() + except Exception as e: + self.is_available = False + self.error = str(e) + + def get_status(self) -> Dict[str, Any]: + """Get Nginx configuration status.""" + if not self.is_available: + return { + 'available': False, + 'error': 'Nginx configuration not found', + 'path': self.config_path + } + + return { + 'available': True, + 'path': self.config_path, + 'file_exists': os.path.exists(self.config_path), + 'ssl_enabled': self._check_ssl_enabled(), + 'http_ports': self._extract_http_ports(), + 'https_ports': self._extract_https_ports(), + 'upstream_servers': self._extract_upstream_servers(), + 'server_names': self._extract_server_names(), + 'ssl_protocols': self._extract_ssl_protocols(), + 'client_max_body_size': self._extract_client_max_body_size(), + 'gzip_enabled': self._check_gzip_enabled(), + } + + def _check_ssl_enabled(self) -> bool: + """Check if SSL is enabled.""" + if not self.config_content: + return False + return 'ssl_certificate' in self.config_content + + def _extract_http_ports(self) -> List[int]: + """Extract HTTP listening ports.""" + if not self.config_content: + return [] + pattern = r'listen\s+(\d+)' + matches = re.findall(pattern, self.config_content) + return sorted(list(set(int(p) for p in matches if int(p) < 1000))) + + def _extract_https_ports(self) -> List[int]: + """Extract HTTPS listening ports.""" + if not self.config_content: + return [] + pattern = r'listen\s+(\d+).*ssl' + matches = re.findall(pattern, self.config_content) + return sorted(list(set(int(p) for p in matches))) + + def _extract_upstream_servers(self) -> List[str]: + """Extract upstream servers.""" + if not self.config_content: + return [] + upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content) + if upstream_match: + upstream_content = upstream_match.group(1) + servers = re.findall(r'server\s+([^\s;]+)', upstream_content) + return servers + return [] + + def _extract_server_names(self) -> List[str]: + """Extract server names.""" + if not self.config_content: + return [] + pattern = r'server_name\s+([^;]+);' + matches = re.findall(pattern, self.config_content) + result = [] + for match in matches: + names = match.strip().split() + result.extend(names) + return result + + def _extract_ssl_protocols(self) -> List[str]: + """Extract SSL protocols.""" + if not self.config_content: + return [] + pattern = r'ssl_protocols\s+([^;]+);' + match = re.search(pattern, self.config_content) + if match: + return match.group(1).strip().split() + return [] + + def _extract_client_max_body_size(self) -> Optional[str]: + """Extract client max body size.""" + if not self.config_content: + return None + pattern = r'client_max_body_size\s+([^;]+);' + match = re.search(pattern, self.config_content) + return match.group(1).strip() if match else None + + def _check_gzip_enabled(self) -> bool: + """Check if gzip is enabled.""" + if not self.config_content: + return False + return bool(re.search(r'gzip\s+on\s*;', self.config_content)) + + +def get_nginx_status() -> Dict[str, Any]: + """Get Nginx configuration status.""" + reader = NginxConfigReader() + return reader.get_status() diff --git a/docker-compose.yml b/docker-compose.yml index c87082a..73d1e4f 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,19 +26,19 @@ services: networks: - digiserver-network - # Caddy reverse proxy with automatic HTTPS - caddy: - image: caddy:2-alpine - container_name: digiserver-caddy + # Nginx reverse proxy with HTTPS support + nginx: + image: nginx:alpine + container_name: digiserver-nginx ports: - "80:80" - "443:443" - - "443:443/udp" # HTTP/3 support - - "2019:2019" # Caddy admin API volumes: - - ./data/Caddyfile:/etc/caddy/Caddyfile:ro - - ./data/caddy-data:/data - - ./data/caddy-config:/config + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw + - ./data/nginx-ssl:/etc/nginx/ssl:ro + - ./data/nginx-logs:/var/log/nginx + - ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges environment: - DOMAIN=${DOMAIN:-localhost} - EMAIL=${EMAIL:-admin@localhost} @@ -46,6 +46,12 @@ services: digiserver-app: condition: service_started restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s networks: - digiserver-network diff --git a/generate_nginx_certs.sh b/generate_nginx_certs.sh new file mode 100755 index 0000000..d081f1e --- /dev/null +++ b/generate_nginx_certs.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Generate self-signed SSL certificates for Nginx +# Usage: ./generate_nginx_certs.sh [domain] [days] + +DOMAIN=${1:-localhost} +DAYS=${2:-365} +CERT_DIR="./data/nginx-ssl" + +echo "🔐 Generating self-signed SSL certificate for Nginx" +echo "Domain: $DOMAIN" +echo "Valid for: $DAYS days" +echo "Certificate directory: $CERT_DIR" + +# Create directory if it doesnt exist +mkdir -p "$CERT_DIR" + +# Generate private key and certificate +openssl req -x509 -nodes -days "$DAYS" \ + -newkey rsa:2048 \ + -keyout "$CERT_DIR/key.pem" \ + -out "$CERT_DIR/cert.pem" \ + -subj "/CN=$DOMAIN/O=DigiServer/C=US" + +# Set proper permissions +chmod 644 "$CERT_DIR/cert.pem" +chmod 600 "$CERT_DIR/key.pem" + +echo "✅ Certificates generated successfully!" +echo "Certificate: $CERT_DIR/cert.pem" +echo "Key: $CERT_DIR/key.pem" diff --git a/nginx-custom-domains.conf b/nginx-custom-domains.conf new file mode 100644 index 0000000..32fc0da --- /dev/null +++ b/nginx-custom-domains.conf @@ -0,0 +1,21 @@ +# Nginx configuration for custom HTTPS domains +# This file will be dynamically generated based on HTTPSConfig database entries +# Include this in your nginx.conf with: include /etc/nginx/conf.d/custom-domains.conf; + +# Example entry for custom domain: +# server { +# listen 443 ssl http2; +# listen [::]:443 ssl http2; +# server_name digiserver.example.com; +# +# ssl_certificate /etc/nginx/ssl/custom/cert.pem; +# ssl_certificate_key /etc/nginx/ssl/custom/key.pem; +# +# location / { +# proxy_pass http://digiserver_app; +# 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; +# } +# } diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..c2323bc --- /dev/null +++ b/nginx.conf @@ -0,0 +1,117 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 2048M; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml; + + # Upstream to Flask application + upstream digiserver_app { + server digiserver-app:5000; + keepalive 32; + } + + # HTTP Server - redirect to HTTPS + server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + # Allow ACME challenges for Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Redirect HTTP to HTTPS for non-ACME requests + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS Server (with self-signed cert by default) + server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + server_name localhost; + + # SSL certificate paths (will be volume-mounted) + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # SSL Configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # Proxy settings + location / { + proxy_pass http://digiserver_app; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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 $server_name; + + # Timeouts for large uploads + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + } + + # Static files caching + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://digiserver_app; + proxy_cache_valid 200 60d; + expires 60d; + add_header Cache-Control "public, immutable"; + } + } + + # Additional server blocks for custom domains can be included here + include /etc/nginx/conf.d/*.conf; +}