From a60d847b603bea99c333ebda6c5629bca56d1747 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:39:06 +0200 Subject: [PATCH 01/14] feat(nginx): TLS 1.3-only default config and build hardening --- nginx/nginx_installer.sh | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 96a5266..061d44d 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -547,6 +547,7 @@ Build-Nginx() { --with-stream_realip_module --with-file-aio --with-threads + --with-cc-opt='-O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIC' --modules-path="${NGINX_MODULES_PATH}" ) [[ $SKIP_HEADERS_MORE == true ]] || configure_args+=(--add-dynamic-module="$BUILD_DIR/headers-more") @@ -717,11 +718,13 @@ EOF user nginx; worker_processes auto; +worker_rlimit_nofile 65535; error_log /var/log/nginx/error.log warn; pid /run/nginx.pid; events { worker_connections 1024; + multi_accept on; } http { @@ -733,7 +736,7 @@ EOF if [[ $SKIP_HEADERS_MORE != true ]]; then cat >> /etc/nginx/nginx.conf <<'EOF' - more_set_headers 'Server: nginx'; + more_clear_headers 'Server'; EOF fi @@ -752,6 +755,9 @@ EOF types_hash_max_size 4096; types_hash_bucket_size 128; + # nginx defaults to 1m, which breaks uploads (e.g. *arr imports) + client_max_body_size 16m; + # Gzip compression gzip on; gzip_vary on; @@ -774,12 +780,11 @@ EOF cat >> /etc/nginx/nginx.conf <<'EOF' - # SSL/TLS configuration - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers off; - # TLS 1.2 ciphers — ECDSA-only (matches the ECDSA certificate generated below). - # TLS 1.3 ciphers are built-in and always secure; no need to list them. - ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256; + # SSL/TLS configuration — TLS 1.3 only. + # TLS 1.3 suites are AEAD-only and secure by default; ssl_ciphers only + # applies to TLS 1.2 and below, which are refused here (clients older + # than ~2018 cannot connect). + ssl_protocols TLSv1.3; ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; @@ -791,6 +796,14 @@ EOF # 0-RTT disabled: no replay attack protection configured at application layer ssl_early_data off; + # Refuse handshakes for names we do not host, instead of serving the + # certificate to scanners and SNI probes hitting the bare IP address. + server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + ssl_reject_handshake on; + } + server { listen 80; listen [::]:80; From 57e637b732b381d3d3ea75ba2d8a735266cd8a53 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:43:20 +0200 Subject: [PATCH 02/14] feat(nginx): X25519-only groups, kTLS offload, drop deprecated X-Frame-Options --- nginx/nginx_installer.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 061d44d..64cbde0 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -785,7 +785,12 @@ EOF # applies to TLS 1.2 and below, which are refused here (clients older # than ~2018 cannot connect). ssl_protocols TLSv1.3; - ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1; + # X25519-only groups: every TLS 1.3 client supports plain X25519, so the + # legacy prime256v1/secp384r1 entries are never negotiated — drop them. + ssl_ecdh_curve X25519MLKEM768:X25519; + # Kernel TLS offload: keeps sendfile working under TLS (OpenSSL 3.x + # built with kTLS + kernel 5.2+). Remove this line if nginx -t rejects it. + ssl_conf_command Options KTLS; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; @@ -829,7 +834,6 @@ EOF add_header X-Protocol $server_protocol always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; From 452d0d67ab1df331ac99b41d375427497e8ab925 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:45:05 +0200 Subject: [PATCH 03/14] chore(nginx): align stale comments with actual behavior (system OpenSSL, preserved config) --- nginx/nginx_installer.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 64cbde0..efb4fc5 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -6,8 +6,9 @@ set -euo pipefail # ============================================================================ # # Description: -# Builds and installs NGINX with OpenSSL, HTTP/3, zstd compression, -# and ACME support on Linux. +# Builds and installs NGINX from source against the system OpenSSL, +# with HTTP/3, zstd compression, ACME support, and a hardened +# TLS 1.3-only configuration. # # Usage: # ./nginx_installer.sh install [options] - Build and install NGINX @@ -459,7 +460,7 @@ Get-Sources() { Write-Log INFO "Extracting archives" # Clean previous extractions - rm -rf nginx openssl pcre2 zlib headers-more zstd-module nginx-acme 2>/dev/null || true + rm -rf nginx pcre2 zlib headers-more zstd-module nginx-acme 2>/dev/null || true tar xzf nginx.tgz && mv "nginx-${NGINX_VERSION}" nginx tar xzf pcre2.tgz && mv "pcre2-${PCRE2_VERSION}" pcre2 @@ -1165,7 +1166,7 @@ case "$COMMAND" in "Install compilers and build dependencies" \ "Download and verify the NGINX sources and modules" \ "Compile NGINX, which takes several minutes" \ - "Install it and replace an existing NGINX configuration" + "Install it, preserving an existing NGINX configuration" Update-SystemPackages Test-RunningWebServers Install-Dependencies From 4f35a24141198060f1b72dce59b719a6b9d90707 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:57:07 +0200 Subject: [PATCH 04/14] fix(nginx): reject block owns the QUIC listeners for HTTP/3 default rejection --- nginx/nginx_installer.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index efb4fc5..b57185b 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -804,9 +804,16 @@ EOF # Refuse handshakes for names we do not host, instead of serving the # certificate to scanners and SNI probes hitting the bare IP address. + # This block must also own the QUIC listeners: a server without a quic + # listen directive never takes part in HTTP/3 SNI selection, so an + # unknown-SNI QUIC client would fall back to the first server that has + # one and still receive its certificate. reuseport may appear only once + # per address:port, so the real server below uses plain quic listeners. server { listen 443 ssl default_server; listen [::]:443 ssl default_server; + listen 443 quic reuseport; + listen [::]:443 quic reuseport; ssl_reject_handshake on; } @@ -820,8 +827,9 @@ EOF server { listen 443 ssl; listen [::]:443 ssl; - listen 443 quic reuseport; - listen [::]:443 quic reuseport; + # reuseport lives on the reject block above (only once per address:port) + listen 443 quic; + listen [::]:443 quic; http2 on; http3 on; From dbba457426038f0540280e6333dd6fded43596dd Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:25:29 +0000 Subject: [PATCH 05/14] fix(nginx): restore NIST groups, pin QUIC default server, fix build hardening - ssl_ecdh_curve: keep prime256v1/secp384r1 after X25519. RFC 8446 makes secp256r1 the mandatory group; P-256-only clients (FIPS mode, embedded stacks) failed the handshake with the X25519-only list. - Mark the reject block's quic listeners default_server explicitly. Without it the QUIC default depended on block order, and a quic server added above it served its certificate to unknown-SNI HTTP/3 clients again. - Drop ssl_conf_command Options KTLS: it is a performance knob, not hardening, and without the tls kernel module it silently does nothing (nginx -t never rejects it, contrary to the comment). - Build flags: -U before -D_FORTIFY_SOURCE=3 so Ubuntu's default level 3 is not lowered to 2; drop the redundant -fPIC; add -fstack-clash-protection and full RELRO via --with-ld-opt. --- nginx/nginx_installer.sh | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index b57185b..192039a 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -548,7 +548,12 @@ Build-Nginx() { --with-stream_realip_module --with-file-aio --with-threads - --with-cc-opt='-O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIC' + # Hardening flags. -U first: Ubuntu's gcc already defines + # _FORTIFY_SOURCE=3, and a bare -D=2 would lower it. glibc falls back + # to level 2 by itself on toolchains that do not support 3. PIE is + # already the default on every supported distro; full RELRO is not. + --with-cc-opt='-O2 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection' + --with-ld-opt='-Wl,-z,relro -Wl,-z,now' --modules-path="${NGINX_MODULES_PATH}" ) [[ $SKIP_HEADERS_MORE == true ]] || configure_args+=(--add-dynamic-module="$BUILD_DIR/headers-more") @@ -786,12 +791,10 @@ EOF # applies to TLS 1.2 and below, which are refused here (clients older # than ~2018 cannot connect). ssl_protocols TLSv1.3; - # X25519-only groups: every TLS 1.3 client supports plain X25519, so the - # legacy prime256v1/secp384r1 entries are never negotiated — drop them. - ssl_ecdh_curve X25519MLKEM768:X25519; - # Kernel TLS offload: keeps sendfile working under TLS (OpenSSL 3.x - # built with kTLS + kernel 5.2+). Remove this line if nginx -t rejects it. - ssl_conf_command Options KTLS; + # Hybrid post-quantum group first, then X25519. Keep the NIST curves: + # RFC 8446 makes secp256r1 the mandatory group (X25519 is only a SHOULD), + # and FIPS-mode or embedded clients may offer nothing else. + ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; @@ -807,13 +810,15 @@ EOF # This block must also own the QUIC listeners: a server without a quic # listen directive never takes part in HTTP/3 SNI selection, so an # unknown-SNI QUIC client would fall back to the first server that has - # one and still receive its certificate. reuseport may appear only once - # per address:port, so the real server below uses plain quic listeners. + # one and still receive its certificate. default_server is explicit on + # the quic listeners too, so a server block added above this one cannot + # silently take over. reuseport may appear only once per address:port, + # so the real server below uses plain quic listeners. server { listen 443 ssl default_server; listen [::]:443 ssl default_server; - listen 443 quic reuseport; - listen [::]:443 quic reuseport; + listen 443 quic reuseport default_server; + listen [::]:443 quic reuseport default_server; ssl_reject_handshake on; } From 42954b00f8a62e5099269d71479b86a6818a0b29 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:13:57 +0000 Subject: [PATCH 06/14] feat(nginx): refuse systems with OpenSSL older than 3.5 The generated config offers X25519MLKEM768, which OpenSSL only knows from 3.5 on; on older versions nginx -t failed at the very end of the install. Check the system OpenSSL up front (before any package upgrade) and again after the dependencies are installed, and stop with a clear "too old" message instead. openssl is now an explicit apt/dnf dependency, since the check and the self-signed certificate both need the CLI. --- nginx/README.md | 4 +++- nginx/nginx_installer.sh | 30 ++++++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/nginx/README.md b/nginx/README.md index 735eeb3..3649845 100644 --- a/nginx/README.md +++ b/nginx/README.md @@ -1,6 +1,8 @@ # NGINX Installer -Builds NGINX from source with hardened defaults: OpenSSL 3.x, HTTP/2, HTTP/3 (QUIC), zstd compression, headers-more, and ACME module. +Builds NGINX from source with hardened defaults: TLS 1.3 only, post-quantum key exchange (X25519MLKEM768), HTTP/2, HTTP/3 (QUIC), zstd compression, headers-more, and ACME module. + +Requires the system OpenSSL to be 3.5 or newer (Debian 13, Ubuntu 26.04, current Fedora and Arch). On older systems the installer stops before changing anything. ```bash sudo ./nginx_installer.sh install diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 192039a..6904c4f 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -6,8 +6,8 @@ set -euo pipefail # ============================================================================ # # Description: -# Builds and installs NGINX from source against the system OpenSSL, -# with HTTP/3, zstd compression, ACME support, and a hardened +# Builds and installs NGINX from source against the system OpenSSL +# (3.5 or newer), with HTTP/3, zstd compression, ACME support, and a hardened # TLS 1.3-only configuration. # # Usage: @@ -356,6 +356,22 @@ Install-Rustup() { return 0 } +# NGINX is built against the system OpenSSL, and the generated config offers +# the hybrid post-quantum group X25519MLKEM768, which OpenSSL only knows from +# 3.5 on. Older versions make nginx -t fail, so refuse them up front. +Test-OpenSsl() { + local name="" version="" major minor + read -r name version _ < <(openssl version 2>/dev/null) || true + [[ $name == OpenSSL && -n $version ]] || Stop-Script "OpenSSL not found; install the openssl package." + IFS=. read -r major minor _ <<< "$version" + if (( major < 3 || (major == 3 && minor < 5) )); then + Write-Log ERROR "This system is too old: OpenSSL ${version} found, 3.5 or newer required." + Write-Log INFO "OpenSSL 3.5 ships with Debian 13, Ubuntu 26.04, and current Fedora and Arch." + exit 1 + fi + Write-Log INFO "OpenSSL ${version} found" +} + # ============================================================================ # System Dependencies # ============================================================================ @@ -373,10 +389,10 @@ Install-Dependencies() { apt) export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 - apt-get install -y build-essential libpcre2-dev zlib1g-dev libzstd-dev libssl-dev curl gcc make cargo pkg-config clang gawk cmake >/dev/null 2>&1 + apt-get install -y build-essential libpcre2-dev zlib1g-dev libzstd-dev libssl-dev openssl curl gcc make cargo pkg-config clang gawk cmake >/dev/null 2>&1 ;; dnf) - dnf install -y -q gcc gcc-c++ make pcre2-devel zlib-devel libzstd-devel openssl-devel curl perl cargo pkgconf-pkg-config clang gawk cmake >/dev/null 2>&1 + dnf install -y -q gcc gcc-c++ make pcre2-devel zlib-devel libzstd-devel openssl-devel openssl curl perl cargo pkgconf-pkg-config clang gawk cmake >/dev/null 2>&1 ;; pacman) # -Syu rather than -Sy: partial upgrades break Arch-based systems. @@ -1174,6 +1190,11 @@ case "$COMMAND" in "Run it as a container: podman run -d -p 80:80 docker.io/library/nginx" \ "distrobox: create a mutable container and build NGINX inside it" \ "Layer it onto the image: rpm-ostree install nginx" + # Fail fast before upgrading anything; checked again once the + # dependencies (which include openssl) are installed. + if command -v openssl >/dev/null 2>&1; then + Test-OpenSsl + fi Show-Intent "This script will build and install NGINX ${NGINX_VERSION} from source." \ "Upgrade all system packages" \ "Install compilers and build dependencies" \ @@ -1183,6 +1204,7 @@ case "$COMMAND" in Update-SystemPackages Test-RunningWebServers Install-Dependencies + Test-OpenSsl Get-Sources Build-Nginx Install-Nginx From 34dc86173f707c34c0193499e5bb501beccb75a4 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:52:46 +0000 Subject: [PATCH 07/14] feat(nginx): answer to the host's own names, add kTLS offload - The default vhost only listened to "localhost", so on a fresh install every browser visit by hostname hit the catch-all reject block (ERR_SSL_UNRECOGNIZED_NAME_ALERT). server_name and the self-signed certificate now also cover the short hostname and FQDN. Plain IP access sends no SNI and stays refused by design; the installer now says so and lists the URLs that work. - kTLS: ssl_conf_command Options KTLS, and the installer loads the tls kernel module now and at boot (/etc/modules-load.d/nginx-ktls.conf). If the module cannot be loaded it only warns; OpenSSL then falls back to user-space TLS. remove cleans up the modules-load.d entry. --- nginx/README.md | 2 +- nginx/nginx_installer.sh | 56 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/nginx/README.md b/nginx/README.md index 3649845..4be1bc1 100644 --- a/nginx/README.md +++ b/nginx/README.md @@ -52,7 +52,7 @@ sudo systemctl {start|stop|reload|restart|status} nginx ```bash sudo systemctl stop nginx && sudo systemctl disable nginx -sudo rm -f /etc/systemd/system/nginx.service +sudo rm -f /etc/systemd/system/nginx.service /etc/modules-load.d/nginx-ktls.conf sudo systemctl daemon-reload sudo rm -rf /usr/sbin/nginx /etc/nginx /usr/lib64/nginx /usr/share/nginx \ /var/log/nginx /var/cache/nginx /var/lib/nginx diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 6904c4f..1f8e05b 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -685,6 +685,20 @@ EOF chmod 0644 /usr/share/nginx/html/*.html /usr/share/nginx/html/*.css 2>/dev/null || true } +# Usage: names=$(Get-ServerNames) -> "localhost " +# The names the default vhost answers to. The catch-all server refuses every +# other name (and bare-IP access, which sends no SNI), so without the host's +# own names the welcome page would only be reachable on the machine itself. +Get-ServerNames() { + local names=(localhost) name + for name in "$(hostname -s 2>/dev/null)" "$(hostname -f 2>/dev/null)"; do + name=${name,,} + [[ $name =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ && $name != localhost* ]] || continue + [[ " ${names[*]} " == *" $name "* ]] || names+=("$name") + done + echo "${names[*]}" +} + New-SelfSignedCertificate() { Write-Log INFO "Generating self-signed TLS certificate" mkdir -p /etc/nginx/ssl @@ -698,6 +712,12 @@ New-SelfSignedCertificate() { ssl_bin=$(command -v openssl || true) [[ -n "$ssl_bin" ]] || Stop-Script "openssl not found" + local san="" name + for name in $(Get-ServerNames); do + san+="DNS:${name}," + done + san+="IP:127.0.0.1,IP:::1" + local output if ! output=$(OPENSSL_CONF=/dev/null "$ssl_bin" req -x509 -newkey ec \ -pkeyopt ec_paramgen_curve:secp384r1 \ @@ -705,7 +725,7 @@ New-SelfSignedCertificate() { -keyout /etc/nginx/ssl/nginx.key \ -out /etc/nginx/ssl/nginx.crt \ -subj '/CN=localhost' \ - -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' 2>&1); then + -addext "subjectAltName=${san}" 2>&1); then Write-Log ERROR "OpenSSL output: $output" Stop-Script "Certificate generation failed" fi @@ -714,6 +734,18 @@ New-SelfSignedCertificate() { chmod 644 /etc/nginx/ssl/nginx.crt } +# kTLS needs the tls kernel module. Load it now and at every boot. A failure +# only warns: without the module OpenSSL falls back to user-space TLS, and +# some environments (e.g. LXC containers) cannot load kernel modules at all. +Enable-Ktls() { + echo tls > /etc/modules-load.d/nginx-ktls.conf + if modprobe tls 2>/dev/null; then + Write-Log INFO "Kernel TLS module loaded (kTLS offload for sendfile)" + else + Write-Log WARN "Could not load the tls kernel module; nginx uses user-space TLS instead" + fi +} + New-NginxConfig() { Write-Log INFO "Creating nginx configuration" @@ -815,6 +847,11 @@ EOF ssl_session_timeout 1d; ssl_session_tickets off; ssl_buffer_size 4k; + # Kernel TLS: the kernel encrypts sendfile() output, so static files no + # longer take a detour through user space. Needs the tls kernel module + # (loaded by the installer); without it OpenSSL silently uses plain + # user-space TLS. Check with: grep TlsCurrTxSw /proc/net/tls_stat + ssl_conf_command Options KTLS; # QUIC configuration quic_retry on; @@ -855,7 +892,11 @@ EOF http2 on; http3 on; - server_name localhost; +EOF + + printf ' server_name %s;\n' "$(Get-ServerNames)" >> /etc/nginx/nginx.conf + + cat >> /etc/nginx/nginx.conf <<'EOF' ssl_certificate /etc/nginx/ssl/nginx.crt; ssl_certificate_key /etc/nginx/ssl/nginx.key; @@ -969,6 +1010,7 @@ Install-Nginx() { New-SelfSignedCertificate if [[ $had_existing_nginx == false ]]; then New-NginxConfig + Enable-Ktls else Write-Log INFO "Existing nginx.conf preserved" fi @@ -1013,7 +1055,13 @@ EOF local openssl_ver openssl_ver=$(openssl version 2>/dev/null | awk '{print $1" "$2}' || echo "OpenSSL unknown") Write-Log INFO "Nginx ${NGINX_VERSION} with ${openssl_ver} (system) installed" - Write-Log INFO "Access: https://localhost" + if [[ $had_existing_nginx == false ]]; then + local name + for name in $(Get-ServerNames); do + Write-Log INFO "Access: https://${name}" + done + Write-Log INFO "Other names and plain IP addresses get a TLS alert by design (ssl_reject_handshake)" + fi Write-Log INFO "Manage nginx with: systemctl {start|stop|reload|restart|status} nginx" /usr/sbin/nginx -V 2>&1 | head -n1 || true @@ -1061,7 +1109,7 @@ Remove-Nginx() { systemctl stop nginx 2>/dev/null || true systemctl disable nginx 2>/dev/null || true - rm -f /etc/systemd/system/nginx.service + rm -f /etc/systemd/system/nginx.service /etc/modules-load.d/nginx-ktls.conf systemctl daemon-reload 2>/dev/null || true rm -rf \ From e1653a1d2539dbf5c8bff338d79ac94686dd8f3f Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:57:49 +0000 Subject: [PATCH 08/14] fix(nginx): keep the hostname private, serve a localhost placeholder by IP Revert the hostname/FQDN in server_name and the certificate: anyone reaching the server from outside could read the machine's name from the certificate. Instead of refusing the handshake, the default server is now the welcome vhost itself with the self-signed "localhost" certificate. The bare IP address and every unknown name get that placeholder, so the page is reachable by IP while scanners learn neither the hostname nor the certificate of a real site added later (that site's server block, by name, still gets its own certificate, over TCP and HTTP/3). --- nginx/nginx_installer.sh | 70 +++++++++------------------------------- 1 file changed, 15 insertions(+), 55 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 1f8e05b..5eba626 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -685,20 +685,6 @@ EOF chmod 0644 /usr/share/nginx/html/*.html /usr/share/nginx/html/*.css 2>/dev/null || true } -# Usage: names=$(Get-ServerNames) -> "localhost " -# The names the default vhost answers to. The catch-all server refuses every -# other name (and bare-IP access, which sends no SNI), so without the host's -# own names the welcome page would only be reachable on the machine itself. -Get-ServerNames() { - local names=(localhost) name - for name in "$(hostname -s 2>/dev/null)" "$(hostname -f 2>/dev/null)"; do - name=${name,,} - [[ $name =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ && $name != localhost* ]] || continue - [[ " ${names[*]} " == *" $name "* ]] || names+=("$name") - done - echo "${names[*]}" -} - New-SelfSignedCertificate() { Write-Log INFO "Generating self-signed TLS certificate" mkdir -p /etc/nginx/ssl @@ -712,12 +698,6 @@ New-SelfSignedCertificate() { ssl_bin=$(command -v openssl || true) [[ -n "$ssl_bin" ]] || Stop-Script "openssl not found" - local san="" name - for name in $(Get-ServerNames); do - san+="DNS:${name}," - done - san+="IP:127.0.0.1,IP:::1" - local output if ! output=$(OPENSSL_CONF=/dev/null "$ssl_bin" req -x509 -newkey ec \ -pkeyopt ec_paramgen_curve:secp384r1 \ @@ -725,7 +705,7 @@ New-SelfSignedCertificate() { -keyout /etc/nginx/ssl/nginx.key \ -out /etc/nginx/ssl/nginx.crt \ -subj '/CN=localhost' \ - -addext "subjectAltName=${san}" 2>&1); then + -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' 2>&1); then Write-Log ERROR "OpenSSL output: $output" Stop-Script "Certificate generation failed" fi @@ -858,23 +838,6 @@ EOF # 0-RTT disabled: no replay attack protection configured at application layer ssl_early_data off; - # Refuse handshakes for names we do not host, instead of serving the - # certificate to scanners and SNI probes hitting the bare IP address. - # This block must also own the QUIC listeners: a server without a quic - # listen directive never takes part in HTTP/3 SNI selection, so an - # unknown-SNI QUIC client would fall back to the first server that has - # one and still receive its certificate. default_server is explicit on - # the quic listeners too, so a server block added above this one cannot - # silently take over. reuseport may appear only once per address:port, - # so the real server below uses plain quic listeners. - server { - listen 443 ssl default_server; - listen [::]:443 ssl default_server; - listen 443 quic reuseport default_server; - listen [::]:443 quic reuseport default_server; - ssl_reject_handshake on; - } - server { listen 80; listen [::]:80; @@ -882,21 +845,24 @@ EOF return 301 https://$host$request_uri; } + # Catch-all for the bare IP address and every name without its own + # server block. It only ever presents the self-signed "localhost" + # certificate, so scanners and SNI probes learn neither the hostname nor + # the certificate of a real site added later. default_server is explicit + # on the quic listeners too: HTTP/3 picks its default separately, and a + # quic server block added above this one would otherwise take over. + # reuseport may appear only once per address:port, so it lives here and + # later server blocks use plain "listen 443 quic". server { - listen 443 ssl; - listen [::]:443 ssl; - # reuseport lives on the reject block above (only once per address:port) - listen 443 quic; - listen [::]:443 quic; + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + listen 443 quic reuseport default_server; + listen [::]:443 quic reuseport default_server; http2 on; http3 on; -EOF - - printf ' server_name %s;\n' "$(Get-ServerNames)" >> /etc/nginx/nginx.conf - - cat >> /etc/nginx/nginx.conf <<'EOF' + server_name localhost; ssl_certificate /etc/nginx/ssl/nginx.crt; ssl_certificate_key /etc/nginx/ssl/nginx.key; @@ -1055,13 +1021,7 @@ EOF local openssl_ver openssl_ver=$(openssl version 2>/dev/null | awk '{print $1" "$2}' || echo "OpenSSL unknown") Write-Log INFO "Nginx ${NGINX_VERSION} with ${openssl_ver} (system) installed" - if [[ $had_existing_nginx == false ]]; then - local name - for name in $(Get-ServerNames); do - Write-Log INFO "Access: https://${name}" - done - Write-Log INFO "Other names and plain IP addresses get a TLS alert by design (ssl_reject_handshake)" - fi + Write-Log INFO "Access: https://localhost or https:// (self-signed certificate for localhost)" Write-Log INFO "Manage nginx with: systemctl {start|stop|reload|restart|status} nginx" /usr/sbin/nginx -V 2>&1 | head -n1 || true From 7054c9aba767a364d600d4b94bb3dfce7deb992c Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:40:52 +0000 Subject: [PATCH 09/14] feat(nginx): ship reverse-proxy examples and wire up conf.d/stream.d Lessons from a real deployment's config: - nginx.conf now includes conf.d/*.conf and stream.d/*.conf and defines the $connection_upgrade map; without those, no site file or websocket proxy could work. - snippets/proxy.conf holds the shared proxy settings (forwarded headers, websocket upgrade, compression off for proxied responses). - Example sites as conf.d/*.conf.example (inactive until copied): Home Assistant, Jellyfin, the *arr apps, Seerr, qBittorrent and Cockpit, each with the app-side setting it needs (trusted proxies, known proxies, Cockpit origins, ...). stream.d/minecraft.conf.example shows a TCP passthrough. - Port 80 serves ACME HTTP-01 challenges from /var/www/acme for every name (certbot --webroot) and redirects the rest to HTTPS. --- nginx/README.md | 36 +++++- nginx/nginx_installer.sh | 258 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 290 insertions(+), 4 deletions(-) diff --git a/nginx/README.md b/nginx/README.md index 4be1bc1..5a658cc 100644 --- a/nginx/README.md +++ b/nginx/README.md @@ -27,6 +27,36 @@ sudo ./nginx_installer.sh install --skip-modules=acme,zstd,headers-more | `--skip-headers-more` | headers-more module | | `--skip-modules=a,b,c` | Any combination of `acme`, `zstd`, `headers-more` | +## Adding sites + +A fresh install ships ready-to-copy reverse-proxy examples. They end in +`.example`, so nginx ignores them until you copy one: + +| File | For | +|------|-----| +| `conf.d/homeassistant.conf.example` | Home Assistant | +| `conf.d/jellyfin.conf.example` | Jellyfin | +| `conf.d/arr.conf.example` | Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr | +| `conf.d/seerr.conf.example` | Seerr / Jellyseerr / Overseerr | +| `conf.d/qbittorrent.conf.example` | qBittorrent Web UI | +| `conf.d/cockpit.conf.example` | Cockpit | +| `stream.d/minecraft.conf.example` | Plain TCP passthrough (Minecraft) | + +Each file lists the settings the app itself needs (trusted proxies, allowed +origins, ...). Shared proxy settings live in `snippets/proxy.conf`. + +```bash +cd /etc/nginx/conf.d +sudo cp homeassistant.conf.example home.example.com.conf # then edit the name and upstream +sudo certbot certonly --webroot -w /var/www/acme -d home.example.com \ + --deploy-hook "systemctl reload nginx" +sudo nginx -t && sudo systemctl reload nginx +``` + +Port 80 answers ACME HTTP-01 challenges from `/var/www/acme` for every name +and redirects everything else to HTTPS. The bare IP address and names without +their own site get the self-signed `localhost` certificate, never a real one. + ## Installed paths | Path | Purpose | @@ -34,6 +64,10 @@ sudo ./nginx_installer.sh install --skip-modules=acme,zstd,headers-more | `/usr/sbin/nginx` | Binary | | `/etc/nginx/nginx.conf` | Main config | | `/etc/nginx/ssl/` | TLS certificates | +| `/etc/nginx/conf.d/` | One file per site (`*.conf`), plus the `*.conf.example` files | +| `/etc/nginx/stream.d/` | TCP/UDP passthrough (`*.conf`) | +| `/etc/nginx/snippets/proxy.conf` | Shared reverse-proxy settings | +| `/var/www/acme/` | ACME HTTP-01 challenge webroot | | `/etc/nginx/modules` | Symlink → `/usr/lib64/nginx/modules` | | `/usr/lib64/nginx/modules/` | Dynamic modules | | `/usr/share/nginx/html/` | Default web root | @@ -55,7 +89,7 @@ sudo systemctl stop nginx && sudo systemctl disable nginx sudo rm -f /etc/systemd/system/nginx.service /etc/modules-load.d/nginx-ktls.conf sudo systemctl daemon-reload sudo rm -rf /usr/sbin/nginx /etc/nginx /usr/lib64/nginx /usr/share/nginx \ - /var/log/nginx /var/cache/nginx /var/lib/nginx + /var/log/nginx /var/cache/nginx /var/lib/nginx /var/www/acme sudo userdel nginx ``` diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 5eba626..145afe2 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -838,11 +838,27 @@ EOF # 0-RTT disabled: no replay attack protection configured at application layer ssl_early_data off; + # WebSocket upgrade for reverse proxies (used by snippets/proxy.conf): + # "upgrade" when the client asks for it, otherwise close. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Port 80 for every name: answer ACME HTTP-01 challenges (certbot + # certonly --webroot -w /var/www/acme -d ), redirect the rest. server { - listen 80; - listen [::]:80; + listen 80 default_server; + listen [::]:80 default_server; server_name _; - return 301 https://$host$request_uri; + + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + + location / { + return 301 https://$host$request_uri; + } } # Catch-all for the bare IP address and every name without its own @@ -886,8 +902,242 @@ EOF root /usr/share/nginx/html; } } + + # One file per site. Examples: /etc/nginx/conf.d/*.conf.example + include /etc/nginx/conf.d/*.conf; +} + +# TCP/UDP passthrough (game servers, SSH, ...): one file per service. +stream { + include /etc/nginx/stream.d/*.conf; +} +EOF +} + +# Usage: Write-VhostHead +# Appends the part every example site shares: listeners (TCP + QUIC, no +# reuseport: that lives on the default server), certificate, headers. +Write-VhostHead() { + local name=$1 file=$2 + cat >> "$file" < /etc/nginx/snippets/proxy.conf <<'EOF' +# Shared reverse-proxy settings. Include inside a location block: +# include /etc/nginx/snippets/proxy.conf; +# Timeouts and buffering differ per app, so each site sets those itself. +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; + +# WebSockets (Home Assistant, *arr, Jellyfin, Cockpit, ...) +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection $connection_upgrade; + +# Leave compression to the app. Compressing proxied streams, server-sent +# events and websockets has crashed nginx workers with the zstd module, and +# most apps compress their own responses anyway. +gzip off; +EOF + if [[ $SKIP_ZSTD != true ]]; then + echo 'zstd off;' >> /etc/nginx/snippets/proxy.conf + fi + + local f + + f=/etc/nginx/conf.d/homeassistant.conf.example + cat > "$f" <<'EOF' +# Home Assistant +# +# Home Assistant answers proxied requests with 400 Bad Request until it +# trusts nginx. In configuration.yaml, then restart Home Assistant: +# http: +# use_x_forwarded_for: true +# trusted_proxies: +# - 127.0.0.1 # the address nginx connects from (its LAN IP if HA runs elsewhere) +EOF + Write-VhostHead home.example.com "$f" + cat >> "$f" <<'EOF' + # Backups and media uploads + client_max_body_size 50M; + + location / { + proxy_pass http://127.0.0.1:8123; + include /etc/nginx/snippets/proxy.conf; + + # The frontend keeps one websocket open for the whole session + proxy_buffering off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } +} +EOF + + f=/etc/nginx/conf.d/jellyfin.conf.example + cat > "$f" <<'EOF' +# Jellyfin +# +# In Jellyfin: Dashboard > Networking > Known proxies, add the address nginx +# connects from. Otherwise every client shows up as nginx's IP. +EOF + Write-VhostHead jellyfin.example.com "$f" + cat >> "$f" <<'EOF' + client_max_body_size 20M; + + location / { + proxy_pass http://127.0.0.1:8096; + include /etc/nginx/snippets/proxy.conf; + + # Streams and the /socket websocket: pass through unbuffered + proxy_buffering off; + proxy_read_timeout 1h; + } +} +EOF + + f=/etc/nginx/conf.d/arr.conf.example + cat > "$f" <<'EOF' +# Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr: one file per app. +# Default ports: Sonarr 8989, Radarr 7878, Lidarr 8686, Readarr 8787, +# Prowlarr 9696, Bazarr 6767. +# +# In the app: leave "URL Base" empty (the app gets its own name) and set +# Authentication to "Forms" before exposing it. +EOF + Write-VhostHead sonarr.example.com "$f" + cat >> "$f" <<'EOF' + location / { + proxy_pass http://127.0.0.1:8989; + include /etc/nginx/snippets/proxy.conf; + + # Live UI updates arrive over a websocket + proxy_buffering off; + } +} +EOF + + f=/etc/nginx/conf.d/seerr.conf.example + cat > "$f" <<'EOF' +# Seerr / Jellyseerr / Overseerr +# +# In Seerr: Settings > General > "Enable Proxy Support", so it reads the +# client IP from X-Forwarded-For. +EOF + Write-VhostHead requests.example.com "$f" + cat >> "$f" <<'EOF' + location / { + proxy_pass http://127.0.0.1:5055; + include /etc/nginx/snippets/proxy.conf; + proxy_buffering off; + } +} +EOF + + f=/etc/nginx/conf.d/qbittorrent.conf.example + cat > "$f" <<'EOF' +# qBittorrent Web UI +# +# In qBittorrent: Options > Web UI +# - "Enable reverse proxy support", trusted proxies: 127.0.0.1 +# - with "Host header validation" on, add qbittorrent.example.com to the +# server domains +# - keep cross-site request forgery protection on +EOF + Write-VhostHead qbittorrent.example.com "$f" + cat >> "$f" <<'EOF' + location / { + proxy_pass http://127.0.0.1:8080; + include /etc/nginx/snippets/proxy.conf; + proxy_buffering off; + } +} +EOF + + f=/etc/nginx/conf.d/cockpit.conf.example + cat > "$f" <<'EOF' +# Cockpit (server admin console) +# +# Cockpit refuses logins from origins it does not know. In +# /etc/cockpit/cockpit.conf, then: systemctl restart cockpit +# [WebService] +# Origins = https://admin.example.com wss://admin.example.com +# ProtocolHeader = X-Forwarded-Proto +# +# Think twice before exposing an admin console; to limit it to your LAN, +# add inside the server block: allow 192.168.0.0/16; deny all; +EOF + Write-VhostHead admin.example.com "$f" + cat >> "$f" <<'EOF' + # File uploads through the Navigator plugin + client_max_body_size 100M; + + location / { + # Cockpit serves TLS itself, even on localhost + proxy_pass https://127.0.0.1:9090; + include /etc/nginx/snippets/proxy.conf; + + # Terminal sessions stay open for a long time + proxy_buffering off; + proxy_read_timeout 2h; + proxy_send_timeout 2h; + } } EOF + + cat > /etc/nginx/stream.d/minecraft.conf.example <<'EOF' +# Minecraft (Java edition): plain TCP passthrough, no TLS or HTTP involved. +# Activate: copy to minecraft.conf, set the server address, open port 25565. + +upstream minecraft { + server 192.168.0.10:25565; +} + +server { + listen 25565; + listen [::]:25565; + proxy_pass minecraft; +} +EOF + + chmod 0644 /etc/nginx/snippets/proxy.conf /etc/nginx/conf.d/*.example /etc/nginx/stream.d/*.example } # ============================================================================ @@ -976,6 +1226,7 @@ Install-Nginx() { New-SelfSignedCertificate if [[ $had_existing_nginx == false ]]; then New-NginxConfig + New-VhostExamples Enable-Ktls else Write-Log INFO "Existing nginx.conf preserved" @@ -1078,6 +1329,7 @@ Remove-Nginx() { /var/log/nginx \ /var/cache/nginx \ /var/lib/nginx \ + /var/www/acme \ "${NGINX_PREFIX}" \ "${NGINX_LIBDIR}/nginx" userdel nginx 2>/dev/null || true From 2c8d0085f0544f91503a0b2b06d1936e38201a8a Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:48:20 +0000 Subject: [PATCH 10/14] feat(nginx): point to the example sites at the end of a fresh install --- nginx/nginx_installer.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 145afe2..9ec9aee 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -1273,6 +1273,9 @@ EOF openssl_ver=$(openssl version 2>/dev/null | awk '{print $1" "$2}' || echo "OpenSSL unknown") Write-Log INFO "Nginx ${NGINX_VERSION} with ${openssl_ver} (system) installed" Write-Log INFO "Access: https://localhost or https:// (self-signed certificate for localhost)" + if [[ $had_existing_nginx == false ]]; then + Write-Log INFO "Add a site: copy one of /etc/nginx/conf.d/*.conf.example to .conf (steps at the top of each file)" + fi Write-Log INFO "Manage nginx with: systemctl {start|stop|reload|restart|status} nginx" /usr/sbin/nginx -V 2>&1 | head -n1 || true From 81bcf3d190cfa59853fd7415cdbb8501eda2a8d8 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:52:19 +0000 Subject: [PATCH 11/14] fix(nginx): keep the installed NGINX serving during a rebuild, check root first Found while rebuilding over an existing install on real hardware: - Without sudo the script died with a bare 'Permission denied' on the log file in /var/log. install and remove now check for root before anything logs. - The port check offered to stop the NGINX this script installed, taking every site down for the whole build. It now leaves that one running (recognised by its unit in /etc/systemd/system) and the install ends with a restart instead of a start, so the new binary takes over in one step. --- nginx/nginx_installer.sh | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 9ec9aee..35b1062 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -1267,7 +1267,8 @@ EOF if ! /usr/sbin/nginx -t; then Stop-Script "nginx configuration test failed — check the error above" fi - systemctl start nginx || Stop-Script "Failed to start nginx service" + # restart rather than start: on a rebuild the old binary is still running + systemctl restart nginx || Stop-Script "Failed to start nginx service" local openssl_ver openssl_ver=$(openssl version 2>/dev/null | awk '{print $1" "$2}' || echo "OpenSSL unknown") @@ -1366,6 +1367,13 @@ Test-RunningWebServers() { if [[ -n "$pid" ]]; then local proc proc=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown") + # The NGINX this script installed earlier (its unit lives in + # /etc/systemd/system; a distro package uses /lib/systemd) keeps + # serving during the rebuild and is restarted at the end. + if [[ $proc == nginx && -f /etc/systemd/system/nginx.service ]]; then + Write-Log INFO "Port $port in use by the NGINX this script installed; it keeps running until the restart" + continue + fi ports_in_use+=("$port ($proc)") Write-Log WARN "Port $port in use by: $proc" fi @@ -1447,6 +1455,13 @@ done trap 'rm -rf "$BUILD_DIR"' EXIT +# Check for root before anything logs: the log file lives in /var/log, and a +# failed write there would end the script with a bare "Permission denied". +if [[ $COMMAND == install || $COMMAND == remove ]] && [[ $EUID -ne 0 ]]; then + LOG_FILE="" + Test-Root +fi + case "$COMMAND" in install) Test-MutableOs "NGINX" \ From a5dec253859a2e3afa7e4da8c8013042fc0d94f7 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:59:48 +0000 Subject: [PATCH 12/14] refactor(nginx): keep any running nginx serving during a rebuild Where a running nginx came from does not matter: it keeps serving during the build and the restart at the end hands over to the new binary. --- nginx/nginx_installer.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index 35b1062..bb18327 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -1367,11 +1367,10 @@ Test-RunningWebServers() { if [[ -n "$pid" ]]; then local proc proc=$(ps -p "$pid" -o comm= 2>/dev/null || echo "unknown") - # The NGINX this script installed earlier (its unit lives in - # /etc/systemd/system; a distro package uses /lib/systemd) keeps - # serving during the rebuild and is restarted at the end. - if [[ $proc == nginx && -f /etc/systemd/system/nginx.service ]]; then - Write-Log INFO "Port $port in use by the NGINX this script installed; it keeps running until the restart" + # A running NGINX keeps serving during the build and is + # restarted with the new binary at the end. + if [[ $proc == nginx ]]; then + Write-Log INFO "Port $port in use by nginx; it keeps running until the restart" continue fi ports_in_use+=("$port ($proc)") From 59b5957dd5eed6a6a0bd83a8032f42924bc6b7ef Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:04:27 +0000 Subject: [PATCH 13/14] refactor(nginx): replace per-app examples with three generic ones Six app-specific site files were more than a fresh install needs. Ship three generic examples instead: reverse-proxy (HTTP or HTTPS upstream, websockets), static-site and tcp-passthrough. The app-side settings that made the per-app files useful (trusted proxies, Cockpit origins, ...) move to one table in the README, and the README now mentions DNS-01 for wildcard certificates. --- nginx/README.md | 41 +++++++---- nginx/nginx_installer.sh | 155 ++++++++------------------------------- 2 files changed, 57 insertions(+), 139 deletions(-) diff --git a/nginx/README.md b/nginx/README.md index 5a658cc..542bd9b 100644 --- a/nginx/README.md +++ b/nginx/README.md @@ -29,33 +29,44 @@ sudo ./nginx_installer.sh install --skip-modules=acme,zstd,headers-more ## Adding sites -A fresh install ships ready-to-copy reverse-proxy examples. They end in -`.example`, so nginx ignores them until you copy one: +A fresh install ships ready-to-copy examples. They end in `.example`, so +nginx ignores them until you copy one: | File | For | |------|-----| -| `conf.d/homeassistant.conf.example` | Home Assistant | -| `conf.d/jellyfin.conf.example` | Jellyfin | -| `conf.d/arr.conf.example` | Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr | -| `conf.d/seerr.conf.example` | Seerr / Jellyseerr / Overseerr | -| `conf.d/qbittorrent.conf.example` | qBittorrent Web UI | -| `conf.d/cockpit.conf.example` | Cockpit | -| `stream.d/minecraft.conf.example` | Plain TCP passthrough (Minecraft) | +| `conf.d/reverse-proxy.conf.example` | An app behind nginx (HTTP or HTTPS upstream, websockets) | +| `conf.d/static-site.conf.example` | Static files from `/var/www/` | +| `stream.d/tcp-passthrough.conf.example` | Forwarding a TCP port unchanged (game servers, SSH) | -Each file lists the settings the app itself needs (trusted proxies, allowed -origins, ...). Shared proxy settings live in `snippets/proxy.conf`. +Shared proxy settings live in `snippets/proxy.conf`. ```bash cd /etc/nginx/conf.d -sudo cp homeassistant.conf.example home.example.com.conf # then edit the name and upstream -sudo certbot certonly --webroot -w /var/www/acme -d home.example.com \ +sudo cp reverse-proxy.conf.example app.example.com.conf # then edit the name and upstream +sudo certbot certonly --webroot -w /var/www/acme -d app.example.com \ --deploy-hook "systemctl reload nginx" sudo nginx -t && sudo systemctl reload nginx ``` Port 80 answers ACME HTTP-01 challenges from `/var/www/acme` for every name -and redirects everything else to HTTPS. The bare IP address and names without -their own site get the self-signed `localhost` certificate, never a real one. +and redirects everything else to HTTPS. For a wildcard certificate use a +DNS-01 client instead (for example acme.sh with your DNS provider's API) and +point both `ssl_certificate` lines at it. The bare IP address and names +without their own site get the self-signed `localhost` certificate, never a +real one. + +### App-side settings + +Behind a reverse proxy, most apps must be told to trust it: + +| App | Setting | +|-----|---------| +| Home Assistant | `configuration.yaml`: `http:` `use_x_forwarded_for: true` and `trusted_proxies:` with nginx's address, otherwise 400 Bad Request | +| Jellyfin | Dashboard > Networking > Known proxies | +| Sonarr, Radarr, Lidarr, Prowlarr, Bazarr | Leave URL Base empty on a subdomain; set Authentication to Forms | +| Seerr / Jellyseerr / Overseerr | Settings > General > Enable Proxy Support | +| qBittorrent | Web UI: enable reverse proxy support with nginx as trusted proxy; add the name to Host header validation | +| Cockpit | `/etc/cockpit/cockpit.conf`: `[WebService]` `Origins = https:// wss://`, `ProtocolHeader = X-Forwarded-Proto`; proxy to `https://127.0.0.1:9090` | ## Installed paths diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index bb18327..c656e9f 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -922,9 +922,10 @@ Write-VhostHead() { cat >> "$file" < "$f" <<'EOF' -# Home Assistant +# Reverse proxy to an app on this machine or on the LAN. # -# Home Assistant answers proxied requests with 400 Bad Request until it -# trusts nginx. In configuration.yaml, then restart Home Assistant: -# http: -# use_x_forwarded_for: true -# trusted_proxies: -# - 127.0.0.1 # the address nginx connects from (its LAN IP if HA runs elsewhere) +# Most apps must also trust the proxy, or they see every visitor as nginx's +# address and some refuse the request outright. Look for a setting such as +# "trusted proxies" or "known proxies" and add the address nginx connects +# from. The nginx README in the Scripts repository lists common apps. EOF - Write-VhostHead home.example.com "$f" + Write-VhostHead app.example.com "$f" cat >> "$f" <<'EOF' - # Backups and media uploads - client_max_body_size 50M; + # Larger uploads than the 16m default, if the app needs them + #client_max_body_size 100M; - location / { - proxy_pass http://127.0.0.1:8123; - include /etc/nginx/snippets/proxy.conf; - - # The frontend keeps one websocket open for the whole session - proxy_buffering off; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - } -} -EOF - - f=/etc/nginx/conf.d/jellyfin.conf.example - cat > "$f" <<'EOF' -# Jellyfin -# -# In Jellyfin: Dashboard > Networking > Known proxies, add the address nginx -# connects from. Otherwise every client shows up as nginx's IP. -EOF - Write-VhostHead jellyfin.example.com "$f" - cat >> "$f" <<'EOF' - client_max_body_size 20M; - - location / { - proxy_pass http://127.0.0.1:8096; - include /etc/nginx/snippets/proxy.conf; - - # Streams and the /socket websocket: pass through unbuffered - proxy_buffering off; - proxy_read_timeout 1h; - } -} -EOF - - f=/etc/nginx/conf.d/arr.conf.example - cat > "$f" <<'EOF' -# Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr: one file per app. -# Default ports: Sonarr 8989, Radarr 7878, Lidarr 8686, Readarr 8787, -# Prowlarr 9696, Bazarr 6767. -# -# In the app: leave "URL Base" empty (the app gets its own name) and set -# Authentication to "Forms" before exposing it. -EOF - Write-VhostHead sonarr.example.com "$f" - cat >> "$f" <<'EOF' - location / { - proxy_pass http://127.0.0.1:8989; - include /etc/nginx/snippets/proxy.conf; - - # Live UI updates arrive over a websocket - proxy_buffering off; - } -} -EOF - - f=/etc/nginx/conf.d/seerr.conf.example - cat > "$f" <<'EOF' -# Seerr / Jellyseerr / Overseerr -# -# In Seerr: Settings > General > "Enable Proxy Support", so it reads the -# client IP from X-Forwarded-For. -EOF - Write-VhostHead requests.example.com "$f" - cat >> "$f" <<'EOF' - location / { - proxy_pass http://127.0.0.1:5055; - include /etc/nginx/snippets/proxy.conf; - proxy_buffering off; - } -} -EOF - - f=/etc/nginx/conf.d/qbittorrent.conf.example - cat > "$f" <<'EOF' -# qBittorrent Web UI -# -# In qBittorrent: Options > Web UI -# - "Enable reverse proxy support", trusted proxies: 127.0.0.1 -# - with "Host header validation" on, add qbittorrent.example.com to the -# server domains -# - keep cross-site request forgery protection on -EOF - Write-VhostHead qbittorrent.example.com "$f" - cat >> "$f" <<'EOF' location / { proxy_pass http://127.0.0.1:8080; + # An app that serves TLS itself: + #proxy_pass https://127.0.0.1:9090; include /etc/nginx/snippets/proxy.conf; + + # Streams, live updates and websockets pass through unbuffered proxy_buffering off; + # Long-lived websockets (dashboards, terminals) idle longer than 60s + #proxy_read_timeout 1h; + #proxy_send_timeout 1h; } } EOF - f=/etc/nginx/conf.d/cockpit.conf.example + f=/etc/nginx/conf.d/static-site.conf.example cat > "$f" <<'EOF' -# Cockpit (server admin console) -# -# Cockpit refuses logins from origins it does not know. In -# /etc/cockpit/cockpit.conf, then: systemctl restart cockpit -# [WebService] -# Origins = https://admin.example.com wss://admin.example.com -# ProtocolHeader = X-Forwarded-Proto -# -# Think twice before exposing an admin console; to limit it to your LAN, -# add inside the server block: allow 192.168.0.0/16; deny all; +# Static site served from /var/www/. EOF - Write-VhostHead admin.example.com "$f" + Write-VhostHead www.example.com "$f" cat >> "$f" <<'EOF' - # File uploads through the Navigator plugin - client_max_body_size 100M; + root /var/www/www.example.com; + index index.html; location / { - # Cockpit serves TLS itself, even on localhost - proxy_pass https://127.0.0.1:9090; - include /etc/nginx/snippets/proxy.conf; - - # Terminal sessions stay open for a long time - proxy_buffering off; - proxy_read_timeout 2h; - proxy_send_timeout 2h; + try_files $uri $uri/ =404; } } EOF - cat > /etc/nginx/stream.d/minecraft.conf.example <<'EOF' -# Minecraft (Java edition): plain TCP passthrough, no TLS or HTTP involved. -# Activate: copy to minecraft.conf, set the server address, open port 25565. + cat > /etc/nginx/stream.d/tcp-passthrough.conf.example <<'EOF' +# TCP passthrough: forwards a port unchanged, without TLS or HTTP +# (game servers, SSH, ...). Example: a Minecraft server on the LAN. +# Activate: copy to .conf, set the port and upstream, open the port. -upstream minecraft { +upstream tcp_backend { server 192.168.0.10:25565; } server { listen 25565; listen [::]:25565; - proxy_pass minecraft; + proxy_pass tcp_backend; } EOF From 3e40ee8d4d36d92b6f13a3fa6036566e2a4004e2 Mon Sep 17 00:00:00 2001 From: "Sten T." <102481635+Stensel8@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:13:42 +0000 Subject: [PATCH 14/14] feat(nginx): offer only the 256-bit TLS 1.3 cipher suites Drop TLS_AES_128_GCM_SHA256, leaving AES-256-GCM and ChaCha20-Poly1305. testssl scored cipher strength 90 because of the 128-bit suite (A+ at 96); every mainstream client also speaks one of the 256-bit suites, so this is full marks at no practical compatibility cost. QUIC Initial packets use AES-128-GCM regardless, HTTP/3 keeps working. --- nginx/nginx_installer.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nginx/nginx_installer.sh b/nginx/nginx_installer.sh index c656e9f..ea341ad 100755 --- a/nginx/nginx_installer.sh +++ b/nginx/nginx_installer.sh @@ -815,10 +815,15 @@ EOF cat >> /etc/nginx/nginx.conf <<'EOF' # SSL/TLS configuration — TLS 1.3 only. - # TLS 1.3 suites are AEAD-only and secure by default; ssl_ciphers only - # applies to TLS 1.2 and below, which are refused here (clients older - # than ~2018 cannot connect). + # ssl_ciphers only applies to TLS 1.2 and below, which are refused here + # (Windows 10 Schannel, Android 8 and older cannot connect). ssl_protocols TLSv1.3; + # Only the 256-bit TLS 1.3 suites. RFC 8446 makes TLS_AES_128_GCM_SHA256 + # mandatory to implement, but every mainstream client also speaks + # AES-256-GCM or ChaCha20, and dropping it gives full marks for cipher + # strength in SSL Labs-style ratings. QUIC is unaffected: its Initial + # packets always use AES-128-GCM, independent of this list. + ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256; # Hybrid post-quantum group first, then X25519. Keep the NIST curves: # RFC 8446 makes secp256r1 the mandatory group (X25519 is only a SHOULD), # and FIPS-mode or embedded clients may offer nothing else.