about summary refs log tree commit diff
path: root/nixos/modules/services/web-servers/nginx/default.nix
diff options
context:
space:
mode:
authorRaito Bezarius <masterancpp@gmail.com>2023-03-05 18:13:27 +0100
committerRaito Bezarius <masterancpp@gmail.com>2023-05-26 19:48:26 +0200
commit69bb0f94dee27c8beddcf786beb89148c51e2e8c (patch)
treea6be87fa72856bc1daa896b34e53a2d46e9ac06d /nixos/modules/services/web-servers/nginx/default.nix
parentbbdb8416a09b77bf44f75bf3c4bd50c88e94a753 (diff)
downloadnixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.tar
nixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.tar.gz
nixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.tar.bz2
nixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.tar.lz
nixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.tar.xz
nixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.tar.zst
nixlib-69bb0f94dee27c8beddcf786beb89148c51e2e8c.zip
nixos/nginx: first-class PROXY protocol support
PROXY protocol is a convenient way to carry information about the
originating address/port of a TCP connection across multiple layers of
proxies/NAT, etc.

Currently, it is possible to make use of it in NGINX's NixOS module, but
is painful when we want to enable it "globally".
Technically, this is achieved by reworking the defaultListen options and
the objective is to have a coherent way to specify default listeners in
the current API design.
See `mkDefaultListenVhost` and `defaultListen` for the details.

It adds a safeguard against running a NGINX with no HTTP listeners (e.g.
only PROXY listeners) while asking for ACME certificates over HTTP-01.

An interesting usecase of PROXY protocol is to enable seamless IPv4 to
IPv6 proxy with origin IPv4 address for IPv6-only NGINX servers, it is
demonstrated how to achieve this in the tests, using sniproxy.

Finally, the tests covers:

- NGINX `defaultListen` mechanisms are not broken by these changes;
- NGINX PROXY protocol listeners are working in a final usecase
  (sniproxy);
- uses snakeoil TLS certs from ACME setup with wildcard certificates;

In the future, it is desirable to spoof-attack NGINX in this scenario to
ascertain that `set_real_ip_from` and all the layers are working as
intended and preventing any user from setting their origin IP address to
any arbitrary, opening up the NixOS module to badâ„¢ vulnerabilities.

For now, it is quite hard to achieve while being minimalistic about the
tests dependencies.
Diffstat (limited to 'nixos/modules/services/web-servers/nginx/default.nix')
-rw-r--r--nixos/modules/services/web-servers/nginx/default.nix100
1 files changed, 94 insertions, 6 deletions
diff --git a/nixos/modules/services/web-servers/nginx/default.nix b/nixos/modules/services/web-servers/nginx/default.nix
index f5fa2ef5f866..12ccba164738 100644
--- a/nixos/modules/services/web-servers/nginx/default.nix
+++ b/nixos/modules/services/web-servers/nginx/default.nix
@@ -309,36 +309,54 @@ let
         onlySSL = vhost.onlySSL || vhost.enableSSL;
         hasSSL = onlySSL || vhost.addSSL || vhost.forceSSL;
 
