nixos/h2o: TLS recommendations

From Mozilla’s ssl-config-generator project

Changed files
+281 -29
nixos
modules
services
tests
+46
nixos/modules/services/web-servers/h2o/common.nix
···
+
{ lib }:
+
{
+
tlsRecommendationsOption = lib.mkOption {
+
type = lib.types.nullOr (
+
lib.types.enum [
+
"modern"
+
"intermediate"
+
"old"
+
]
+
);
+
default = null;
+
example = "intermediate";
+
description = ''
+
By default, H2O, without prejudice, will use as many TLS versions &
+
cipher suites as it & the TLS library (OpenSSL) can support. The user is
+
expected to hone settings for the security of their server. Setting some
+
constraints is recommended, & if unsure about what TLS settings to use,
+
this option gives curated TLS settings recommendations from Mozilla’s
+
‘SSL Configuration Generator’ project (see
+
<https://ssl-config.mozilla.org>) or read more at Mozilla’s Wiki (see
+
<https://wiki.mozilla.org/Security/Server_Side_TLS>).
+
+
modern
+
: Services with clients that support TLS 1.3 & don’t need backward
+
compatibility
+
+
intermediate
+
: General-purpose servers with a variety of clients, recommended for
+
almost all systems
+
+
old
+
: Compatible with a number of very old clients, & should be used only as
+
a last resort
+
+
The default for all virtual hosts can be set with
+
services.h2o.defaultTLSRecommendations, but this value can be overridden
+
on a per-host basis using services.h2o.hosts.<name>.tls.recommmendations.
+
The settings will also be overidden by manual values set with
+
services.settings.h2o.hosts.<name>.tls.extraSettings.
+
+
NOTE: older/weaker ciphers might require overriding the OpenSSL version
+
of H2O (such as `openssl_legacy`). This can be done with
+
sevices.settings.h2o.package.
+
'';
+
};
+
}
+111 -28
nixos/modules/services/web-servers/h2o/default.nix
···
}:
# TODO: Gems includes for Mruby
-
# TODO: Recommended options
let
cfg = config.services.h2o;
inherit (config.security.acme) certs;
···
;
mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix lib;
+
+
inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption;
settingsFormat = pkgs.formats.yaml { };
···
all = certNames'.dependent ++ certNames'.independent;
};
+
mozTLSRecs =
+
if cfg.defaultTLSRecommendations != null then
+
let
+
# NOTE: if updating, *do* verify the changes then adjust ciphers &
+
# other settings with the tests @
+
# `nixos/tests/web-servers/h2o/tls-recommendations.nix`
+
# & run with `nix-build -A nixosTests.h2o.tls-recommendations`
+
version = "5.7";
+
git_tag = "v5.7.1";
+
guidelinesJSON =
+
lib.pipe
+
{
+
urls = [
+
"https://ssl-config.mozilla.org/guidelines/${version}.json"
+
"https://raw.githubusercontent.com/mozilla/ssl-config-generator/refs/tags/${git_tag}/src/static/guidelines/${version}.json"
+
];
+
sha256 = "sha256:1mj2pcb1hg7q2wpgdq3ac8pc2q64wvwvwlkb9xjmdd9jm4hiyny7";
+
}
+
[
+
pkgs.fetchurl
+
builtins.readFile
+
builtins.fromJSON
+
];
+
in
+
guidelinesJSON.configurations
+
else
+
null;
+
hostsConfig = lib.concatMapAttrs (
name: value:
let
···
]
)
{
-
"${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";
+
"${names.server}:${builtins.toString port.TLS}" =
+
let
+
tlsRecommendations = lib.attrByPath [ "tls" "recommendations" ] cfg.defaultTLSRecommendations value;
+
+
hasTLSRecommendations = tlsRecommendations != null && mozTLSRecs != null;
+
+
# NOTE: Let’s Encrypt has sunset OCSP stapling. Mozilla’s
+
# ssl-config-generator is at present still recommending this setting, but
+
# this module will skip setting a stapling value as Let’s Encrypt +
+
# ACME is the most likely use case.
+
#
+
# See: https://github.com/mozilla/ssl-config-generator/issues/323
+
tlsRecAttrs = lib.optionalAttrs hasTLSRecommendations (
+
let
+
recs = mozTLSRecs.${tlsRecommendations};
+
in
+
{
+
min-version = builtins.head recs.tls_versions;
+
cipher-preference = "server";
+
"cipher-suite-tls1.3" = recs.ciphersuites;
+
}
+
// lib.optionalAttrs (recs.ciphers.openssl != [ ]) {
+
cipher-suite = lib.concatStringsSep ":" recs.ciphers.openssl;
+
}
+
);
+
+
headerRecAttrs =
+
lib.optionalAttrs
+
(
+
hasTLSRecommendations
+
&& value.tls != null
+
&& builtins.elem value.tls.policy [
+
"force"
+
"only"
+
]
+
)
+
(
+
let
+
headerSet = value.settings."header.set" or [ ];
+
recs = mozTLSRecs.${tlsRecommendations};
+
hsts = "Strict-Transport-Security: max-age=${builtins.toString recs.hsts_min_age}; includeSubDomains; preload";
+
in
+
{
+
"header.set" =
+
if builtins.isString headerSet then
+
[
+
headerSet
+
hsts
+
]
+
else
+
headerSet ++ [ hsts ];
+
}
+
);
+
in
+
value.settings
+
// headerRecAttrs
+
// {
+
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 = (lib.recursiveUpdate tlsRecAttrs value.tls.extraSettings) // {
+
inherit identity;
};
-
in
-
{
-
port = port.TLS;
-
ssl = value.tls.extraSettings // {
-
inherit identity;
};
-
};
-
};
+
};
};
in
# With a high likelihood of HTTP & ACME challenges being on the same port,
···
};
package = lib.mkPackageOption pkgs "h2o" {
-
example = ''
-
pkgs.h2o.override {
-
withMruby = false;
-
};
-
'';
+
example = # nix
+
''
+
pkgs.h2o.override {
+
withMruby = false;
+
openssl = pkgs.openssl_legacy;
+
}
+
'';
};
defaultHTTPListenPort = mkOption {
···
example = 8443;
};
+
defaultTLSRecommendations = tlsRecommendationsOption;
+
settings = mkOption {
type = settingsFormat.type;
default = { };
···
};
hosts = mkOption {
-
type = types.attrsOf (
-
types.submodule (
-
import ./vhost-options.nix {
-
inherit config lib;
-
}
-
)
-
);
+
type = types.attrsOf (types.submodule (import ./vhost-options.nix { inherit config lib; }));
default = { };
description = ''
The `hosts` config to be merged with the settings.
+8 -1
nixos/modules/services/web-servers/h2o/vhost-options.nix
···
-
{ config, lib, ... }:
+
{
+
config,
+
lib,
+
...
+
}:
let
inherit (lib)
···
mkOption
types
;
+
+
inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption;
in
{
options = {
···
]
'';
};
+
recommendations = tlsRecommendationsOption;
extraSettings = mkOption {
type = types.attrs;
default = { };
+1
nixos/tests/web-servers/h2o/default.nix
···
{
basic = handleTestOn supportedSystems ./basic.nix { inherit system; };
mruby = handleTestOn supportedSystems ./mruby.nix { inherit system; };
+
tls-recommendations = handleTestOn supportedSystems ./tls-recommendations.nix { inherit system; };
}
+115
nixos/tests/web-servers/h2o/tls-recommendations.nix
···
+
import ../../make-test-python.nix (
+
{ lib, pkgs, ... }:
+
+
let
+
domain = "acme.test";
+
port = 8443;
+
+
hello_txt =
+
name:
+
pkgs.writeTextFile {
+
name = "/hello_${name}.txt";
+
text = "Hello, ${name}!";
+
};
+
+
mkH2OServer =
+
recommendations:
+
{ pkgs, lib, ... }:
+
{
+
services.h2o = {
+
enable = true;
+
package = pkgs.h2o.override (
+
lib.optionalAttrs
+
(builtins.elem recommendations [
+
"intermediate"
+
"old"
+
])
+
{
+
openssl = pkgs.openssl_legacy;
+
}
+
);
+
defaultTLSRecommendations = "modern"; # prove overridden
+
hosts = {
+
"${domain}" = {
+
tls = {
+
inherit port recommendations;
+
policy = "force";
+
identity = [
+
{
+
key-file = ../../common/acme/server/acme.test.key.pem;
+
certificate-file = ../../common/acme/server/acme.test.cert.pem;
+
}
+
];
+
};
+
settings = {
+
paths."/"."file.file" = "${hello_txt recommendations}";
+
};
+
};
+
};
+
settings = {
+
ssl-offload = "kernel";
+
};
+
};
+
+
security.pki.certificates = [
+
(builtins.readFile ../../common/acme/server/ca.cert.pem)
+
];
+
+
networking = {
+
firewall.allowedTCPPorts = [ port ];
+
extraHosts = "127.0.0.1 ${domain}";
+
};
+
};
+
in
+
{
+
name = "h2o-tls-recommendations";
+
+
meta = {
+
maintainers = with lib.maintainers; [ toastal ];
+
};
+
+
nodes = {
+
server_modern = mkH2OServer "modern";
+
server_intermediate = mkH2OServer "intermediate";
+
server_old = mkH2OServer "old";
+
};
+
+
testScript =
+
let
+
portStr = builtins.toString port;
+
in
+
# python
+
''
+
curl_basic = "curl -v --tlsv1.3 --http2 'https://${domain}:${portStr}/'"
+
curl_head = "curl -v --head 'https://${domain}:${portStr}/'"
+
curl_max_tls1_2 ="curl -v --tlsv1.0 --tls-max 1.2 'https://${domain}:${portStr}/'"
+
curl_max_tls1_2_intermediate_cipher ="curl -v --tlsv1.0 --tls-max 1.2 --ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256' 'https://${domain}:${portStr}/'"
+
curl_max_tls1_2_old_cipher ="curl -v --tlsv1.0 --tls-max 1.2 --ciphers 'ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256' 'https://${domain}:${portStr}/'"
+
+
server_modern.wait_for_unit("h2o.service")
+
modern_response = server_modern.succeed(curl_basic)
+
assert "Hello, modern!" in modern_response
+
modern_head = server_modern.succeed(curl_head)
+
assert "strict-transport-security" in modern_head
+
server_modern.fail(curl_max_tls1_2)
+
+
server_intermediate.wait_for_unit("h2o.service")
+
intermediate_response = server_intermediate.succeed(curl_basic)
+
assert "Hello, intermediate!" in intermediate_response
+
intermediate_head = server_modern.succeed(curl_head)
+
assert "strict-transport-security" in intermediate_head
+
server_intermediate.succeed(curl_max_tls1_2)
+
server_intermediate.succeed(curl_max_tls1_2_intermediate_cipher)
+
server_intermediate.fail(curl_max_tls1_2_old_cipher)
+
+
server_old.wait_for_unit("h2o.service")
+
old_response = server_old.succeed(curl_basic)
+
assert "Hello, old!" in old_response
+
old_head = server_modern.succeed(curl_head)
+
assert "strict-transport-security" in old_head
+
server_old.succeed(curl_max_tls1_2)
+
server_old.succeed(curl_max_tls1_2_intermediate_cipher)
+
server_old.succeed(curl_max_tls1_2_old_cipher)
+
'';
+
}
+
)