nixos/h2o: ACME support + fixups; h2o: add passthru.tests (#383282)

lassulus e4ee61d0 2dc5bfee

Changed files
+430 -155
nixos
modules
services
web-servers
tests
web-servers
h2o
pkgs
by-name
h2
+245 -75
nixos/modules/services/web-servers/h2o/default.nix
···
...
}:
-
# TODO: ACME
# TODO: Gems includes for Mruby
# TODO: Recommended options
let
cfg = config.services.h2o;
+
inherit (config.security.acme) certs;
inherit (lib)
literalExpression
···
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
···
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}";
+
+
names = getNames name value;
+
+
acmeSettings = lib.optionalAttrs (builtins.elem names.cert certNames.dependent) (
+
let
+
acmePort = 80;
+
acmeChallengePath = "/.well-known/acme-challenge";
+
in
+
{
+
"${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}";
+
};
+
};
};
};
-
};
-
}
-
# TLS settings
-
//
-
lib.optionalAttrs
-
(
-
value.tls != null
-
&& builtins.elem value.tls.policy [
-
"add"
-
"only"
-
"force"
-
]
-
)
-
{
-
"${serverName}:${builtins.toString port.TLS}" = value.settings // {
-
listen =
-
let
-
identity = value.tls.identity;
-
in
-
{
-
port = port.TLS;
-
ssl = value.tls.extraSettings or { } // {
-
inherit identity;
+
+
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 = {
···
package = lib.mkPackageOption pkgs "h2o" {
example = ''
pkgs.h2o.override {
-
withMruby = true;
+
withMruby = false;
};
'';
};
···
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 <https://h2o.examp1e.net/configure.html>)";
};
···
};
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.<name>.acme.enable,
+
services.h2o.hosts.<name>.acme.useHost,
+
services.h2o.hosts.<name>.tls.identity, or
+
services.h2o.hosts.<name>.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} =
{
···
};
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";
···
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.<cert>.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;
+
};
}
+69 -16
nixos/modules/services/web-servers/h2o/vhost-options.nix
···
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 {
···
'';
};
identity = mkOption {
-
type = types.nonEmptyListOf (
+
type = types.listOf (
types.submodule {
options = {
key-file = mkOption {
···
};
}
);
-
default = null;
+
default = [ ];
description = ''
Key / certificate pairs for the virtual host.
'';
···
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.
'';
···
);
default = null;
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 {
+111 -63
nixos/tests/step-ca.nix
···
-
import ./make-test-python.nix ({ pkgs, ... }:
+
import ./make-test-python.nix (
+
{ pkgs, ... }:
let
test-certificates = pkgs.runCommandLocal "test-certificates" { } ''
mkdir -p $out
···
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}";
-
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";
-
}
-
];
-
};
+
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;
+
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" ];
+
security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ];
-
networking.firewall.allowedTCPPorts = [ 80 443 ];
+
networking.firewall.allowedTCPPorts = [
+
80
+
443
+
];
-
services.nginx = {
-
enable = true;
-
virtualHosts = {
-
"caclient" = {
-
forceSSL = true;
-
enableACME = true;
-
};
+
services.nginx = {
+
enable = true;
+
virtualHosts = {
+
"caclient" = {
+
forceSSL = true;
+
enableACME = true;
};
};
};
+
};
-
caclientcaddy =
-
{ config, pkgs, ... }: {
-
security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ];
+
caclientcaddy =
+
{ config, pkgs, ... }:
+
{
+
security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ];
-
networking.firewall.allowedTCPPorts = [ 80 443 ];
+
networking.firewall.allowedTCPPorts = [
+
80
+
443
+
];
+
+
services.caddy = {
+
enable = true;
+
virtualHosts."caclientcaddy".extraConfig = ''
+
respond "Welcome to Caddy!"
-
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
+
];
-
tls caddy@example.org {
-
ca https://caserver:8443/acme/acme/directory
-
}
-
'';
+
services.h2o = {
+
enable = true;
+
hosts."caclienth2o" = {
+
tls.policy = "force";
+
acme.enable = true;
+
settings = {
+
paths."/" = {
+
"file.file" = "${pkgs.writeTextFile {
+
name = "h2o_welcome.txt";
+
text = "Welcome to H2O!";
+
}}";
+
};
+
};
};
};
+
};
-
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")
···
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 cURL until success.
+
catester.wait_until_succeeds("curl https://caclientcaddy/ | grep \"Welcome to Caddy!\"")
-
# It's hard to know when caddy has finished the ACME
-
# dance with step-ca, so we keep trying to curl
-
# until succeess.
-
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!\"")
'';
-
})
+
}
+
)
-1
nixos/tests/web-servers/h2o/basic.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
+5
pkgs/by-name/h2/h2o/package.nix
···
withMruby ? true,
bison,
ruby,
+
nixosTests,
}:
stdenv.mkDerivation (finalAttrs: {
···
--prefix "PATH" : "${lib.getBin openssl}/bin"
done
'';
+
+
passthru = {
+
tests = { inherit (nixosTests) h2o; };
+
};
meta = with lib; {
description = "Optimized HTTP/1.x, HTTP/2, HTTP/3 server";