diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/enhanced-nginx.iml b/.idea/enhanced-nginx.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/enhanced-nginx.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..0c2c824 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..f324872 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83ffdc6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM nginx:1.26.0 + +LABEL org.opencontainers.image.source="https://github.com/fedorov-xyz/nginx" + +RUN apt-get update && apt-get install --no-install-recommends -y \ + nano \ + curl \ + cron \ + certbot \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /etc/nginx/ + +COPY nginx/nginxconfig.io/ ./nginxconfig.io +COPY nginx/nginx.conf . + +COPY scripts/entrypoint.sh / +RUN ["chmod", "+x", "/entrypoint.sh"] + +COPY scripts/update_cloudflare_ips.sh /usr/local/bin/update_cloudflare_ips.sh +RUN chmod +x /usr/local/bin/update_cloudflare_ips.sh + +COPY cron/crontab /etc/cron.d/crontab +RUN chmod 0644 /etc/cron.d/crontab +RUN crontab /etc/cron.d/crontab +RUN touch /var/log/cron.log + +# Запуск cron и nginx +CMD ["/entrypoint.sh"] diff --git a/README.md b/README.md index 2df5036..0dbc943 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ -# nginx -Nginx Docker container with standalone certbot and automatic update of Cloudflare IP address ranges +# Enhanced Nginx + +> Documentation in progress + +The script to generate the config for Cloudflare is taken from here: https://github.com/ergin/nginx-cloudflare-real-ip + +Nginx Docker container with standalone certbot and automatic update of Cloudflare IP address ranges. + +## How it works + +Instead of agonizing with certbot + nginx + well-known/acme-challenge, let's just let certbot do its job in standalone mode. + +When the container is started for the first time, certbot will bring up its server, issue and fail certificates. And then at the very end nginx will start up. + +When restarting the container, certbot will check the existing certificate for expiration, and if the expiration is ok, it will just not do anything, nginx will start right away. + +## Usage + +1. Specify 2 volumes for nginx and letsencrypt +2. Mount your site config to container. You can mount multiple sites + +You should use the following lines in your nginx config for the site. `REPLACEMENT_CERT_NAME` will be replaced by the name of the certificate you pass to the container. + +```nginx configuration + ssl_certificate /etc/letsencrypt/live/REPLACEMENT_CERT_NAME/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/REPLACEMENT_CERT_NAME/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/REPLACEMENT_CERT_NAME/chain.pem; +``` + +If you want to serve multiple domains within a container, list their domains in the `SITE_DOMAINS` environment variable. A common certificate will be issued for them. + +Example configuration for Docker Compose: + +```yaml filename="docker-compose.yml" +volumes: + nginx_data: + letsencrypt_data: + +services: + nginx: + image: fedorov-xyz/enhanced-nginx:latest + ports: + - 80:80 + - 443:443 + volumes: + - nginx_data:/etc/nginx/data + - letsencrypt_data:/etc/letsencrypt + - ./example.com.conf:/sites/example.com.conf + environment: + - SITE_DOMAINS=example.com,staging.example.com + - CERT_NAME=example.com + - CERTBOT_EMAIL=your@email.com + - CERTBOT_TEST_CERT=true +``` + +## Environment variables list + +| Variable | Requirded | Description | +|----------------------|-----------|--------------------------------------------------------------| +| `SITE_DOMAINS` | yes | Сomma-separated list of domains for Let's Encrypt certificate | +| `CERT_NAME` | yes | Certificate name. | +| `CERTBOT_EMAIL` | yes | Email for Let's Encrypt. | +| `CERTBOT_TEST_CERT` | | Pass `true` for test certificates. | diff --git a/cron/crontab b/cron/crontab new file mode 100644 index 0000000..080254b --- /dev/null +++ b/cron/crontab @@ -0,0 +1,3 @@ +# Auto sync ip addresses of Cloudflare and reload nginx +# Every day at 8:30 +30 8 * * * /usr/local/bin/update_cloudflare_ips.sh restart_nginx >/dev/null 2>&1 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..b41e0b1 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,88 @@ +# Generated by nginxconfig.io +# See nginxconfig.txt for the configuration share link + +user nginx; +pid /var/run/nginx.pid; +worker_processes auto; +worker_rlimit_nofile 65535; + +# Load modules +include /etc/nginx/modules-enabled/*.conf; + +events { + multi_accept on; + worker_connections 65535; +} + +http { + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + log_not_found off; + types_hash_max_size 2048; + types_hash_bucket_size 64; + client_max_body_size 16M; + + # MIME + include mime.types; + default_type application/octet-stream; + + # Log Format + include nginxconfig.io/log_format.conf; + + # Logging + access_log off; + error_log /var/log/nginx/error.log warn; + + # SSL + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # Diffie-Hellman parameter for DHE ciphersuites + ssl_dhparam /etc/nginx/data/dhparam.pem; + + # Mozilla Intermediate configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + + # OCSP Stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 208.67.222.222 208.67.220.220 valid=60s; + resolver_timeout 2s; + + # Connection header for WebSocket reverse proxy + map $http_upgrade $connection_upgrade { + default upgrade; + "" close; + } + + map $remote_addr $proxy_forwarded_elem { + + # IPv4 addresses can be sent as-is + ~^[0-9.]+$ "for=$remote_addr"; + + # IPv6 addresses need to be bracketed and quoted + ~^[0-9A-Fa-f:.]+$ "for=\"[$remote_addr]\""; + + # Unix domain socket names cannot be represented in RFC 7239 syntax + default "for=unknown"; + } + + map $http_forwarded $proxy_add_forwarded { + + # If the incoming Forwarded header is syntactically valid, append to it + "~^(,[ \\t]*)*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*([ \\t]*,([ \\t]*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*)?)*$" "$http_forwarded, $proxy_forwarded_elem"; + + # Otherwise, replace it + default "$proxy_forwarded_elem"; + } + + # Load configs and sites + # include /etc/nginx/conf.d/*.conf; + include /etc/nginx/data/cloudflare.conf; + include /etc/nginx/sites.d/*.conf; +} diff --git a/nginx/nginxconfig.io/general.conf b/nginx/nginxconfig.io/general.conf new file mode 100644 index 0000000..84a3b58 --- /dev/null +++ b/nginx/nginxconfig.io/general.conf @@ -0,0 +1,9 @@ +# favicon.ico +location = /favicon.ico { + log_not_found off; +} + +# robots.txt +location = /robots.txt { + log_not_found off; +} diff --git a/nginx/nginxconfig.io/gzip.conf b/nginx/nginxconfig.io/gzip.conf new file mode 100644 index 0000000..d061996 --- /dev/null +++ b/nginx/nginxconfig.io/gzip.conf @@ -0,0 +1,6 @@ +# gzip +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; diff --git a/nginx/nginxconfig.io/log_format.conf b/nginx/nginxconfig.io/log_format.conf new file mode 100644 index 0000000..e548797 --- /dev/null +++ b/nginx/nginxconfig.io/log_format.conf @@ -0,0 +1 @@ +log_format cloudflare '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" $http_cf_ray $http_cf_connecting_ip $http_cf_ipcountry'; diff --git a/nginx/nginxconfig.io/proxy.conf b/nginx/nginxconfig.io/proxy.conf new file mode 100644 index 0000000..8b0d71e --- /dev/null +++ b/nginx/nginxconfig.io/proxy.conf @@ -0,0 +1,20 @@ +proxy_http_version 1.1; +proxy_cache_bypass $http_upgrade; + +# Proxy SSL +proxy_ssl_server_name on; + +# Proxy headers +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header Forwarded $proxy_add_forwarded; +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; + +# Proxy timeouts +proxy_connect_timeout 60s; +proxy_send_timeout 60s; +proxy_read_timeout 60s; diff --git a/nginx/nginxconfig.io/security.conf b/nginx/nginxconfig.io/security.conf new file mode 100644 index 0000000..fbfe726 --- /dev/null +++ b/nginx/nginxconfig.io/security.conf @@ -0,0 +1,12 @@ +# security headers +add_header X-XSS-Protection "1; mode=block" always; +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "no-referrer-when-downgrade" always; +add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; +add_header Permissions-Policy "interest-cohort=()" always; +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + +# . files +location ~ /\.(?!well-known) { + deny all; +} diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..c2918bd --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Static +DOCKER_DATA=/etc/nginx/data +CLOUDFLARE_FILE_PATH=/etc/nginx/data/cloudflare.conf +DHPARAMS_FILE_PATH=$DOCKER_DATA/dhparams.pem + +# External +: "${SITE_DOMAINS:?Must provide SITE_DOMAINS in environment}" +: "${CERT_NAME:?Must provide CERT_NAME in environment}" +: "${CERTBOT_EMAIL:?Must provide CERTBOT_EMAIL in environment}" + +mkdir -p $DOCKER_DATA; + +echo "Init nginx container" + +# Generate dhparams.pem +if [ ! -f $DHPARAMS_FILE_PATH ]; then + echo "Generate dhparams.pem" + openssl dhparam -out $DHPARAMS_FILE_PATH 2048 + chmod 600 $DHPARAMS_FILE_PATH +fi + +# Generate Cloudflare IP ranges +if [ ! -f $CLOUDFLARE_FILE_PATH ]; then + echo "Generate Cloudflare IP ranges config" + /usr/local/bin/update_cloudflare_ips.sh +fi + +# Applying replacements for site configs + +cp -r /sites/* /etc/nginx/sites.d/ + +declare -a replacements=( + "CERT_NAME" +) + +for env in "${replacements[@]}" +do + if [ -n "${!env}" ]; then + replacement=$(printf "%s" "${!env}" | sed 's/[,\/&]/\\&/g') # Escape special characters in the replacement value + for file in /etc/nginx/sites.d/*; do + sed -i "s,REPLACEMENT_$env,$replacement,g" "$file" || exit + done + echo " $env is updated in sites.d" + fi +done + +# Check cert exist +if [ ! -f /etc/letsencrypt/live/"$CERT_NAME"/fullchain.pem ]; then + echo "Certificate $CERT_NAME do not exists, will generate it now" +fi + +echo "Running certbot" + +certbot_args=( + certonly + --standalone + --renew-by-default + --non-interactive + --agree-tos + --cert-name "$CERT_NAME" + --email "$CERTBOT_EMAIL" + -d "$SITE_DOMAINS" +) + +if [ "$CERTBOT_TEST_CERT" = "true" ]; then + echo "Using staging (test) certificate" + certbot_args+=(--test-cert) +fi + +certbot "${certbot_args[@]}" || exit + +echo "Start nginx" +nginx -g "daemon off;" diff --git a/scripts/update_cloudflare_ips.sh b/scripts/update_cloudflare_ips.sh new file mode 100644 index 0000000..aa9cf34 --- /dev/null +++ b/scripts/update_cloudflare_ips.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +CLOUDFLARE_FILE_PATH=/etc/nginx/data/cloudflare.conf + +echo "# Cloudflare IP ranges" > $CLOUDFLARE_FILE_PATH; +echo "" >> $CLOUDFLARE_FILE_PATH; + +echo "# - IPv4" >> $CLOUDFLARE_FILE_PATH; +for i in $(curl -s -L https://www.cloudflare.com/ips-v4); do + echo "set_real_ip_from $i;" >> $CLOUDFLARE_FILE_PATH; +done + +echo "" >> $CLOUDFLARE_FILE_PATH; +echo "# - IPv6" >> $CLOUDFLARE_FILE_PATH; +for i in $(curl -s -L https://www.cloudflare.com/ips-v6); do + echo "set_real_ip_from $i;" >> $CLOUDFLARE_FILE_PATH; +done + +echo "" >> $CLOUDFLARE_FILE_PATH; +echo "real_ip_header CF-Connecting-IP;" >> $CLOUDFLARE_FILE_PATH; + +# Test configuration +nginx -t; + +if [ "$1" = "restart_nginx" ]; then + systemctl reload nginx; +fi