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

This commit is contained in:
lassulus 2025-02-24 17:10:07 +07:00 committed by GitHub
commit e4ee61d0f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 442 additions and 167 deletions

View file

@ -5,11 +5,11 @@
... ...
}: }:
# TODO: ACME
# TODO: Gems includes for Mruby # TODO: Gems includes for Mruby
# TODO: Recommended options # TODO: Recommended options
let let
cfg = config.services.h2o; cfg = config.services.h2o;
inherit (config.security.acme) certs;
inherit (lib) inherit (lib)
literalExpression literalExpression
@ -20,8 +20,62 @@ let
types types
; ;
mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix lib;
settingsFormat = pkgs.formats.yaml { }; 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 ( hostsConfig = lib.concatMapAttrs (
name: value: name: value:
let let
@ -29,56 +83,88 @@ let
HTTP = lib.attrByPath [ "http" "port" ] cfg.defaultHTTPListenPort value; HTTP = lib.attrByPath [ "http" "port" ] cfg.defaultHTTPListenPort value;
TLS = lib.attrByPath [ "tls" "port" ] cfg.defaultTLSListenPort value; TLS = lib.attrByPath [ "tls" "port" ] cfg.defaultTLSListenPort value;
}; };
serverName = if value.serverName != null then value.serverName else name;
in names = getNames name value;
# HTTP settings
lib.optionalAttrs (value.tls == null || value.tls.policy == "add") { acmeSettings = lib.optionalAttrs (builtins.elem names.cert certNames.dependent) (
"${serverName}:${builtins.toString port.HTTP}" = value.settings // { let
listen.port = port.HTTP; acmePort = 80;
}; acmeChallengePath = "/.well-known/acme-challenge";
} in
# 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"
]
)
{ {
"${serverName}:${builtins.toString port.TLS}" = value.settings // { "${names.server}:${builtins.toString acmePort}" = {
listen = listen.port = acmePort;
let paths."${acmeChallengePath}/" = {
identity = value.tls.identity; "file.dir" = value.acme.root + acmeChallengePath;
in };
{
port = port.TLS;
ssl = value.tls.extraSettings or { } // {
inherit identity;
};
};
}; };
} }
);
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; ) cfg.hosts;
h2oConfig = settingsFormat.generate "h2o.yaml" ( h2oConfig = settingsFormat.generate "h2o.yaml" (
lib.recursiveUpdate { hosts = hostsConfig; } cfg.settings 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 in
{ {
options = { options = {
@ -100,7 +186,7 @@ in
package = lib.mkPackageOption pkgs "h2o" { package = lib.mkPackageOption pkgs "h2o" {
example = '' example = ''
pkgs.h2o.override { pkgs.h2o.override {
withMruby = true; withMruby = false;
}; };
''; '';
}; };
@ -123,21 +209,9 @@ in
example = 8443; example = 8443;
}; };
mode = mkOption {
type =
with types;
nullOr (enum [
"daemon"
"master"
"worker"
"test"
]);
default = "master";
description = "Operating mode of H2O";
};
settings = mkOption { settings = mkOption {
type = settingsFormat.type; type = settingsFormat.type;
default = { };
description = "Configuration for H2O (see <https://h2o.examp1e.net/configure.html>)"; description = "Configuration for H2O (see <https://h2o.examp1e.net/configure.html>)";
}; };
@ -189,6 +263,47 @@ in
}; };
config = mkIf cfg.enable { 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 = {
users.${cfg.user} = users.${cfg.user} =
{ {
@ -201,14 +316,25 @@ in
}; };
systemd.services.h2o = { systemd.services.h2o = {
description = "H2O web server service"; description = "H2O HTTP server";
wantedBy = [ "multi-user.target" ]; 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 = { 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"; ExecStop = "${pkgs.coreutils}/bin/kill -s QUIT $MAINPID";
User = cfg.user; User = cfg.user;
Group = cfg.group;
Restart = "always"; Restart = "always";
RestartSec = "10s"; RestartSec = "10s";
RuntimeDirectory = "h2o"; RuntimeDirectory = "h2o";
@ -242,22 +368,66 @@ in
CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
}; };
script = preStart = "${h2oExe} --mode 'test'";
let
args =
[
"--conf"
"${h2oConfig}"
]
++ lib.optionals (cfg.mode != null) [
"--mode"
cfg.mode
];
in
''
${lib.getExe cfg.package} ${lib.strings.escapeShellArgs args}
'';
}; };
};
# 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 Nginxs FIXME, theres 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;
};
} }

View file

