diff --git a/nixos/modules/services/web-servers/h2o/default.nix b/nixos/modules/services/web-servers/h2o/default.nix index 3455ed5c9358..d021ef7d4354 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 @@ -22,6 +22,58 @@ let 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 +81,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 = { @@ -177,6 +261,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} = { @@ -191,10 +316,20 @@ in systemd.services.h2o = { 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; @@ -231,19 +366,66 @@ in CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; }; - script = - let - args = [ - "--conf" - "${h2oConfig}" - "--mode" - "master" - ]; - 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 596b8a362cbc..f0ebf5a7b6d9 100644 --- a/nixos/modules/services/web-servers/h2o/vhost-options.nix +++ b/nixos/modules/services/web-servers/h2o/vhost-options.nix @@ -108,7 +108,7 @@ in }; } ); - default = null; + default = [ ]; description = '' Key / certificate pairs for the virtual host. ''; @@ -129,8 +129,8 @@ in ''; }; extraSettings = mkOption { - type = types.nullOr types.attrs; - default = null; + type = types.attrs; + default = { }; description = '' Additional TLS/SSL-related configuration options. ''; @@ -150,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 = ''