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: 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,28 +83,43 @@ 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;
names = getNames name value;
acmeSettings = lib.optionalAttrs (builtins.elem names.cert certNames.dependent) (
let
acmePort = 80;
acmeChallengePath = "/.well-known/acme-challenge";
in
# HTTP settings
{
"${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") {
"${serverName}:${builtins.toString port.HTTP}" = value.settings // {
"${names.server}:${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}" = {
"${names.server}:${builtins.toString port.HTTP}" = {
listen.port = port.HTTP;
paths."/" = {
redirect = {
status = value.tls.redirectCode;
url = "https://${serverName}:${builtins.toString port.TLS}";
url = "https://${names.server}:${builtins.toString port.TLS}";
};
};
};
}
# TLS settings
//
};
tlsSettings =
lib.optionalAttrs
(
value.tls != null
@ -61,24 +130,41 @@ let
]
)
{
"${serverName}:${builtins.toString port.TLS}" = value.settings // {
"${names.server}:${builtins.toString port.TLS}" = value.settings // {
listen =
let
identity = value.tls.identity;
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 or { } // {
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 <https://h2o.examp1e.net/configure.html>)";
};
@ -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.<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} =
{
@ -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 =
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
args =
[
"--conf"
"${h2oConfig}"
]
++ lib.optionals (cfg.mode != null) [
"--mode"
cfg.mode
];
tlsTargets = map (certName: "acme-${certName}.target") certNames.all;
tlsServices = map (certName: "acme-${certName}.service") certNames.all;
in
''
${lib.getExe cfg.package} ${lib.strings.escapeShellArgs args}
'';
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";
};
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,8 +116,7 @@ in
literalExpression
# nix
''
{
indentities = [
[
{
key-file = "/path/to/rsa.key";
certificate-file = "/path/to/rsa.crt";
@ -114,13 +125,12 @@ in
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 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 {
type = types.attrs;
description = ''

View file

@ -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,10 +11,10 @@ import ./make-test-python.nix ({ pkgs, ... }:
in
{
name = "step-ca";
nodes =
{
nodes = {
caserver =
{ config, pkgs, ... }: {
{ config, pkgs, ... }:
{
environment.etc.password-file.source = "${test-certificates}/intermediate-password-file";
services.step-ca = {
enable = true;
@ -43,14 +44,18 @@ import ./make-test-python.nix ({ pkgs, ... }:
};
caclient =
{ 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 ];
networking.firewall.allowedTCPPorts = [
80
443
];
services.nginx = {
enable = true;
@ -64,10 +69,14 @@ import ./make-test-python.nix ({ pkgs, ... }:
};
caclientcaddy =
{ config, pkgs, ... }: {
{ config, pkgs, ... }:
{
security.pki.certificateFiles = [ "${test-certificates}/root_ca.crt" ];
networking.firewall.allowedTCPPorts = [ 80 443 ];
networking.firewall.allowedTCPPorts = [
80
443
];
services.caddy = {
enable = true;
@ -81,12 +90,48 @@ import ./make-test-python.nix ({ pkgs, ... }:
};
};
catester = { config, pkgs, ... }: {
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 = {
paths."/" = {
"file.file" = "${pkgs.writeTextFile {
name = "h2o_welcome.txt";
text = "Welcome to H2O!";
}}";
};
};
};
};
};
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.
# Its 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!\"")
'';
})
}
)

View file

@ -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

View file

@ -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";