mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-06-27 19:46:40 +03:00
553 lines
18 KiB
Nix
553 lines
18 KiB
Nix
{
|
||
config,
|
||
lib,
|
||
pkgs,
|
||
...
|
||
}:
|
||
|
||
# TODO: Gems includes for Mruby
|
||
let
|
||
cfg = config.services.h2o;
|
||
inherit (config.security.acme) certs;
|
||
|
||
inherit (lib)
|
||
literalExpression
|
||
mkDefault
|
||
mkEnableOption
|
||
mkIf
|
||
mkOption
|
||
types
|
||
;
|
||
|
||
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 {
|
||
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.
|
||
acmeCertNames =
|
||
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;
|
||
};
|
||
|
||
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
|
||
port = {
|
||
HTTP = lib.attrByPath [ "http" "port" ] cfg.defaultHTTPListenPort value;
|
||
TLS = lib.attrByPath [ "tls" "port" ] cfg.defaultTLSListenPort value;
|
||
};
|
||
|
||
names = getNames name value;
|
||
|
||
acmeSettings = lib.optionalAttrs (builtins.elem names.cert acmeCertNames.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}";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
|
||
tlsSettings =
|
||
lib.optionalAttrs
|
||
(
|
||
value.tls != null
|
||
&& builtins.elem value.tls.policy [
|
||
"add"
|
||
"only"
|
||
"force"
|
||
]
|
||
)
|
||
{
|
||
"${names.server}:${builtins.toString port.TLS}" =
|
||
let
|
||
tlsRecommendations = lib.attrByPath [ "tls" "recommendations" ] cfg.defaultTLSRecommendations value;
|
||
|
||
hasTLSRecommendations = tlsRecommendations != null && mozTLSRecs != null;
|
||
|
||
# ATTENTION: Let’s Encrypt has sunset OCSP stapling.
|
||
tlsRecAttrs =
|
||
# If using ACME, this module will disable H2O’s default OCSP
|
||
# stapling.
|
||
#
|
||
# See: https://letsencrypt.org/2024/12/05/ending-ocsp/
|
||
lib.optionalAttrs (builtins.elem names.cert acmeCertNames.all) {
|
||
ocsp-update-interval = 0;
|
||
}
|
||
# Mozilla’s ssl-config-generator is at present still
|
||
# recommending this setting as well, 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
|
||
// 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 ];
|
||
}
|
||
);
|
||
|
||
listen =
|
||
let
|
||
identity =
|
||
value.tls.identity
|
||
++ lib.optional (builtins.elem names.cert acmeCertNames.all) {
|
||
key-file = "${certs.${names.cert}.directory}/key.pem";
|
||
certificate-file = "${certs.${names.cert}.directory}/fullchain.pem";
|
||
};
|
||
|
||
baseListen =
|
||
{
|
||
port = port.TLS;
|
||
ssl = (lib.recursiveUpdate tlsRecAttrs value.tls.extraSettings) // {
|
||
inherit identity;
|
||
};
|
||
}
|
||
// lib.optionalAttrs (value.host != null) {
|
||
host = value.host;
|
||
};
|
||
|
||
# QUIC, if used, will duplicate the TLS over TCP directive, but
|
||
# append some extra QUIC-related settings
|
||
quicListen = lib.optional (value.tls.quic != null) (baseListen // { inherit (value.tls) quic; });
|
||
in
|
||
{
|
||
listen = [ baseListen ] ++ quicListen;
|
||
};
|
||
in
|
||
value.settings // headerRecAttrs // listen;
|
||
};
|
||
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 = {
|
||
services.h2o = {
|
||
enable = mkEnableOption "H2O web server";
|
||
|
||
user = mkOption {
|
||
type = types.nonEmptyStr;
|
||
default = "h2o";
|
||
description = "User running H2O service";
|
||
};
|
||
|
||
group = mkOption {
|
||
type = types.nonEmptyStr;
|
||
default = "h2o";
|
||
description = "Group running H2O services";
|
||
};
|
||
|
||
package = lib.mkPackageOption pkgs "h2o" {
|
||
example = # nix
|
||
''
|
||
pkgs.h2o.override {
|
||
withMruby = false;
|
||
openssl = pkgs.openssl_legacy;
|
||
}
|
||
'';
|
||
};
|
||
|
||
defaultHTTPListenPort = mkOption {
|
||
type = types.port;
|
||
default = 80;
|
||
description = ''
|
||
If hosts do not specify listen.port, use these ports for HTTP by default.
|
||
'';
|
||
example = 8080;
|
||
};
|
||
|
||
defaultTLSListenPort = mkOption {
|
||
type = types.port;
|
||
default = 443;
|
||
description = ''
|
||
If hosts do not specify listen.port, use these ports for SSL by default.
|
||
'';
|
||
example = 8443;
|
||
};
|
||
|
||
defaultTLSRecommendations = tlsRecommendationsOption;
|
||
|
||
settings = mkOption {
|
||
type = settingsFormat.type;
|
||
default = { };
|
||
description = "Configuration for H2O (see <https://h2o.examp1e.net/configure.html>)";
|
||
example =
|
||
literalExpression
|
||
# nix
|
||
''
|
||
{
|
||
compress = "ON";
|
||
ssl-offload = "kernel";
|
||
http2-reprioritize-blocking-assets = "ON";
|
||
"file.mime.addtypes" = {
|
||
"text/x-rst" = {
|
||
extensions = [ ".rst" ];
|
||
is_compressible = "YES";
|
||
};
|
||
};
|
||
}
|
||
'';
|
||
};
|
||
|
||
hosts = mkOption {
|
||
type = types.attrsOf (types.submodule (import ./vhost-options.nix { inherit config lib; }));
|
||
default = { };
|
||
description = ''
|
||
The `hosts` config to be merged with the settings.
|
||
|
||
Note that unlike YAML used for H2O, Nix will not support duplicate
|
||
keys to, for instance, have multiple listens in a host block; use the
|
||
virtual host options in like `http` & `tls` or use `$HOST:$PORT`
|
||
keys if manually specifying config.
|
||
'';
|
||
example =
|
||
literalExpression
|
||
# nix
|
||
''
|
||
{
|
||
"hydra.example.com" = {
|
||
tls = {
|
||
policy = "force";
|
||
identity = [
|
||
{
|
||
key-file = "/path/to/key";
|
||
certificate-file = "/path/to/cert";
|
||
};
|
||
];
|
||
extraSettings = {
|
||
minimum-version = "TLSv1.3";
|
||
};
|
||
};
|
||
settings = {
|
||
paths."/" = {
|
||
"file:dir" = "/var/www/default";
|
||
};
|
||
};
|
||
};
|
||
}
|
||
'';
|
||
};
|
||
};
|
||
};
|
||
|
||
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 (acmeCertNames.all != [ ]) config.systemd.services.h2o-config-reload;
|
||
}
|
||
) acmeCertNames.all;
|
||
|
||
users = {
|
||
users.${cfg.user} =
|
||
{
|
||
group = cfg.group;
|
||
}
|
||
// lib.optionalAttrs (cfg.user == "h2o") {
|
||
isSystemUser = true;
|
||
};
|
||
groups.${cfg.group} = { };
|
||
};
|
||
|
||
systemd.services.h2o = {
|
||
description = "H2O HTTP server";
|
||
wantedBy = [ "multi-user.target" ];
|
||
wants = lib.concatLists (map (certName: [ "acme-finished-${certName}.target" ]) acmeCertNames.all);
|
||
# Since H2O will be hosting the challenges, H2O must be started
|
||
before = builtins.map (certName: "acme-${certName}.service") acmeCertNames.dependent;
|
||
after =
|
||
[ "network.target" ]
|
||
++ builtins.map (certName: "acme-selfsigned-${certName}.service") acmeCertNames.all
|
||
++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.independent; # avoid loading self-signed key w/ real cert, or vice-versa
|
||
|
||
serviceConfig = {
|
||
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";
|
||
RuntimeDirectoryMode = "0750";
|
||
CacheDirectory = "h2o";
|
||
CacheDirectoryMode = "0750";
|
||
LogsDirectory = "h2o";
|
||
LogsDirectoryMode = "0750";
|
||
ProtectSystem = "strict";
|
||
ProtectHome = mkDefault true;
|
||
PrivateTmp = true;
|
||
PrivateDevices = true;
|
||
ProtectHostname = true;
|
||
ProtectClock = true;
|
||
ProtectKernelTunables = true;
|
||
ProtectKernelModules = true;
|
||
ProtectKernelLogs = true;
|
||
ProtectControlGroups = true;
|
||
RestrictAddressFamilies = [
|
||
"AF_UNIX"
|
||
"AF_INET"
|
||
"AF_INET6"
|
||
];
|
||
RestrictNamespaces = true;
|
||
LockPersonality = true;
|
||
RestrictRealtime = true;
|
||
RestrictSUIDSGID = true;
|
||
RemoveIPC = true;
|
||
PrivateMounts = true;
|
||
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
|
||
CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
|
||
};
|
||
|
||
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") acmeCertNames.all;
|
||
tlsServices = map (certName: "acme-${certName}.service") acmeCertNames.all;
|
||
in
|
||
mkIf (acmeCertNames.all != [ ]) {
|
||
wantedBy = tlsServices ++ [ "multi-user.target" ];
|
||
before = tlsTargets;
|
||
after = tlsServices;
|
||
unitConfig = {
|
||
ConditionPathExists = map (
|
||
certName: "${certs.${certName}.directory}/fullchain.pem"
|
||
) acmeCertNames.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;
|
||
};
|
||
}
|