diff --git a/nixos/modules/services/web-servers/nginx/default.nix b/nixos/modules/services/web-servers/nginx/default.nix index 6be3703d91da9..6508dc4cc9b75 100644 --- a/nixos/modules/services/web-servers/nginx/default.nix +++ b/nixos/modules/services/web-servers/nginx/default.nix @@ -159,166 +159,171 @@ let ''; configFile = - (if cfg.validateConfigFile then pkgs.writers.writeNginxConfig else pkgs.writeText) "nginx.conf" - '' - ${cfg.prependConfig} + let + writeNginxConfig = pkgs.writers.writeNginxConfig { + nginxPackage = cfg.package; + inherit (cfg) validateSyntax; + }; + in + (if cfg.validateConfigFile then writeNginxConfig else pkgs.writeText) "nginx.conf" '' + ${cfg.prependConfig} - pid /run/nginx/nginx.pid; - error_log ${cfg.logError}; - daemon off; + pid /run/nginx/nginx.pid; + error_log ${cfg.logError}; + daemon off; - ${optionalString cfg.enableQuicBPF '' - quic_bpf on; - ''} + ${optionalString cfg.enableQuicBPF '' + quic_bpf on; + ''} + + ${cfg.config} - ${cfg.config} + ${optionalString (cfg.eventsConfig != "" || cfg.config == "") '' + events { + ${cfg.eventsConfig} + } + ''} + + ${optionalString (cfg.httpConfig == "" && cfg.config == "") '' + http { + ${commonHttpConfig} + + ${optionalString (cfg.resolver.addresses != [ ]) '' + resolver ${toString cfg.resolver.addresses} ${ + optionalString (cfg.resolver.valid != "") "valid=${cfg.resolver.valid}" + } ${optionalString (!cfg.resolver.ipv4) "ipv4=off"} ${ + optionalString (!cfg.resolver.ipv6) "ipv6=off" + }; + ''} + ${upstreamConfig} + + ${optionalString cfg.recommendedOptimisation '' + # optimisation + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + ''} - ${optionalString (cfg.eventsConfig != "" || cfg.config == "") '' - events { - ${cfg.eventsConfig} + ssl_protocols ${cfg.sslProtocols}; + ${optionalString (cfg.sslCiphers != null) "ssl_ciphers ${cfg.sslCiphers};"} + ${optionalString (cfg.sslDhparam != false) + "ssl_dhparam ${ + if cfg.sslDhparam == true then config.security.dhparams.params.nginx.path else cfg.sslDhparam + };" } - ''} - ${optionalString (cfg.httpConfig == "" && cfg.config == "") '' - http { - ${commonHttpConfig} + ${optionalString cfg.recommendedTlsSettings '' + # Consider https://ssl-config.mozilla.org/#server=nginx&config=intermediate as the lower bound + + ssl_conf_command Groups "X25519MLKEM768:X25519:P-256:P-384"; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + # Breaks forward secrecy: https://github.com/mozilla/server-side-tls/issues/135 + ssl_session_tickets off; + # We don't enable insecure ciphers by default, so this allows + # clients to pick the most performant, per https://github.com/mozilla/server-side-tls/issues/260 + ssl_prefer_server_ciphers off; + ''} - ${optionalString (cfg.resolver.addresses != [ ]) '' - resolver ${toString cfg.resolver.addresses} ${ - optionalString (cfg.resolver.valid != "") "valid=${cfg.resolver.valid}" - } ${optionalString (!cfg.resolver.ipv4) "ipv4=off"} ${ - optionalString (!cfg.resolver.ipv6) "ipv6=off" - }; - ''} - ${upstreamConfig} - - ${optionalString cfg.recommendedOptimisation '' - # optimisation - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - ''} - - ssl_protocols ${cfg.sslProtocols}; - ${optionalString (cfg.sslCiphers != null) "ssl_ciphers ${cfg.sslCiphers};"} - ${optionalString (cfg.sslDhparam != false) - "ssl_dhparam ${ - if cfg.sslDhparam == true then config.security.dhparams.params.nginx.path else cfg.sslDhparam - };" - } + ${optionalString cfg.recommendedBrotliSettings '' + brotli on; + brotli_static on; + brotli_comp_level 5; + brotli_window 512k; + brotli_min_length 256; + brotli_types ${lib.concatStringsSep " " compressMimeTypes}; + ''} - ${optionalString cfg.recommendedTlsSettings '' - # Consider https://ssl-config.mozilla.org/#server=nginx&config=intermediate as the lower bound - - ssl_conf_command Groups "X25519MLKEM768:X25519:P-256:P-384"; - ssl_session_timeout 1d; - ssl_session_cache shared:SSL:10m; - # Breaks forward secrecy: https://github.com/mozilla/server-side-tls/issues/135 - ssl_session_tickets off; - # We don't enable insecure ciphers by default, so this allows - # clients to pick the most performant, per https://github.com/mozilla/server-side-tls/issues/260 - ssl_prefer_server_ciphers off; - ''} - - ${optionalString cfg.recommendedBrotliSettings '' - brotli on; - brotli_static on; - brotli_comp_level 5; - brotli_window 512k; - brotli_min_length 256; - brotli_types ${lib.concatStringsSep " " compressMimeTypes}; - ''} - - ${optionalString cfg.recommendedGzipSettings - # https://docs.nginx.com/nginx/admin-guide/web-server/compression/ - '' - gzip on; - gzip_static on; - gzip_vary on; - gzip_comp_level 5; - gzip_min_length 256; - gzip_proxied expired no-cache no-store private auth; - gzip_types ${lib.concatStringsSep " " compressMimeTypes}; - '' - } + ${optionalString cfg.recommendedGzipSettings + # https://docs.nginx.com/nginx/admin-guide/web-server/compression/ + '' + gzip on; + gzip_static on; + gzip_vary on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_proxied expired no-cache no-store private auth; + gzip_types ${lib.concatStringsSep " " compressMimeTypes}; + '' + } - ${optionalString cfg.experimentalZstdSettings '' - zstd on; - zstd_comp_level 9; - zstd_min_length 256; - zstd_static on; - zstd_types ${lib.concatStringsSep " " compressMimeTypes}; - ''} - - ${optionalString cfg.recommendedProxySettings '' - proxy_redirect off; - proxy_connect_timeout ${cfg.proxyTimeout}; - proxy_send_timeout ${cfg.proxyTimeout}; - proxy_read_timeout ${cfg.proxyTimeout}; - proxy_http_version 1.1; - # don't let clients close the keep-alive connection to upstream. See the nginx blog for details: - # https://www.nginx.com/blog/avoiding-top-10-nginx-configuration-mistakes/#no-keepalives - proxy_set_header "Connection" ""; - include ${recommendedProxyConfig}; - ''} - - ${optionalString cfg.recommendedUwsgiSettings '' - uwsgi_connect_timeout ${cfg.uwsgiTimeout}; - uwsgi_send_timeout ${cfg.uwsgiTimeout}; - uwsgi_read_timeout ${cfg.uwsgiTimeout}; - uwsgi_param HTTP_CONNECTION ""; - include ${cfg.package}/conf/uwsgi_params; - ''} - - ${optionalString (cfg.mapHashBucketSize != null) '' - map_hash_bucket_size ${toString cfg.mapHashBucketSize}; - ''} - - ${optionalString (cfg.mapHashMaxSize != null) '' - map_hash_max_size ${toString cfg.mapHashMaxSize}; - ''} - - ${optionalString (cfg.serverNamesHashBucketSize != null) '' - server_names_hash_bucket_size ${toString cfg.serverNamesHashBucketSize}; - ''} - - ${optionalString (cfg.serverNamesHashMaxSize != null) '' - server_names_hash_max_size ${toString cfg.serverNamesHashMaxSize}; - ''} - - # $connection_upgrade is used for websocket proxying - map $http_upgrade $connection_upgrade { - default upgrade; - ''' close; - } - client_max_body_size ${cfg.clientMaxBodySize}; + ${optionalString cfg.experimentalZstdSettings '' + zstd on; + zstd_comp_level 9; + zstd_min_length 256; + zstd_static on; + zstd_types ${lib.concatStringsSep " " compressMimeTypes}; + ''} - server_tokens ${if cfg.serverTokens then "on" else "off"}; + ${optionalString cfg.recommendedProxySettings '' + proxy_redirect off; + proxy_connect_timeout ${cfg.proxyTimeout}; + proxy_send_timeout ${cfg.proxyTimeout}; + proxy_read_timeout ${cfg.proxyTimeout}; + proxy_http_version 1.1; + # don't let clients close the keep-alive connection to upstream. See the nginx blog for details: + # https://www.nginx.com/blog/avoiding-top-10-nginx-configuration-mistakes/#no-keepalives + proxy_set_header "Connection" ""; + include ${recommendedProxyConfig}; + ''} - ${cfg.commonHttpConfig} + ${optionalString cfg.recommendedUwsgiSettings '' + uwsgi_connect_timeout ${cfg.uwsgiTimeout}; + uwsgi_send_timeout ${cfg.uwsgiTimeout}; + uwsgi_read_timeout ${cfg.uwsgiTimeout}; + uwsgi_param HTTP_CONNECTION ""; + include ${cfg.package}/conf/uwsgi_params; + ''} - ${proxyCachePathConfig} + ${optionalString (cfg.mapHashBucketSize != null) '' + map_hash_bucket_size ${toString cfg.mapHashBucketSize}; + ''} - ${vhosts} + ${optionalString (cfg.mapHashMaxSize != null) '' + map_hash_max_size ${toString cfg.mapHashMaxSize}; + ''} - ${cfg.appendHttpConfig} - }''} + ${optionalString (cfg.serverNamesHashBucketSize != null) '' + server_names_hash_bucket_size ${toString cfg.serverNamesHashBucketSize}; + ''} - ${optionalString (cfg.httpConfig != "") '' - http { - ${commonHttpConfig} - ${cfg.httpConfig} - }''} + ${optionalString (cfg.serverNamesHashMaxSize != null) '' + server_names_hash_max_size ${toString cfg.serverNamesHashMaxSize}; + ''} - ${optionalString (cfg.streamConfig != "") '' - stream { - ${cfg.streamConfig} + # $connection_upgrade is used for websocket proxying + map $http_upgrade $connection_upgrade { + default upgrade; + ''' close; } - ''} + client_max_body_size ${cfg.clientMaxBodySize}; + + server_tokens ${if cfg.serverTokens then "on" else "off"}; + + ${cfg.commonHttpConfig} + + ${proxyCachePathConfig} + + ${vhosts} - ${cfg.appendConfig} - ''; + ${cfg.appendHttpConfig} + }''} + + ${optionalString (cfg.httpConfig != "") '' + http { + ${commonHttpConfig} + ${cfg.httpConfig} + }''} + + ${optionalString (cfg.streamConfig != "") '' + stream { + ${cfg.streamConfig} + } + ''} + + ${cfg.appendConfig} + ''; configPath = if cfg.enableReload then "/etc/nginx/nginx.conf" else configFile; @@ -1283,6 +1288,11 @@ in validateConfigFile = lib.mkEnableOption "validating configuration with pkgs.writeNginxConfig" // { default = true; }; + + validateSyntax = mkEnableOption "validating the syntax of the configuration" // { + default = cfg.validateConfigFile; + defaultText = lib.literalExpression "config.services.nginx.validateConfigFile"; + }; }; }; @@ -1420,6 +1430,13 @@ in At least one of services.nginx.resolver.ipv4 and services.nginx.resolver.ipv6 must be true. ''; } + + { + assertion = cfg.validateSyntax -> cfg.validateConfigFile; + message = '' + Validating syntax depends on validating the config file. + ''; + } ] ++ map ( name: diff --git a/nixos/tests/nginx.nix b/nixos/tests/nginx.nix index 821ece83c73ea..59545c17c81fe 100644 --- a/nixos/tests/nginx.nix +++ b/nixos/tests/nginx.nix @@ -74,6 +74,7 @@ specialisation.reloadWithErrorsSystem.configuration = { services.nginx.package = pkgs.nginxMainline; services.nginx.virtualHosts."!@$$(#*%".locations."~@#*$*!)".proxyPass = ";;;"; + services.nginx.validateSyntax = false; }; }; }; diff --git a/pkgs/build-support/writers/scripts.nix b/pkgs/build-support/writers/scripts.nix index 43880c62ccaf0..b13dc7e56516c 100644 --- a/pkgs/build-support/writers/scripts.nix +++ b/pkgs/build-support/writers/scripts.nix @@ -1103,18 +1103,36 @@ rec { ''; writeNginxConfig = + { + nginxPackage, + validateSyntax, + }: name: text: pkgs.runCommandLocal name { inherit text; passAsFile = [ "text" ]; - nativeBuildInputs = [ gixy ]; + nativeBuildInputs = [ gixy ] ++ lib.optional validateSyntax nginxPackage; } # sh - '' - # nginx-config-formatter has an error - https://github.com/1connect/nginx-config-formatter/issues/16 - awk -f ${awkFormatNginx} "$textPath" | sed '/^\s*$/d' > $out - gixy $out || (echo "\n\nThis can be caused by combining multiple incompatible services on the same hostname.\n\nFull merged config:\n\n"; cat $out; exit 1) - ''; + ( + '' + # nginx-config-formatter has an error - https://github.com/1connect/nginx-config-formatter/issues/16 + awk -f ${awkFormatNginx} "$textPath" | sed '/^\s*$/d' > $out + gixy $out || (echo "\n\nThis can be caused by combining multiple incompatible services on the same hostname.\n\nFull merged config:\n\n"; cat $out; exit 1) + '' + + lib.optionalString validateSyntax '' + if nginx -h 2>&1 | grep -- -S; then + if ! nginx -S -c $out; then + echo "Nginx syntax validation failed. If it's a false positive, you can set \`services.nginx.validateSyntax = false\` to disable this check." + exit 1 + fi + else + echo "Nginx package doesn't support syntax checking." + echo "Either disable syntax checking with \`services.nginx.validateSyntax = false\` or use a patched version of nginx that supports it." + exit 1 + fi + '' + ); /** writePerl takes a name an attributeset with libraries and some perl sourcecode and diff --git a/pkgs/servers/http/nginx/0001-Add-S-flag-for-testing-only-the-syntax.patch b/pkgs/servers/http/nginx/0001-Add-S-flag-for-testing-only-the-syntax.patch new file mode 100644 index 0000000000000..0de4408cd016f --- /dev/null +++ b/pkgs/servers/http/nginx/0001-Add-S-flag-for-testing-only-the-syntax.patch @@ -0,0 +1,190 @@ +From babea5a09fb4faa9ea124f84254d158e227458b5 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Gutyina=20Gerg=C5=91?= +Date: Sun, 28 Dec 2025 14:01:03 +0100 +Subject: [PATCH] Add -S flag for testing only the syntax + +It is a lightweight version of `-T` that only validates the syntax, +skips testing modules, avoids creating a pidfile, etc. +Impurities like inet host resolution (requires internet) and +SSL certificate lookups (usually requires accessing /var/lib/acme/) +are disabled with `-S`. +--- + src/core/nginx.c | 9 ++++++++- + src/core/ngx_cycle.c | 6 ++++++ + src/core/ngx_cycle.h | 1 + + src/core/ngx_inet.c | 29 +++++++++++++++++++++++++++++ + src/event/ngx_event_openssl.c | 20 ++++++++++++++++++++ + 5 files changed, 64 insertions(+), 1 deletion(-) + +diff --git a/src/core/nginx.c b/src/core/nginx.c +index 0deb27b7f..41748f6dc 100644 +--- a/src/core/nginx.c ++++ b/src/core/nginx.c +@@ -395,7 +395,7 @@ ngx_show_version_info(void) + + if (ngx_show_help) { + ngx_write_stderr( +- "Usage: nginx [-?hvVtTq] [-s signal] [-p prefix]" NGX_LINEFEED ++ "Usage: nginx [-?hvVtTSq] [-s signal] [-p prefix]" NGX_LINEFEED + " [-e filename] [-c filename] [-g directives]" + NGX_LINEFEED NGX_LINEFEED + "Options:" NGX_LINEFEED +@@ -406,6 +406,7 @@ ngx_show_version_info(void) + " -t : test configuration and exit" NGX_LINEFEED + " -T : test configuration, dump it and exit" + NGX_LINEFEED ++ " -S : test configuration syntax, dump it and exit" NGX_LINEFEED + " -q : suppress non-error messages " + "during configuration testing" NGX_LINEFEED + " -s signal : send signal to a master process: " +@@ -841,6 +842,12 @@ ngx_get_options(int argc, char *const *argv) + ngx_dump_config = 1; + break; + ++ case 'S': ++ ngx_test_config = 1; ++ ngx_dump_config = 1; ++ ngx_test_syntax = 1; ++ break; ++ + case 'q': + ngx_quiet_mode = 1; + break; +diff --git a/src/core/ngx_cycle.c b/src/core/ngx_cycle.c +index a75bdf878..1c291c140 100644 +--- a/src/core/ngx_cycle.c ++++ b/src/core/ngx_cycle.c +@@ -26,6 +26,7 @@ static ngx_event_t ngx_cleaner_event; + static ngx_event_t ngx_shutdown_event; + + ngx_uint_t ngx_test_config; ++ngx_uint_t ngx_test_syntax; + ngx_uint_t ngx_dump_config; + ngx_uint_t ngx_quiet_mode; + +@@ -292,6 +293,11 @@ ngx_init_cycle(ngx_cycle_t *old_cycle) + cycle->conf_file.data); + } + ++ // we are done with syntax checking, return ++ if (ngx_test_syntax) { ++ return cycle; ++ } ++ + for (i = 0; cycle->modules[i]; i++) { + if (cycle->modules[i]->type != NGX_CORE_MODULE) { + continue; +diff --git a/src/core/ngx_cycle.h b/src/core/ngx_cycle.h +index 0c47f25fe..edd90bc2e 100644 +--- a/src/core/ngx_cycle.h ++++ b/src/core/ngx_cycle.h +@@ -142,6 +142,7 @@ extern volatile ngx_cycle_t *ngx_cycle; + extern ngx_array_t ngx_old_cycles; + extern ngx_module_t ngx_core_module; + extern ngx_uint_t ngx_test_config; ++extern ngx_uint_t ngx_test_syntax; + extern ngx_uint_t ngx_dump_config; + extern ngx_uint_t ngx_quiet_mode; + +diff --git a/src/core/ngx_inet.c b/src/core/ngx_inet.c +index 2233e617b..e1bef1f3c 100644 +--- a/src/core/ngx_inet.c ++++ b/src/core/ngx_inet.c +@@ -1120,6 +1120,20 @@ ngx_parse_inet6_url(ngx_pool_t *pool, ngx_url_t *u) + ngx_int_t + ngx_inet_resolve_host(ngx_pool_t *pool, ngx_url_t *u) + { ++ // skip resolving original host as it requires internet, resolve localhost instead ++ // we can't just return NGX_OK because side effects of ngx_inet_add_addr may be needed ++ if (ngx_test_syntax) { ++ struct sockaddr_in sin; ++ ++ ngx_memzero(&sin, sizeof(struct sockaddr_in)); ++ sin.sin_family = AF_INET; ++ sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ sin.sin_port = htons(u->port); ++ ++ return ngx_inet_add_addr(pool, u, (struct sockaddr *) &sin, ++ sizeof(struct sockaddr_in), 1); ++ } ++ + u_char *host; + ngx_uint_t n; + struct addrinfo hints, *res, *rp; +@@ -1201,6 +1215,21 @@ failed: + ngx_int_t + ngx_inet_resolve_host(ngx_pool_t *pool, ngx_url_t *u) + { ++ // skip resolving original host as it requires internet, resolve localhost instead ++ // we can't just return NGX_OK because side effects of ngx_inet_add_addr may be needed ++ if (ngx_test_syntax) { ++ struct sockaddr_in sin; ++ ++ ngx_memzero(&sin, sizeof(struct sockaddr_in)); ++ ++ sin.sin_family = AF_INET; ++ sin.sin_addr.s_addr = htonl(INADDR_LOOPBACK); ++ sin.sin_port = htons(u->port); ++ ++ return ngx_inet_add_addr(pool, u, (struct sockaddr *) &sin, ++ sizeof(struct sockaddr_in), 1); ++ } ++ + u_char *host; + ngx_uint_t i, n; + struct hostent *h; +diff --git a/src/event/ngx_event_openssl.c b/src/event/ngx_event_openssl.c +index d1386d3a6..94b727829 100644 +--- a/src/event/ngx_event_openssl.c ++++ b/src/event/ngx_event_openssl.c +@@ -475,6 +475,11 @@ ngx_int_t + ngx_ssl_certificate(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *cert, + ngx_str_t *key, ngx_array_t *passwords) + { ++ // requires loading certificate from disk, skip it ++ if (ngx_test_syntax) { ++ return NGX_OK; ++ } ++ + char *err; + X509 *x509, **elm; + u_long n; +@@ -944,6 +949,11 @@ ngx_int_t + ngx_ssl_client_certificate(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *cert, + ngx_int_t depth) + { ++ // requires loading certificate from disk, skip it ++ if (ngx_test_syntax) { ++ return NGX_OK; ++ } ++ + int n, i; + char *err; + X509 *x509; +@@ -1048,6 +1058,11 @@ ngx_int_t + ngx_ssl_trusted_certificate(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *cert, + ngx_int_t depth) + { ++ // requires loading certificate from disk, skip it ++ if (ngx_test_syntax) { ++ return NGX_OK; ++ } ++ + int i, n; + char *err; + X509 *x509; +@@ -1109,6 +1124,11 @@ ngx_ssl_trusted_certificate(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *cert, + ngx_int_t + ngx_ssl_crl(ngx_conf_t *cf, ngx_ssl_t *ssl, ngx_str_t *crl) + { ++ // requires loading certificate from disk, skip it ++ if (ngx_test_syntax) { ++ return NGX_OK; ++ } ++ + int n, i; + char *err; + X509_CRL *x509; +-- +2.51.2 + diff --git a/pkgs/servers/http/nginx/generic.nix b/pkgs/servers/http/nginx/generic.nix index cffe00f395275..18547ac6c371a 100644 --- a/pkgs/servers/http/nginx/generic.nix +++ b/pkgs/servers/http/nginx/generic.nix @@ -224,6 +224,8 @@ stdenv.mkDerivation { ./nix-etag-1.15.4.patch ./nix-skip-check-logs-path.patch ] + # incompatible with forks like angie + ++ lib.optional (pname == "nginx") ./0001-Add-S-flag-for-testing-only-the-syntax.patch # Upstream may be against cross-compilation patches. # https://trac.nginx.org/nginx/ticket/2240 https://trac.nginx.org/nginx/ticket/1928#comment:6 # That dev quit the project in 2024 so the stance could be different now.