@ -19,6 +19,18 @@ in
example = "example.org"; 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 { http = mkOption {
type = types.nullOr ( type = types.nullOr (
types.submodule { types.submodule {
@ -82,7 +94,7 @@ in
''; '';
}; };
identity = mkOption { identity = mkOption {
type = types.nonEmptyListOf ( type = types.listOf (
types.submodule { types.submodule {
options = { options = {
key-file = mkOption { key-file = mkOption {
@ -96,7 +108,7 @@ in
}; };
} }
); );
default = null; default = [ ];
description = '' description = ''
Key / certificate pairs for the virtual host. Key / certificate pairs for the virtual host.
''; '';
@ -104,23 +116,21 @@ in
literalExpression literalExpression
# nix # nix
'' ''
{ [
indentities = [ {
{ key-file = "/path/to/rsa.key";
key-file = "/path/to/rsa.key"; certificate-file = "/path/to/rsa.crt";
certificate-file = "/path/to/rsa.crt"; }
} {
{ key-file = "/path/to/ecdsa.key";
key-file = "/path/to/ecdsa.key"; certificate-file = "/path/to/ecdsa.crt";
certificate-file = "/path/to/ecdsa.crt"; }
} ]
];
}
''; '';
}; };
extraSettings = mkOption { extraSettings = mkOption {
type = types.nullOr types.attrs; type = types.attrs;
default = null; default = { };
description = '' description = ''
Additional TLS/SSL-related configuration options. Additional TLS/SSL-related configuration options.
''; '';
@ -140,6 +150,49 @@ in
description = "TLS options for virtual host"; 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 Lets 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 Lets 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 onesyou 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**. Dont 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 { settings = mkOption {
type = types.attrs; type = types.attrs;
description = '' description = ''

View file

@ -1,4 +1,5 @@
import ./make-test-python.nix ({ pkgs, ... }: import ./make-test-python.nix (
{ pkgs, ... }:
let let
test-certificates = pkgs.runCommandLocal "test-certificates" { } '' test-certificates = pkgs.runCommandLocal "test-certificates" { } ''
mkdir -p $out mkdir -p $out
@ -10,83 +11,127 @@ import ./make-test-python.nix ({ pkgs, ... }:
in in
{ {
name = "step-ca"; name = "step-ca";
nodes = nodes = {
{ caserver =
caserver = { config, pkgs, ... }:
{ config, pkgs, ... }: { {
environment.etc.password-file.source = "${test-certificates}/intermediate-password-file"; environment.etc.password-file.source = "${test-certificates}/intermediate-password-file";
services.step-ca = { services.step-ca = {
enable = true; enable = true;
address = "[::]"; address = "[::]";
port = 8443; port = 8443;
openFirewall = true; openFirewall = true;
intermediatePasswordFile = "/etc/${config.environment.etc.password-file.target}"; 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 = { settings = {
dnsNames = [ "caserver" ]; paths."/" = {
root = "${test-certificates}/root_ca.crt"; "file.file" = "${pkgs.writeTextFile {
crt = "${test-certificates}/intermediate_ca.crt"; name = "h2o_welcome.txt";
key = "${test-certificates}/intermediate_ca.key"; text = "Welcome to H2O!";
db = { }}";
type = "badger";
dataSource = "/var/lib/step-ca/db";
};
authority = {
provisioners = [
{
type = "ACME";
name = "acme";
}
];
}; };
}; };
}; };
}; };
};
caclient = catester =
{ config, pkgs, ... }: { { 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, ... }: {
security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ]; security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ];
}; };
}; };
testScript = testScript = # python
'' ''
catester.start() catester.start()
caserver.wait_for_unit("step-ca.service") 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!\"") catester.succeed("curl https://caclient/ | grep \"Welcome to nginx!\"")
caclientcaddy.wait_for_unit("caddy.service") caclientcaddy.wait_for_unit("caddy.service")
# Its hard to know when Caddy has finished the ACME dance with
# It's hard to know when caddy has finished the ACME # step-ca, so we keep trying cURL until success.
# dance with step-ca, so we keep trying to curl
# until succeess.
catester.wait_until_succeeds("curl https://caclientcaddy/ | grep \"Welcome to Caddy!\"") 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!\"")
''; '';
}) }
)

View file

@ -118,7 +118,6 @@ import ../../make-test-python.nix (
assert "${sawatdi_chao_lok}" in http_hello_world_body 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() 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 "http/2 200" in tls_hello_world_head
assert "server: h2o" in tls_hello_world_head assert "server: h2o" in tls_hello_world_head
assert "content-type: text/x-rst" in tls_hello_world_head assert "content-type: text/x-rst" in tls_hello_world_head

View file

@ -16,6 +16,7 @@
withMruby ? true, withMruby ? true,
bison, bison,
ruby, ruby,
nixosTests,
}: }:
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
@ -71,6 +72,10 @@ stdenv.mkDerivation (finalAttrs: {
done done
''; '';
passthru = {
tests = { inherit (nixosTests) h2o; };
};
meta = with lib; { meta = with lib; {
description = "Optimized HTTP/1.x, HTTP/2, HTTP/3 server"; description = "Optimized HTTP/1.x, HTTP/2, HTTP/3 server";
homepage = "https://h2o.examp1e.net"; homepage = "https://h2o.examp1e.net";