diff --git a/nixos/modules/services/web-servers/h2o/default.nix b/nixos/modules/services/web-servers/h2o/default.nix index 7e021281f91a..e359ea9671d8 100644 --- a/nixos/modules/services/web-servers/h2o/default.nix +++ b/nixos/modules/services/web-servers/h2o/default.nix @@ -5,11 +5,11 @@ ... }: -# TODO: ACME # TODO: Gems includes for Mruby # TODO: Recommended options let cfg = config.services.h2o; + inherit (config.security.acme) certs; inherit (lib) literalExpression @@ -20,8 +20,62 @@ let types ; + mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix lib; + settingsFormat = pkgs.formats.yaml { }; + getNames = name: vhostSettings: rec { + server = if vhostSettings.serverName != null then vhostSettings.serverName else name; + cert = + if lib.attrByPath [ "acme" "useHost" ] null vhostSettings == null then + server + else + vhostSettings.acme.useHost; + }; + + # Attrset with the virtual hosts relevant to ACME configuration + acmeEnabledHostsConfigs = lib.foldlAttrs ( + acc: name: value: + if value.acme == null || (!value.acme.enable && value.acme.useHost == null) then + acc + else + let + names = getNames name value; + virtualHostConfig = value // { + serverName = names.server; + certName = names.cert; + }; + in + acc ++ [ virtualHostConfig ] + ) [ ] cfg.hosts; + + # Attrset with the ACME certificate names split by whether or not they depend + # on H2O serving challenges. + certNames = + let + partition = + acc: vhostSettings: + let + inherit (vhostSettings) certName; + isDependent = certs.${certName}.dnsProvider == null; + in + if isDependent && !(builtins.elem certName acc.dependent) then + acc // { dependent = acc.dependent ++ [ certName ]; } + else if !isDependent && !(builtins.elem certName acc.independent) then + acc // { independent = acc.independent ++ [ certName ]; } + else + acc; + + certNames' = lib.lists.foldl partition { + dependent = [ ]; + independent = [ ]; + } acmeEnabledHostsConfigs; + in + certNames' + // { + all = certNames'.dependent ++ certNames'.independent; + }; + hostsConfig = lib.concatMapAttrs ( name: value: let @@ -29,56 +83,88 @@ let HTTP = lib.attrByPath [ "http" "port" ] cfg.defaultHTTPListenPort value; TLS = lib.attrByPath [ "tls" "port" ] cfg.defaultTLSListenPort value; }; - serverName = if value.serverName != null then value.serverName else name; - in - # HTTP settings - lib.optionalAttrs (value.tls == null || value.tls.policy == "add") { - "${serverName}:${builtins.toString port.HTTP}" = value.settings // { - listen.port = port.HTTP; - }; - } - # Redirect settings - // lib.optionalAttrs (value.tls != null && value.tls.policy == "force") { - "${serverName}:${builtins.toString port.HTTP}" = { - listen.port = port.HTTP; - paths."/" = { - redirect = { - status = value.tls.redirectCode; - url = "https://${serverName}:${builtins.toString port.TLS}"; - }; - }; - }; - } - # TLS settings - // - lib.optionalAttrs - ( - value.tls != null - && builtins.elem value.tls.policy [ - "add" - "only" - "force" - ] - ) + + names = getNames name value; + + acmeSettings = lib.optionalAttrs (builtins.elem names.cert certNames.dependent) ( + let + acmePort = 80; + acmeChallengePath = "/.well-known/acme-challenge"; + in { - "${serverName}:${builtins.toString port.TLS}" = value.settings // { - listen = - let - identity = value.tls.identity; - in - { - port = port.TLS; - ssl = value.tls.extraSettings or { } // { - inherit identity; - }; - }; + "${names.server}:${builtins.toString acmePort}" = { + listen.port = acmePort; + paths."${acmeChallengePath}/" = { + "file.dir" = value.acme.root + acmeChallengePath; + }; }; } + ); + + httpSettings = + lib.optionalAttrs (value.tls == null || value.tls.policy == "add") { + "${names.server}:${builtins.toString port.HTTP}" = value.settings // { + listen.port = port.HTTP; + }; + } + // lib.optionalAttrs (value.tls != null && value.tls.policy == "force") { + "${names.server}:${builtins.toString port.HTTP}" = { + listen.port = port.HTTP; + paths."/" = { + redirect = { + status = value.tls.redirectCode; + url = "https://${names.server}:${builtins.toString port.TLS}"; + }; + }; + }; + }; + + tlsSettings = + lib.optionalAttrs + ( + value.tls != null + && builtins.elem value.tls.policy [ + "add" + "only" + "force" + ] + ) + { + "${names.server}:${builtins.toString port.TLS}" = value.settings // { + listen = + let + identity = + value.tls.identity + ++ lib.optional (builtins.elem names.cert certNames.all) { + key-file = "${certs.${names.cert}.directory}/key.pem"; + certificate-file = "${certs.${names.cert}.directory}/fullchain.pem"; + }; + in + { + port = port.TLS; + ssl = value.tls.extraSettings // { + inherit identity; + }; + }; + }; + }; + in + # With a high likelihood of HTTP & ACME challenges being on the same port, + # 80, do a recursive update to merge the 2 settings together + (lib.recursiveUpdate acmeSettings httpSettings) // tlsSettings ) cfg.hosts; h2oConfig = settingsFormat.generate "h2o.yaml" ( lib.recursiveUpdate { hosts = hostsConfig; } cfg.settings ); + + # Executing H2O with our generated configuration; `mode` added as needed + h2oExe = ''${lib.getExe cfg.package} ${ + lib.strings.escapeShellArgs [ + "--conf" + "${h2oConfig}" + ] + }''; in { options = { @@ -100,7 +186,7 @@ in package = lib.mkPackageOption pkgs "h2o" { example = '' pkgs.h2o.override { - withMruby = true; + withMruby = false; }; ''; }; @@ -123,21 +209,9 @@ in example = 8443; }; - mode = mkOption { - type = - with types; - nullOr (enum [ - "daemon" - "master" - "worker" - "test" - ]); - default = "master"; - description = "Operating mode of H2O"; - }; - settings = mkOption { type = settingsFormat.type; + default = { }; description = "Configuration for H2O (see )"; }; @@ -189,6 +263,47 @@ in }; config = mkIf cfg.enable { + assertions = + [ + { + assertion = + !(builtins.hasAttr "hosts" h2oConfig) + || builtins.all ( + host: + let + hasKeyPlusCert = attrs: (attrs.key-file or "") != "" && (attrs.certificate-file or "") != ""; + in + # TLS not used + (lib.attrByPath [ "listen" "ssl" ] null host == null) + # TLS identity property + || ( + builtins.hasAttr "identity" host + && builtins.length host.identity > 0 + && builtins.all hasKeyPlusCert host.listen.ssl.identity + ) + # TLS short-hand (was manually specified) + || (hasKeyPlusCert host.listen.ssl) + ) (lib.attrValues h2oConfig.hosts); + message = '' + TLS support will require at least one non-empty certificate & key + file. Use services.h2o.hosts..acme.enable, + services.h2o.hosts..acme.useHost, + services.h2o.hosts..tls.identity, or + services.h2o.hosts..tls.extraSettings. + ''; + } + ] + ++ builtins.map ( + name: + mkCertOwnershipAssertion { + cert = certs.${name}; + groups = config.users.groups; + services = [ + config.systemd.services.h2o + ] ++ lib.optional (certNames.all != [ ]) config.systemd.services.h2o-config-reload; + } + ) certNames.all; + users = { users.${cfg.user} = { @@ -201,14 +316,25 @@ in }; systemd.services.h2o = { - description = "H2O web server service"; + description = "H2O HTTP server"; wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; + wants = lib.concatLists (map (certName: [ "acme-finished-${certName}.target" ]) certNames.all); + # Since H2O will be hosting the challenges, H2O must be started + before = builtins.map (certName: "acme-${certName}.service") certNames.dependent; + after = + [ "network.target" ] + ++ builtins.map (certName: "acme-selfsigned-${certName}.service") certNames.all + ++ builtins.map (certName: "acme-${certName}.service") certNames.independent; # avoid loading self-signed key w/ real cert, or vice-versa serviceConfig = { - ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + ExecStart = "${h2oExe} --mode 'master'"; + ExecReload = [ + "${h2oExe} --mode 'test'" + "${pkgs.coreutils}/bin/kill -HUP $MAINPID" + ]; ExecStop = "${pkgs.coreutils}/bin/kill -s QUIT $MAINPID"; User = cfg.user; + Group = cfg.group; Restart = "always"; RestartSec = "10s"; RuntimeDirectory = "h2o"; @@ -242,22 +368,66 @@ in CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; }; - script = - let - args = - [ - "--conf" - "${h2oConfig}" - ] - ++ lib.optionals (cfg.mode != null) [ - "--mode" - cfg.mode - ]; - in - '' - ${lib.getExe cfg.package} ${lib.strings.escapeShellArgs args} - ''; + preStart = "${h2oExe} --mode 'test'"; }; - }; + # This service waits for all certificates to be available before reloading + # H2O configuration. `tlsTargets` are added to `wantedBy` + `before` which + # allows the `acme-finished-$cert.target` to signify the successful updating + # of certs end-to-end. + systemd.services.h2o-config-reload = + let + tlsTargets = map (certName: "acme-${certName}.target") certNames.all; + tlsServices = map (certName: "acme-${certName}.service") certNames.all; + in + mkIf (certNames.all != [ ]) { + wantedBy = tlsServices ++ [ "multi-user.target" ]; + before = tlsTargets; + after = tlsServices; + unitConfig = { + ConditionPathExists = map (certName: "${certs.${certName}.directory}/fullchain.pem") certNames.all; + # Disable rate limiting for this since it may be triggered quickly + # a bunch of times if a lot of certificates are renewed in quick + # succession. The reload itself is cheap, so even doing a lot of them + # in a short burst is fine. + # + # FIXME: like Nginx’s FIXME, there’s probably a better way to do + # this. + StartLimitIntervalSec = 0; + }; + serviceConfig = { + Type = "oneshot"; + TimeoutSec = 60; + ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active h2o.service"; + ExecStart = "/run/current-system/systemd/bin/systemctl reload h2o.service"; + }; + }; + + security.acme.certs = + let + mkCerts = + acc: vhostSettings: + if vhostSettings.acme.useHost == null then + let + hasRoot = vhostSettings.acme.root != null; + in + acc + // { + "${vhostSettings.serverName}" = { + group = mkDefault cfg.group; + # If `acme.root` is `null`, inherit `config.security.acme`. + # Since `config.security.acme.certs..webroot`’s own + # default value should take precedence set priority higher than + # mkOptionDefault + webroot = lib.mkOverride (if hasRoot then 1000 else 2000) vhostSettings.acme.root; + # Also nudge dnsProvider to null in case it is inherited + dnsProvider = lib.mkOverride (if hasRoot then 1000 else 2000) null; + extraDomainNames = vhostSettings.serverAliases; + }; + } + else + acc; + in + lib.lists.foldl mkCerts { } acmeEnabledHostsConfigs; + }; } diff --git a/nixos/modules/services/web-servers/h2o/vhost-options.nix b/nixos/modules/services/web-servers/h2o/vhost-options.nix index 26abf8eb4f6a..f0ebf5a7b6d9 100644 --- a/nixos/modules/services/web-servers/h2o/vhost-options.nix +++ b/nixos/modules/services/web-servers/h2o/vhost-options.nix @@ -19,6 +19,18 @@ in example = "example.org"; }; + serverAliases = mkOption { + type = types.listOf types.nonEmptyStr; + default = [ ]; + example = [ + "www.example.org" + "example.org" + ]; + description = '' + Additional names of virtual hosts served by this virtual host configuration. + ''; + }; + http = mkOption { type = types.nullOr ( types.submodule { @@ -82,7 +94,7 @@ in ''; }; identity = mkOption { - type = types.nonEmptyListOf ( + type = types.listOf ( types.submodule { options = { key-file = mkOption { @@ -96,7 +108,7 @@ in }; } ); - default = null; + default = [ ]; description = '' Key / certificate pairs for the virtual host. ''; @@ -104,23 +116,21 @@ in literalExpression # nix '' - { - indentities = [ - { - key-file = "/path/to/rsa.key"; - certificate-file = "/path/to/rsa.crt"; - } - { - key-file = "/path/to/ecdsa.key"; - certificate-file = "/path/to/ecdsa.crt"; - } - ]; - } + [ + { + key-file = "/path/to/rsa.key"; + certificate-file = "/path/to/rsa.crt"; + } + { + key-file = "/path/to/ecdsa.key"; + certificate-file = "/path/to/ecdsa.crt"; + } + ] ''; }; extraSettings = mkOption { - type = types.nullOr types.attrs; - default = null; + type = types.attrs; + default = { }; description = '' Additional TLS/SSL-related configuration options. ''; @@ -140,6 +150,49 @@ in description = "TLS options for virtual host"; }; + acme = mkOption { + type = types.nullOr ( + types.addCheck (types.submodule { + options = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to ask Let’s Encrypt to sign a certificate for this + virtual host. Alternatively, an existing host can be used thru + {option}`acme.useHost`. + ''; + }; + useHost = mkOption { + type = types.nullOr types.nonEmptyStr; + default = null; + description = '' + An existing Let’s Encrypt certificate to use for this virtual + host. This is useful if you have many subdomains and want to + avoid hitting the [rate + limit](https://letsencrypt.org/docs/rate-limits). Alternately, + you can generate a certificate through {option}`acme.enable`. + Note that this option neither creates any certificates nor does + it add subdomains to existing ones — you will need to create + them manually using [](#opt-security.acme.certs). + ''; + }; + root = mkOption { + type = types.nullOr types.path; + default = "/var/lib/acme/acme-challenge"; + description = '' + Directory for the ACME challenge, which is **public**. Don’t put + certs or keys in here. Set to `null` to inherit from + config.security.acme. + ''; + }; + }; + }) (a: (a.enable || a.useHost != null) && !(a.enable && a.useHost != null)) + ); + default = null; + description = "ACME options for virtual host."; + }; + settings = mkOption { type = types.attrs; description = '' diff --git a/nixos/tests/step-ca.nix b/nixos/tests/step-ca.nix index 4f2ff71c85e8..69b22bfd424a 100644 --- a/nixos/tests/step-ca.nix +++ b/nixos/tests/step-ca.nix @@ -1,4 +1,5 @@ -import ./make-test-python.nix ({ pkgs, ... }: +import ./make-test-python.nix ( + { pkgs, ... }: let test-certificates = pkgs.runCommandLocal "test-certificates" { } '' mkdir -p $out @@ -10,83 +11,127 @@ import ./make-test-python.nix ({ pkgs, ... }: in { name = "step-ca"; - nodes = - { - caserver = - { config, pkgs, ... }: { - environment.etc.password-file.source = "${test-certificates}/intermediate-password-file"; - services.step-ca = { - enable = true; - address = "[::]"; - port = 8443; - openFirewall = true; - intermediatePasswordFile = "/etc/${config.environment.etc.password-file.target}"; + nodes = { + caserver = + { config, pkgs, ... }: + { + environment.etc.password-file.source = "${test-certificates}/intermediate-password-file"; + services.step-ca = { + enable = true; + address = "[::]"; + port = 8443; + openFirewall = true; + intermediatePasswordFile = "/etc/${config.environment.etc.password-file.target}"; + settings = { + dnsNames = [ "caserver" ]; + root = "${test-certificates}/root_ca.crt"; + crt = "${test-certificates}/intermediate_ca.crt"; + key = "${test-certificates}/intermediate_ca.key"; + db = { + type = "badger"; + dataSource = "/var/lib/step-ca/db"; + }; + authority = { + provisioners = [ + { + type = "ACME"; + name = "acme"; + } + ]; + }; + }; + }; + }; + + caclient = + { config, pkgs, ... }: + { + security.acme.defaults.server = "https://caserver:8443/acme/acme/directory"; + security.acme.defaults.email = "root@example.org"; + security.acme.acceptTerms = true; + + security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; + + networking.firewall.allowedTCPPorts = [ + 80 + 443 + ]; + + services.nginx = { + enable = true; + virtualHosts = { + "caclient" = { + forceSSL = true; + enableACME = true; + }; + }; + }; + }; + + caclientcaddy = + { config, pkgs, ... }: + { + security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; + + networking.firewall.allowedTCPPorts = [ + 80 + 443 + ]; + + services.caddy = { + enable = true; + virtualHosts."caclientcaddy".extraConfig = '' + respond "Welcome to Caddy!" + + tls caddy@example.org { + ca https://caserver:8443/acme/acme/directory + } + ''; + }; + }; + + caclienth2o = + { config, pkgs, ... }: + { + security.acme = { + acceptTerms = true; + defaults = { + server = "https://caserver:8443/acme/acme/directory"; + email = "root@example.org"; + }; + }; + security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; + + networking.firewall.allowedTCPPorts = [ + 80 + 443 + ]; + + services.h2o = { + enable = true; + hosts."caclienth2o" = { + tls.policy = "force"; + acme.enable = true; settings = { - dnsNames = [ "caserver" ]; - root = "${test-certificates}/root_ca.crt"; - crt = "${test-certificates}/intermediate_ca.crt"; - key = "${test-certificates}/intermediate_ca.key"; - db = { - type = "badger"; - dataSource = "/var/lib/step-ca/db"; - }; - authority = { - provisioners = [ - { - type = "ACME"; - name = "acme"; - } - ]; + paths."/" = { + "file.file" = "${pkgs.writeTextFile { + name = "h2o_welcome.txt"; + text = "Welcome to H2O!"; + }}"; }; }; }; }; + }; - caclient = - { config, pkgs, ... }: { - security.acme.defaults.server = "https://caserver:8443/acme/acme/directory"; - security.acme.defaults.email = "root@example.org"; - security.acme.acceptTerms = true; - - security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; - - networking.firewall.allowedTCPPorts = [ 80 443 ]; - - services.nginx = { - enable = true; - virtualHosts = { - "caclient" = { - forceSSL = true; - enableACME = true; - }; - }; - }; - }; - - caclientcaddy = - { config, pkgs, ... }: { - security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; - - networking.firewall.allowedTCPPorts = [ 80 443 ]; - - services.caddy = { - enable = true; - virtualHosts."caclientcaddy".extraConfig = '' - respond "Welcome to Caddy!" - - tls caddy@example.org { - ca https://caserver:8443/acme/acme/directory - } - ''; - }; - }; - - catester = { config, pkgs, ... }: { + catester = + { config, pkgs, ... }: + { security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; }; - }; + }; - testScript = + testScript = # python '' catester.start() caserver.wait_for_unit("step-ca.service") @@ -96,10 +141,13 @@ import ./make-test-python.nix ({ pkgs, ... }: catester.succeed("curl https://caclient/ | grep \"Welcome to nginx!\"") caclientcaddy.wait_for_unit("caddy.service") - - # It's hard to know when caddy has finished the ACME - # dance with step-ca, so we keep trying to curl - # until succeess. + # It’s hard to know when Caddy has finished the ACME dance with + # step-ca, so we keep trying cURL until success. catester.wait_until_succeeds("curl https://caclientcaddy/ | grep \"Welcome to Caddy!\"") + + caclienth2o.wait_for_unit("acme-finished-caclienth2o.target") + caclienth2o.wait_for_unit("h2o.service") + catester.succeed("curl https://caclienth2o/ | grep \"Welcome to H2O!\"") ''; - }) + } +) diff --git a/nixos/tests/web-servers/h2o/basic.nix b/nixos/tests/web-servers/h2o/basic.nix index 673e082322bb..5432a3b04172 100644 --- a/nixos/tests/web-servers/h2o/basic.nix +++ b/nixos/tests/web-servers/h2o/basic.nix @@ -118,7 +118,6 @@ import ../../make-test-python.nix ( assert "${sawatdi_chao_lok}" in http_hello_world_body tls_hello_world_head = server.succeed("curl -v --head --compressed --http2 --tlsv1.3 --fail-with-body 'https://${domain.TLS}:${builtins.toString port.TLS}/hello_world.rst'").lower() - print(tls_hello_world_head) assert "http/2 200" in tls_hello_world_head assert "server: h2o" in tls_hello_world_head assert "content-type: text/x-rst" in tls_hello_world_head diff --git a/pkgs/by-name/h2/h2o/package.nix b/pkgs/by-name/h2/h2o/package.nix index 66f1e5712bef..5afdcbeab9aa 100644 --- a/pkgs/by-name/h2/h2o/package.nix +++ b/pkgs/by-name/h2/h2o/package.nix @@ -16,6 +16,7 @@ withMruby ? true, bison, ruby, + nixosTests, }: stdenv.mkDerivation (finalAttrs: { @@ -71,6 +72,10 @@ stdenv.mkDerivation (finalAttrs: { done ''; + passthru = { + tests = { inherit (nixosTests) h2o; }; + }; + meta = with lib; { description = "Optimized HTTP/1.x, HTTP/2, HTTP/3 server"; homepage = "https://h2o.examp1e.net";