feat(security): optional SSL/TLS hardening with Cloudflare-aware configs and HTTP fallback

- Add secure nginx config with TLS 1.2+, HSTS preload, CSP, and security headers
- Add insecure HTTP fallback config for users who cannot or do not want HTTPS
- Make installer best-effort SSL: try Let's Encrypt, fall back to HTTP without failing
- Preserve secure/insecure SSL choice across updates via /etc/serverkit/ssl-mode
- Support SERVERKIT_SKIP_SSL=1 to disable HTTPS entirely
- Add CAA record to default DNS zone template
- Harden generated per-app nginx configs with HSTS preload, CSP, and security headers
- Align Flask security middleware headers with nginx edge config
This commit is contained in:
Juan Denis committed 2026-06-19 16:13:06 -04:00
1 parent 1f700cea06
commit 02afdec9f0
10 files changed
+440 -77

No files matched your search

+12 -5
View File
@@ -21,26 +21,32 @@ def register_security_headers(app: Flask):
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Content Security Policy
# In debug mode, allow inline styles/scripts for Vite dev tooling
# In debug mode, allow inline styles/scripts for Vite dev tooling.
# The production policy intentionally matches the nginx edge config so
# responses served directly by Flask are consistent with Cloudflare/nginx.
if app.debug:
csp_directives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' ws: wss: http://localhost:* http://127.0.0.1:*",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
else:
csp_directives = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' ws: wss:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
response.headers['Content-Security-Policy'] = '; '.join(csp_directives)
@@ -48,7 +54,8 @@ def register_security_headers(app: Flask):
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
# HSTS - only in production and on HTTPS
# 63072000 seconds = 2 years, matching the nginx edge config and preload requirements
if not app.debug:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload'
return response
+3
View File
@@ -19,6 +19,9 @@ class DNSZoneService:
{'record_type': 'A', 'name': '@', 'content': '{{server_ip}}', 'ttl': 3600},
{'record_type': 'A', 'name': 'www', 'content': '{{server_ip}}', 'ttl': 3600},
{'record_type': 'CNAME', 'name': 'mail', 'content': '{{domain}}', 'ttl': 3600},
# Authorize only Let's Encrypt (what ServerKit issues with) to mint
# certs for this domain. Satisfies CAA scanners and blocks rogue CAs.
{'record_type': 'CAA', 'name': '@', 'content': '0 issue "letsencrypt.org"', 'ttl': 3600},
],
},
'email-hosting': {
+14 -1
View File
@@ -196,8 +196,21 @@ class NginxService:
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;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:secp384r1;
add_header Strict-Transport-Security "max-age=63072000" always;
# HSTS: 2 years, all subdomains, preload-eligible. Submit the domain at
# https://hstspreload.org to get onto the browser preload list (this is what
# protects first-time visitors from an initial-request MITM downgrade).
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Baseline response-header hardening. These are safe for reverse-proxied apps;
# the CSP is deliberately permissive so it does not break managed apps while
# still satisfying "a CSP is present" and blocking cross-origin framing.
# Tighten the CSP per-app when the upstream can support a stricter policy.
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 Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: https:; frame-ancestors 'self'; upgrade-insecure-requests" always;
'''
SSL_REDIRECT_TEMPLATE = '''server {{
+95 -56
View File
@@ -9,6 +9,7 @@
#
# INSTALL_FROM_RELEASE=1 fetch a pre-built tarball instead of compiling
# BUILD_FROM_SOURCE=1 force a source build even when a release exists
# SERVERKIT_SKIP_SSL=1 run on plain HTTP (no HTTPS / no certbot attempt)
#
# The Flask backend runs straight on the host (it needs real system access);
# the React frontend is built to static files and served by the host nginx.
@@ -38,6 +39,7 @@ CHANNEL="${CHANNEL:-Stable}"
PANEL_DOMAIN="${PANEL_DOMAIN:-}"
PANEL_PORT="${PANEL_PORT:-80}"
SERVERKIT_SKIP_SSL="${SERVERKIT_SKIP_SSL:-0}"
# Runtime state populated as we go (declared up-front for `set -u`).
OS_FAMILY="unknown"
@@ -45,6 +47,7 @@ ARCH=""
DL_ARCH=""
PKG_MGR=""
SAFE_MODE=false
SSL_MODE="insecure"
# ---------------------------------------------------------------------------
# Terminal styling
@@ -510,9 +513,16 @@ write_config() {
# Widen CORS / advertise a public URL when a panel domain is known.
local public_url="" cors_origins="http://localhost,https://localhost"
local url_scheme="http"
if [ -n "$PANEL_DOMAIN" ]; then
public_url="http://$PANEL_DOMAIN"
cors_origins="http://localhost,https://localhost,http://$PANEL_DOMAIN,https://$PANEL_DOMAIN"
if [ "$SSL_MODE" = "secure" ]; then
url_scheme="https"
cors_origins="http://localhost,https://localhost,https://$PANEL_DOMAIN"
else
url_scheme="http"
cors_origins="http://localhost,https://localhost,http://$PANEL_DOMAIN"
fi
public_url="${url_scheme}://$PANEL_DOMAIN"
fi
cat > "$INSTALL_DIR/.env" <<EOF
@@ -553,7 +563,7 @@ server {
listen 80 default_server;
server_name _;
if (\$host ~* ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+\$) {
return 301 http://$PANEL_DOMAIN\$request_uri;
return 301 ${url_scheme}://$PANEL_DOMAIN\$request_uri;
}
}
EOF
@@ -561,25 +571,6 @@ EOF
fi
}
# ---------------------------------------------------------------------------
# Self-signed certificate placeholder
# ---------------------------------------------------------------------------
seed_self_signed_cert() {
phase "SSL Certificate"
if [ -f "$INSTALL_DIR/nginx/ssl/fullchain.pem" ]; then
good "SSL certificate already present."
return
fi
step "Creating a self-signed certificate..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$INSTALL_DIR/nginx/ssl/privkey.pem" \
-out "$INSTALL_DIR/nginx/ssl/fullchain.pem" \
-subj "/CN=localhost" 2>/dev/null
warn "Self-signed cert in place — swap in a real certificate for production."
}
# ---------------------------------------------------------------------------
# Frontend build (skipped on release installs)
# ---------------------------------------------------------------------------
@@ -618,7 +609,7 @@ configure_nginx() {
systemctl stop nginx 2>/dev/null || true
rm -f /etc/nginx/sites-enabled/default
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/serverkit-conf.d
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled /etc/nginx/serverkit-conf.d /var/www/certbot
# Fedora/RHEL nginx.conf doesn't include sites-enabled by default.
if ! grep -q "sites-enabled" /etc/nginx/nginx.conf; then
@@ -637,15 +628,78 @@ configure_nginx() {
done
cp "$INSTALL_DIR/nginx/sites-available/serverkit.conf" /etc/nginx/sites-available/
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/
cp "$INSTALL_DIR/nginx/sites-available/serverkit-insecure.conf" /etc/nginx/sites-available/
cp "$INSTALL_DIR/nginx/sites-available/example.conf.template" /etc/nginx/sites-available/
# Decide whether we can use HTTPS. SSL is best-effort: if certbot fails or
# no domain is given, we fall back to plain HTTP rather than forcing the
# user to fix DNS/certs before they can use ServerKit.
choose_ssl_mode
install_nginx_config_for_mode
# SELinux: let nginx make upstream connections to the app containers.
if { [ "$OS_FAMILY" = "fedora" ] || [ "$OS_FAMILY" = "rhel" ]; } && command -v setsebool &>/dev/null; then
setsebool -P httpd_can_network_connect 1 2>/dev/null || true
fi
good "Nginx configured."
good "Nginx configured ($SSL_MODE)."
}
# ---------------------------------------------------------------------------
# Choose secure or insecure mode. Never block the install because of SSL.
# ---------------------------------------------------------------------------
choose_ssl_mode() {
if [ "$SERVERKIT_SKIP_SSL" = "1" ]; then
SSL_MODE="insecure"
warn "SERVERKIT_SKIP_SSL=1 — using plain HTTP."
return
fi
if [ -z "$PANEL_DOMAIN" ]; then
SSL_MODE="insecure"
warn "No panel domain provided — using plain HTTP (access by IP)."
warn "Set PANEL_DOMAIN and re-run the installer to try HTTPS automatically."
return
fi
if ! command -v certbot &>/dev/null; then
SSL_MODE="insecure"
warn "Certbot not installed — cannot request SSL automatically."
warn "Continuing with plain HTTP. Install certbot and run: certbot --nginx -d $PANEL_DOMAIN"
return
fi
# Temporarily use the insecure config so certbot's webroot challenge can
# answer on port 80.
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
step "Attempting Let's Encrypt certificate for $PANEL_DOMAIN..."
if systemctl start nginx 2>/dev/null && \
certbot certonly --webroot -w /var/www/certbot -d "$PANEL_DOMAIN" \
--non-interactive --agree-tos --register-unsafely-without-email 2>/dev/null; then
SSL_MODE="secure"
good "Let's Encrypt certificate issued for $PANEL_DOMAIN."
else
SSL_MODE="insecure"
warn "Could not obtain SSL certificate for $PANEL_DOMAIN."
warn "Common causes: DNS not pointing to this server, port 80 not reachable, or rate limits."
warn "Continuing with plain HTTP. Retry later with: certbot --nginx -d $PANEL_DOMAIN"
fi
systemctl stop nginx 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Install the nginx config matching the chosen SSL mode.
# ---------------------------------------------------------------------------
install_nginx_config_for_mode() {
if [ "$SSL_MODE" = "secure" ]; then
sed -i "s|/etc/letsencrypt/live/YOUR_DOMAIN/|/etc/letsencrypt/live/$PANEL_DOMAIN/|g" \
/etc/nginx/sites-available/serverkit.conf
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/serverkit.conf
else
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
fi
mkdir -p /etc/serverkit
printf '%s\n' "$SSL_MODE" > /etc/serverkit/ssl-mode
}
# ---------------------------------------------------------------------------
@@ -744,31 +798,6 @@ await_health() {
return 1
}
# ---------------------------------------------------------------------------
# Let's Encrypt for the panel domain
# ---------------------------------------------------------------------------
issue_panel_cert() {
if [ -z "$PANEL_DOMAIN" ]; then
good "No domain set — skipping SSL (panel reachable by IP)."
return
fi
phase "Panel SSL Certificate"
if ! command -v certbot &>/dev/null; then
warn "Certbot is not installed — skipping SSL."
return
fi
step "Requesting a Let's Encrypt certificate for $PANEL_DOMAIN..."
if certbot --nginx -d "$PANEL_DOMAIN" --non-interactive --agree-tos \
--register-unsafely-without-email --redirect 2>/dev/null; then
good "Certificate issued for $PANEL_DOMAIN"
else
warn "Certbot failed — retry manually: certbot --nginx -d $PANEL_DOMAIN"
fi
}
# ---------------------------------------------------------------------------
# Safety copy of an existing install before we touch it
# ---------------------------------------------------------------------------
@@ -794,11 +823,22 @@ print_outro() {
printf ' %s%s%s\n\n' "$V4" "──────────────────────────────────────" "$RST"
if [ -n "$PANEL_DOMAIN" ]; then
printf ' %sPanel URL%s https://%s\n' "$BLD" "$RST" "$PANEL_DOMAIN"
if [ "$SSL_MODE" = "secure" ]; then
printf ' %sPanel URL%s https://%s\n' "$BLD" "$RST" "$PANEL_DOMAIN"
else
printf ' %sPanel URL%s http://%s\n' "$BLD" "$RST" "$PANEL_DOMAIN"
fi
else
printf ' %sPanel URL%s http://%s\n' "$BLD" "$RST" "$ip"
fi
printf ' %sFirst step%s create an admin user\n\n' "$BLD" "$RST"
if [ "$SSL_MODE" != "secure" ]; then
printf '\n %sWARNING%s Running without HTTPS. Passwords and tokens will be\n' "$BLD" "$RST"
printf ' transmitted unencrypted. Set PANEL_DOMAIN and run\n'
printf ' certbot, or set SERVERKIT_SKIP_SSL=1 to suppress this warning.\n'
fi
printf '\n %sFirst step%s create an admin user\n\n' "$BLD" "$RST"
printf ' %sCLI%s serverkit status\n' "$BLD" "$RST"
printf ' serverkit create-admin\n'
@@ -831,8 +871,9 @@ prompt_for_domain() {
[ -z "$PANEL_DOMAIN" ] && [ -t 0 ] || return 0
printf '\n'
printf '%sEnter your panel domain (e.g. panel.example.com)%s\n' "$BLD" "$RST"
printf 'Leave blank to access via IP\n'
printf 'Leave blank to access via IP (no SSL attempt)\n'
printf '%sTip:%s set PANEL_DOMAIN=... in the environment to skip this prompt\n' "$BLD" "$RST"
printf ' set SERVERKIT_SKIP_SSL=1 to disable HTTPS entirely\n'
printf '> '
read -r PANEL_DOMAIN
PANEL_DOMAIN=$(printf '%s' "$PANEL_DOMAIN" | tr -d ' ')
@@ -883,17 +924,15 @@ main() {
fi
fi
write_config
seed_self_signed_cert
sync_templates
configure_nginx
write_config
install_service
# Tolerate failures through start + health so rollback can run.
set +e
launch_services
await_health || revert_install
issue_panel_cert
set -e
ping_telemetry
+14 -1
View File
@@ -1,4 +1,11 @@
# ServerKit Production Nginx Configuration
#
# This is the reference all-in-one nginx config. It assumes TLS terminates at
# this server. If you use Cloudflare in front, prefer the split design in
# nginx/sites-available/serverkit.conf, which supports Cloudflare Origin CA
# certificates and documents the Cloudflare dashboard settings that make the
# end-user experience painless (Always Use HTTPS, HSTS, Minimum TLS 1.2).
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
@@ -76,6 +83,7 @@ http {
server_name _;
# SSL Configuration
# For Cloudflare Origin CA, replace these paths with your origin cert/key.
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_session_timeout 1d;
@@ -87,6 +95,11 @@ http {
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# OCSP stapling (optional)
# ssl_stapling on;
# ssl_stapling_verify on;
# ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
@@ -98,7 +111,7 @@ http {
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Connection limits
limit_conn conn_limit 20;
@@ -7,11 +7,59 @@
# 3. Enable: sudo ln -s /etc/nginx/sites-available/mysite.conf /etc/nginx/sites-enabled/
# 4. Test: sudo nginx -t
# 5. Reload: sudo systemctl reload nginx
#
# This template assumes Cloudflare OR standalone Let's Encrypt. Pick the
# certificate option below that matches your setup.
server {
listen 80;
server_name example.builditdesign.com; # Change this to your domain
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name example.builditdesign.com; # Change this to your domain
# Pick ONE certificate option:
# Option A: Let's Encrypt
ssl_certificate /etc/letsencrypt/live/example.builditdesign.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.builditdesign.com/privkey.pem;
# Option B: Cloudflare Origin CA (recommended with Cloudflare proxy + Full (strict))
# ssl_certificate /etc/nginx/ssl/cloudflare-origin-example.pem;
# ssl_certificate_key /etc/nginx/ssl/cloudflare-origin-example.key;
# Modern TLS
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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# OCSP stapling (optional; uncomment if using Let's Encrypt and set chain path)
# ssl_stapling on;
# ssl_stapling_verify on;
# ssl_trusted_certificate /etc/letsencrypt/live/example.builditdesign.com/chain.pem;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# Option 1: Proxy to a local app (Docker container, Node.js, etc.)
location / {
proxy_pass http://127.0.0.1:3000; # Change port to your app's port
@@ -20,6 +68,7 @@ server {
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 Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
@@ -43,4 +92,13 @@ server {
# fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# include fastcgi_params;
# }
# Deny hidden/sensitive files
location ~ /\. {
deny all;
}
location ~* \.(env|git|gitignore|dockerignore)$ {
deny all;
}
}
@@ -0,0 +1,78 @@
# ServerKit - Insecure HTTP fallback configuration
# /etc/nginx/sites-available/serverkit-insecure.conf
#
# ⚠️ WARNING: This configuration does NOT use SSL/TLS. All traffic between
# browsers and this server is unencrypted. Passwords, tokens, and any
# data sent to the panel or hosted apps can be read by anyone on the
# network path.
#
# This mode exists only as a convenience for local networks, temporary
# testing, or situations where you cannot obtain certificates. It is
# strongly recommended to switch to the secure config as soon as possible.
#
# To switch to HTTPS later:
# 1. Obtain certificates (Let's Encrypt or Cloudflare Origin CA).
# 2. Replace this file with serverkit.conf:
# sudo cp /opt/serverkit/nginx/sites-available/serverkit.conf \
# /etc/nginx/sites-available/serverkit.conf
# 3. Update the certificate paths inside that file.
# 4. sudo nginx -t && sudo systemctl reload nginx
server {
listen 80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://127.0.0.1:3847;
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;
}
location /api {
proxy_pass http://127.0.0.1:5000;
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_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location /socket.io {
proxy_pass http://127.0.0.1:5000;
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 $host;
proxy_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
}
include /etc/nginx/serverkit-locations/*.conf;
location ~ /\. {
deny all;
}
location ~* \.(env|git|gitignore|dockerignore)$ {
deny all;
}
}
include /etc/nginx/serverkit-conf.d/*.conf;
+117 -4
View File
@@ -2,7 +2,41 @@
# /etc/nginx/sites-available/serverkit.conf
#
# This is the default server block for ServerKit.
# Update server_name to your domain, or leave as _ for catch-all.
# It works both WITH and WITHOUT Cloudflare in front of the server.
#
# ═══════════════════════════════════════════════════════════════════════════════
# CLOUDFLARE USERS (recommended for zero-downtime certs + DDoS protection)
# ═══════════════════════════════════════════════════════════════════════════════
# 1. In Cloudflare dashboard → SSL/TLS → Overview:
# Set Encryption mode to "Full (strict)". NEVER use "Flexible".
# 2. Create an Origin CA certificate:
# SSL/TLS → Origin Server → Create Certificate → choose RSA.
# 3. Save the files on this server, for example:
# /etc/nginx/ssl/cloudflare-origin.pem (certificate)
# /etc/nginx/ssl/cloudflare-origin.key (private key)
# 4. Uncomment the Cloudflare Origin CA paths below.
# 5. Recommended Cloudflare dashboard toggles for painless UX:
# - SSL/TLS → Edge Certificates → Always Use HTTPS: ON
# - SSL/TLS → Edge Certificates → Automatic HTTPS Rewrites: ON
# - SSL/TLS → Edge Certificates → HSTS: ON
# (max-age 63072000, Include subdomains: ON, Preload: ON, No-Sniff: ON)
# - SSL/TLS → Edge Certificates → Minimum TLS Version: TLS 1.2
# 6. Add a CAA DNS record allowing Cloudflare's certificate authorities, e.g.:
# example.com. CAA 0 issue "letsencrypt.org"
# example.com. CAA 0 issue "pki.goog"
# example.com. CAA 0 issue "digicert.com"
# example.com. CAA 0 issuewild "letsencrypt.org"
# example.com. CAA 0 iodef "mailto:security@example.com"
#
# ═══════════════════════════════════════════════════════════════════════════════
# STANDALONE USERS (no Cloudflare)
# ═══════════════════════════════════════════════════════════════════════════════
# 1. Obtain Let's Encrypt certificates, e.g.:
# certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com
# 2. Set the Let's Encrypt paths below.
# 3. Add a CAA record for your chosen CA, e.g.:
# example.com. CAA 0 issue "letsencrypt.org"
# example.com. CAA 0 issuewild "letsencrypt.org"
#
# Optional: place a canonical-domain redirect block in
# /etc/nginx/serverkit-canonical-domain.conf (managed by install.sh / settings UI).
@@ -11,10 +45,71 @@ server {
listen 80 default_server;
server_name _;
# Redirect to HTTPS (handled by Cloudflare in Flexible mode)
# If using Full SSL mode, uncomment the redirect below
# return 301 https://$server_name$request_uri;
# Allow Let's Encrypt validation over plain HTTP
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2 default_server;
server_name _;
# ═══════════════════════════════════════════════════════════════════════════
# TLS certificates - pick ONE option and comment the other out
# ═══════════════════════════════════════════════════════════════════════════
# Option A: Let's Encrypt (standalone mode)
ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
# Option B: Cloudflare Origin CA (use with Cloudflare proxy + Full (strict))
# ssl_certificate /etc/nginx/ssl/cloudflare-origin.pem;
# ssl_certificate_key /etc/nginx/ssl/cloudflare-origin.key;
# ═══════════════════════════════════════════════════════════════════════════
# Modern TLS 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;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# OCSP stapling (optional; improves handshake privacy/performance)
# For Let's Encrypt, uncomment all three lines and set the chain path.
# Not applicable to Cloudflare Origin CA certificates.
# ssl_stapling on;
# ssl_stapling_verify on;
# ssl_trusted_certificate /etc/letsencrypt/live/YOUR_DOMAIN/chain.pem;
# ═══════════════════════════════════════════════════════════════════════════
# Security headers
# ═══════════════════════════════════════════════════════════════════════════
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content Security Policy
# Note: 'unsafe-inline'/'unsafe-eval' are kept because many SPAs need them.
# Once your build is verified to work without inline scripts, remove them.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
# ═══════════════════════════════════════════════════════════════════════════
# Proxy to ServerKit frontend
# NOTE: If your frontend container runs on a different port, change 3847 below.
# The reference nginx/nginx.conf uses 8080; this file preserves the legacy 3847
# port to avoid breaking existing ServerKit installations.
# ═══════════════════════════════════════════════════════════════════════════
location / {
proxy_pass http://127.0.0.1:3847;
proxy_http_version 1.1;
@@ -22,8 +117,12 @@ server {
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 to ServerKit backend API
# ═══════════════════════════════════════════════════════════════════════════
location /api {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
@@ -31,11 +130,15 @@ server {
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_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# ═══════════════════════════════════════════════════════════════════════════
# WebSocket support for Socket.IO
# ═══════════════════════════════════════════════════════════════════════════
location /socket.io {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
@@ -45,6 +148,7 @@ server {
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_connect_timeout 7d;
proxy_send_timeout 7d;
proxy_read_timeout 7d;
@@ -53,6 +157,15 @@ server {
# Include app location blocks (WordPress, Gitea, private URLs, etc.)
# These are location-only snippets, NOT separate server blocks.
include /etc/nginx/serverkit-locations/*.conf;
# Deny access to hidden/sensitive files
location ~ /\. {
deny all;
}
location ~* \.(env|git|gitignore|dockerignore)$ {
deny all;
}
}
# Optional canonical-domain redirect block. Generated by install.sh when a
+25 -10
View File
@@ -62,19 +62,30 @@ if ! grep -q "sites-enabled" /etc/nginx/nginx.conf; then
sed -i '/^http {/a \ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf
fi
# Copy ServerKit site config
# Copy ServerKit site configs
SERVERKIT_DIR="/opt/serverkit"
if [ -f "$SERVERKIT_DIR/nginx/sites-available/serverkit.conf" ]; then
print_info "Installing ServerKit nginx config..."
cp "$SERVERKIT_DIR/nginx/sites-available/serverkit.conf" /etc/nginx/sites-available/
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/
print_success "ServerKit config installed"
SSL_MODE="insecure"
if [ -f /etc/serverkit/ssl-mode ]; then
SSL_MODE=$(cat /etc/serverkit/ssl-mode)
fi
if [ "${SERVERKIT_SKIP_SSL:-0}" = "1" ]; then
SSL_MODE="insecure"
fi
# Copy template
if [ -f "$SERVERKIT_DIR/nginx/sites-available/example.conf.template" ]; then
if [ -f "$SERVERKIT_DIR/nginx/sites-available/serverkit.conf" ]; then
print_info "Installing ServerKit nginx configs..."
cp "$SERVERKIT_DIR/nginx/sites-available/serverkit.conf" /etc/nginx/sites-available/
cp "$SERVERKIT_DIR/nginx/sites-available/serverkit-insecure.conf" /etc/nginx/sites-available/
cp "$SERVERKIT_DIR/nginx/sites-available/example.conf.template" /etc/nginx/sites-available/
print_success "Template copied to /etc/nginx/sites-available/"
if [ "$SSL_MODE" = "secure" ]; then
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/serverkit.conf
print_success "ServerKit HTTPS config installed"
else
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
print_warning "ServerKit HTTP (insecure) config installed"
print_warning "Set SERVERKIT_SKIP_SSL=0 and run certbot to enable HTTPS later"
fi
fi
# Update ServerKit docker-compose to use port 8080
@@ -104,7 +115,11 @@ echo -e "${GREEN} Setup Complete!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Architecture:"
echo " • Host nginx (ports 80/443) - routes domains to apps"
if [ "$SSL_MODE" = "secure" ]; then
echo " • Host nginx (ports 80/443) - routes domains to apps"
else
echo " • Host nginx (port 80) - routes domains to apps (HTTPS not enabled)"
fi
echo " • ServerKit frontend (port 8080) - UI"
echo " • ServerKit backend (port 5000) - API"
echo ""
+24
View File
@@ -252,6 +252,30 @@ phase "Refreshing Configuration"
if [ -f "$INSTALL_DIR/nginx/sites-available/serverkit.conf" ]; then
cp "$INSTALL_DIR/nginx/sites-available/serverkit.conf" /etc/nginx/sites-available/
fi
if [ -f "$INSTALL_DIR/nginx/sites-available/serverkit-insecure.conf" ]; then
cp "$INSTALL_DIR/nginx/sites-available/serverkit-insecure.conf" /etc/nginx/sites-available/
fi
# Preserve the user's secure/insecure SSL choice across updates.
SSL_MODE="insecure"
if [ -f /etc/serverkit/ssl-mode ]; then
SSL_MODE=$(cat /etc/serverkit/ssl-mode)
fi
if [ "$SSL_MODE" = "secure" ] && [ -f /etc/nginx/sites-available/serverkit.conf ]; then
# Re-apply the Let's Encrypt path if we know the panel domain.
PANEL_DOMAIN="${PANEL_DOMAIN:-}"
if [ -z "$PANEL_DOMAIN" ] && [ -f "$INSTALL_DIR/.env" ]; then
PANEL_DOMAIN=$(grep -E '^SERVERKIT_PUBLIC_URL=' "$INSTALL_DIR/.env" 2>/dev/null | \
sed 's|^SERVERKIT_PUBLIC_URL=||; s|^https\?://||; s|/.*||' || true)
fi
if [ -n "$PANEL_DOMAIN" ] && [ -d "/etc/letsencrypt/live/$PANEL_DOMAIN" ]; then
sed -i "s|/etc/letsencrypt/live/YOUR_DOMAIN/|/etc/letsencrypt/live/$PANEL_DOMAIN/|g" \
/etc/nginx/sites-available/serverkit.conf
fi
ln -sf /etc/nginx/sites-available/serverkit.conf /etc/nginx/sites-enabled/serverkit.conf
else
ln -sf /etc/nginx/sites-available/serverkit-insecure.conf /etc/nginx/sites-enabled/serverkit.conf
fi
mkdir -p /etc/nginx/serverkit-locations
for conf in /etc/nginx/sites-enabled/*; do