nixos/h2o: TLS recommendations

From Mozilla’s ssl-config-generator project
This commit is contained in:
โทสฺตัล
2025-02-26 12:26:12 +07:00
parent 106d33952f
commit b3f93d7292
5 changed files with 281 additions and 29 deletions
@@ -0,0 +1,46 @@
{ 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 Mozillas
SSL Configuration Generator project (see
<https://ssl-config.mozilla.org>) or read more at Mozillas Wiki (see
<https://wiki.mozilla.org/Security/Server_Side_TLS>).
modern
: Services with clients that support TLS 1.3 & dont 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.
'';
};
}
@@ -6,7 +6,6 @@
}:
# TODO: Gems includes for Mruby
# TODO: Recommended options
let
cfg = config.services.h2o;
inherit (config.security.acme) certs;
@@ -22,6 +21,8 @@ let
mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix lib;
inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption;
settingsFormat = pkgs.formats.yaml { };
getNames = name: vhostSettings: rec {
@@ -76,6 +77,34 @@ let
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
@@ -130,23 +159,79 @@ 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: Lets Encrypt has sunset OCSP stapling. Mozillas
# ssl-config-generator is at present still recommending this setting, but
# this module will skip setting a stapling value as Lets 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,
@@ -184,11 +269,13 @@ in
};
package = lib.mkPackageOption pkgs "h2o" {
example = ''
pkgs.h2o.override {
withMruby = false;
};
'';
example = # nix
''
pkgs.h2o.override {
withMruby = false;
openssl = pkgs.openssl_legacy;
}
'';
};
defaultHTTPListenPort = mkOption {
@@ -209,6 +296,8 @@ in
example = 8443;
};
defaultTLSRecommendations = tlsRecommendationsOption;
settings = mkOption {
type = settingsFormat.type;
default = { };
@@ -216,13 +305,7 @@ in
};
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.
@@ -1,4 +1,8 @@
{ config, lib, ... }:
{
config,
lib,
...
}:
let
inherit (lib)
@@ -6,6 +10,8 @@ let
mkOption
types
;
inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption;
in
{
options = {
@@ -128,6 +134,7 @@ in
]
'';
};
recommendations = tlsRecommendationsOption;
extraSettings = mkOption {
type = types.attrs;
default = { };
+1
View File
@@ -13,4 +13,5 @@ in
{
basic = handleTestOn supportedSystems ./basic.nix { inherit system; };
mruby = handleTestOn supportedSystems ./mruby.nix { inherit system; };
tls-recommendations = handleTestOn supportedSystems ./tls-recommendations.nix { inherit system; };
}
@@ -0,0 +1,115 @@
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)
'';
}
)