+        # First evaluation of defaultListen based on a set of listen lines.
+        mkDefaultListenVhost = listenLines:
+          # If this vhost has SSL or is a SSL rejection host.
+          # We enable a TLS variant for lines without explicit ssl or ssl = true.
+          optionals (hasSSL || vhost.rejectSSL)
+            (map (listen: { port = cfg.defaultSSLListenPort; ssl = true; } // listen)
+            (filter (listen: !(listen ? ssl) || listen.ssl) listenLines))
+          # If this vhost is supposed to serve HTTP
+          # We provide listen lines for those without explicit ssl or ssl = false.
+          ++ optionals (!onlySSL)
+            (map (listen: { port = cfg.defaultHTTPListenPort; ssl = false; } // listen)
+            (filter (listen: !(listen ? ssl) || !listen.ssl) listenLines));
+
         defaultListen =
           if vhost.listen != [] then vhost.listen
           else
+          if cfg.defaultListen != [] then mkDefaultListenVhost
+            # Cleanup nulls which will mess up with //.
+            # TODO: is there a better way to achieve this? i.e. mergeButIgnoreNullPlease?
+            (map (listenLine: filterAttrs (_: v: (v != null)) listenLine) cfg.defaultListen)
+          else
             let addrs = if vhost.listenAddresses != [] then vhost.listenAddresses else cfg.defaultListenAddresses;
-            in optionals (hasSSL || vhost.rejectSSL) (map (addr: { inherit addr; port = cfg.defaultSSLListenPort; ssl = true; }) addrs)
-              ++ optionals (!onlySSL) (map (addr: { inherit addr; port = cfg.defaultHTTPListenPort; ssl = false; }) addrs);
+            in mkDefaultListenVhost (map (addr: { inherit addr; }) addrs);
+
 
         hostListen =
           if vhost.forceSSL
             then filter (x: x.ssl) defaultListen
             else defaultListen;
 
-        listenString = { addr, port, ssl, extraParameters ? [], ... }:
+        listenString = { addr, port, ssl, proxyProtocol ? false, extraParameters ? [], ... }:
           # UDP listener for QUIC transport protocol.
           (optionalString (ssl && vhost.quic) ("
             listen ${addr}:${toString port} quic "
           + optionalString vhost.default "default_server "
           + optionalString vhost.reuseport "reuseport "
-          + optionalString (extraParameters != []) (concatStringsSep " " (
-            let inCompatibleParameters = [ "ssl" "proxy_protocol" "http2" ];
+          + optionalString (extraParameters != []) (concatStringsSep " "
+            (let inCompatibleParameters = [ "ssl" "proxy_protocol" "http2" ];
                 isCompatibleParameter = param: !(any (p: p == param) inCompatibleParameters);
             in filter isCompatibleParameter extraParameters))
           + ";"))
           + "
-
             listen ${addr}:${toString port} "
           + optionalString (ssl && vhost.http2) "http2 "
           + optionalString ssl "ssl "
           + optionalString vhost.default "default_server "
           + optionalString vhost.reuseport "reuseport "
+          + optionalString proxyProtocol "proxy_protocol "
           + optionalString (extraParameters != []) (concatStringsSep " " extraParameters)
           + ";";
 
@@ -539,6 +557,49 @@ in
         '';
       };
 
+      defaultListen = mkOption {
+        type = with types; listOf (submodule {
+          options = {
+            addr = mkOption {
+              type = str;
+              description = lib.mdDoc "IP address.";
+            };
+            port = mkOption {
+              type = nullOr port;
+              description = lib.mdDoc "Port number.";
+              default = null;
+            };
+            ssl  = mkOption {
+              type = nullOr bool;
+              default = null;
+              description = lib.mdDoc "Enable SSL.";
+            };
+            proxyProtocol = mkOption {
+              type = bool;
+              description = lib.mdDoc "Enable PROXY protocol.";
+              default = false;
+            };
+            extraParameters = mkOption {
+              type = listOf str;
+              description = lib.mdDoc "Extra parameters of this listen directive.";
+              default = [ ];
+              example = [ "backlog=1024" "deferred" ];
+            };
+          };
+        });
+        default = [];
+        example = literalExpression ''[
+          { addr = "10.0.0.12"; proxyProtocol = true; ssl = true; }
+          { addr = "0.0.0.0"; }
+          { addr = "[::0]"; }
+        ]'';
+        description = lib.mdDoc ''
+          If vhosts do not specify listen, use these addresses by default.
+          This option takes precedence over {option}`defaultListenAddresses` and
+          other listen-related defaults options.
+        '';
+      };
+
       defaultListenAddresses = mkOption {
         type = types.listOf types.str;
         default = [ "0.0.0.0" ] ++ optional enableIPv6 "[::0]";
@@ -546,6 +607,7 @@ in
         example = literalExpression ''[ "10.0.0.12" "[2002:a00:1::]" ]'';
         description = lib.mdDoc ''
           If vhosts do not specify listenAddresses, use these addresses by default.
+          This is akin to writing `defaultListen = [ { addr = "0.0.0.0" } ]`.
         '';
       };
 
@@ -1078,6 +1140,32 @@ in
           which can be achieved by setting `services.nginx.package = pkgs.nginxQuic;`.
         '';
       }
+
+      {
+        # The idea is to understand whether there is a virtual host with a listen configuration
+        # that requires ACME configuration but has no HTTP listener which will make deterministically fail
+        # this operation.
+        # Options' priorities are the following at the moment:
+        # listen (vhost) > defaultListen (server) > listenAddresses (vhost) > defaultListenAddresses (server)
+        assertion =
+        let
+          hasAtLeastHttpListener = listenOptions: any (listenLine: if listenLine ? proxyProtocol then !listenLine.proxyProtocol else true) listenOptions;
+          hasAtLeastDefaultHttpListener = if cfg.defaultListen != [] then hasAtLeastHttpListener cfg.defaultListen else (cfg.defaultListenAddresses != []);
+        in
+          all (host:
+            let
+              hasAtLeastVhostHttpListener = if host.listen != [] then hasAtLeastHttpListener host.listen else (host.listenAddresses != []);
+              vhostAuthority = host.listen != [] || (cfg.defaultListen == [] && host.listenAddresses != []);
+            in
+              # Either vhost has precedence and we need a vhost specific http listener
+              # Either vhost set nothing and inherit from server settings
+              host.enableACME -> ((vhostAuthority && hasAtLeastVhostHttpListener) || (!vhostAuthority && hasAtLeastDefaultHttpListener))
+          ) (attrValues virtualHosts);
+        message = ''
+          services.nginx.virtualHosts.<name>.enableACME requires a HTTP listener
+          to answer to ACME requests.
+        '';
+      }
     ] ++ map (name: mkCertOwnershipAssertion {
       inherit (cfg) group user;
       cert = config.security.acme.certs.${name};