1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8# TODO: Gems includes for Mruby
9let
10 cfg = config.services.h2o;
11 inherit (config.security.acme) certs;
12
13 inherit (lib)
14 literalExpression
15 mkDefault
16 mkEnableOption
17 mkIf
18 mkOption
19 types
20 ;
21
22 mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix lib;
23
24 inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption;
25
26 settingsFormat = pkgs.formats.yaml { };
27
28 getNames = name: vhostSettings: rec {
29 server = if vhostSettings.serverName != null then vhostSettings.serverName else name;
30 cert =
31 if lib.attrByPath [ "acme" "useHost" ] null vhostSettings == null then
32 server
33 else
34 vhostSettings.acme.useHost;
35 };
36
37 # Attrset with the virtual hosts relevant to ACME configuration
38 acmeEnabledHostsConfigs = lib.foldlAttrs (
39 acc: name: value:
40 if value.acme == null || (!value.acme.enable && value.acme.useHost == null) then
41 acc
42 else
43 let
44 names = getNames name value;
45 virtualHostConfig = value // {
46 serverName = names.server;
47 certName = names.cert;
48 };
49 in
50 acc ++ [ virtualHostConfig ]
51 ) [ ] cfg.hosts;
52
53 # Attrset with the ACME certificate names split by whether or not they depend
54 # on H2O serving challenges.
55 acmeCertNames =
56 let
57 partition =
58 acc: vhostSettings:
59 let
60 inherit (vhostSettings) certName;
61 isDependent = certs.${certName}.dnsProvider == null;
62 in
63 if isDependent && !(builtins.elem certName acc.dependent) then
64 acc // { dependent = acc.dependent ++ [ certName ]; }
65 else if !isDependent && !(builtins.elem certName acc.independent) then
66 acc // { independent = acc.independent ++ [ certName ]; }
67 else
68 acc;
69
70 certNames = lib.lists.foldl partition {
71 dependent = [ ];
72 independent = [ ];
73 } acmeEnabledHostsConfigs;
74 in
75 certNames
76 // {
77 all = certNames.dependent ++ certNames.independent;
78 };
79
80 mozTLSRecs =
81 if cfg.defaultTLSRecommendations != null then
82 let
83 # NOTE: if updating, *do* verify the changes then adjust ciphers &
84 # other settings with the tests @
85 # `nixos/tests/web-servers/h2o/tls-recommendations.nix`
86 # & run with `nix-build -A nixosTests.h2o.tls-recommendations`
87 version = "5.7";
88 git_tag = "v5.7.1";
89 guidelinesJSON =
90 lib.pipe
91 {
92 urls = [
93 "https://ssl-config.mozilla.org/guidelines/${version}.json"
94 "https://raw.githubusercontent.com/mozilla/ssl-config-generator/refs/tags/${git_tag}/src/static/guidelines/${version}.json"
95 ];
96 sha256 = "sha256:1mj2pcb1hg7q2wpgdq3ac8pc2q64wvwvwlkb9xjmdd9jm4hiyny7";
97 }
98 [
99 pkgs.fetchurl
100 builtins.readFile
101 builtins.fromJSON
102 ];
103 in
104 guidelinesJSON.configurations
105 else
106 null;
107
108 hostsConfig = lib.concatMapAttrs (
109 name: value:
110 let
111 port = {
112 HTTP = lib.attrByPath [ "http" "port" ] cfg.defaultHTTPListenPort value;
113 TLS = lib.attrByPath [ "tls" "port" ] cfg.defaultTLSListenPort value;
114 };
115
116 names = getNames name value;
117
118 acmeSettings = lib.optionalAttrs (builtins.elem names.cert acmeCertNames.dependent) (
119 let
120 acmePort = 80;
121 acmeChallengePath = "/.well-known/acme-challenge";
122 in
123 {
124 "${names.server}:${builtins.toString acmePort}" = {
125 listen.port = acmePort;
126 paths."${acmeChallengePath}/" = {
127 "file.dir" = value.acme.root + acmeChallengePath;
128 };
129 };
130 }
131 );
132
133 httpSettings =
134 lib.optionalAttrs (value.tls == null || value.tls.policy == "add") {
135 "${names.server}:${builtins.toString port.HTTP}" = value.settings // {
136 listen.port = port.HTTP;
137 };
138 }
139 // lib.optionalAttrs (value.tls != null && value.tls.policy == "force") {
140 "${names.server}:${builtins.toString port.HTTP}" = {
141 listen.port = port.HTTP;
142 paths."/" = {
143 redirect = {
144 status = value.tls.redirectCode;
145 url = "https://${names.server}:${builtins.toString port.TLS}";
146 };
147 };
148 };
149 };
150
151 tlsSettings =
152 lib.optionalAttrs
153 (
154 value.tls != null
155 && builtins.elem value.tls.policy [
156 "add"
157 "only"
158 "force"
159 ]
160 )
161 {
162 "${names.server}:${builtins.toString port.TLS}" =
163 let
164 tlsRecommendations = lib.attrByPath [ "tls" "recommendations" ] cfg.defaultTLSRecommendations value;
165
166 hasTLSRecommendations = tlsRecommendations != null && mozTLSRecs != null;
167
168 # ATTENTION: Let’s Encrypt has sunset OCSP stapling.
169 tlsRecAttrs =
170 # If using ACME, this module will disable H2O’s default OCSP
171 # stapling.
172 #
173 # See: https://letsencrypt.org/2024/12/05/ending-ocsp/
174 lib.optionalAttrs (builtins.elem names.cert acmeCertNames.all) {
175 ocsp-update-interval = 0;
176 }
177 # Mozilla’s ssl-config-generator is at present still
178 # recommending this setting as well, but this module will
179 # skip setting a stapling value as Let’s Encrypt + ACME is
180 # the most likely use case.
181 #
182 # See: https://github.com/mozilla/ssl-config-generator/issues/323
183 // lib.optionalAttrs hasTLSRecommendations (
184 let
185 recs = mozTLSRecs.${tlsRecommendations};
186 in
187 {
188 min-version = builtins.head recs.tls_versions;
189 cipher-preference = "server";
190 "cipher-suite-tls1.3" = recs.ciphersuites;
191 }
192 // lib.optionalAttrs (recs.ciphers.openssl != [ ]) {
193 cipher-suite = lib.concatStringsSep ":" recs.ciphers.openssl;
194 }
195 );
196
197 headerRecAttrs =
198 lib.optionalAttrs
199 (
200 hasTLSRecommendations
201 && value.tls != null
202 && builtins.elem value.tls.policy [
203 "force"
204 "only"
205 ]
206 )
207 (
208 let
209 headerSet = value.settings."header.set" or [ ];
210 recs = mozTLSRecs.${tlsRecommendations};
211 hsts = "Strict-Transport-Security: max-age=${builtins.toString recs.hsts_min_age}; includeSubDomains; preload";
212 in
213 {
214 "header.set" =
215 if builtins.isString headerSet then
216 [
217 headerSet
218 hsts
219 ]
220 else
221 headerSet ++ [ hsts ];
222 }
223 );
224
225 listen =
226 let
227 identity =
228 value.tls.identity
229 ++ lib.optional (builtins.elem names.cert acmeCertNames.all) {
230 key-file = "${certs.${names.cert}.directory}/key.pem";
231 certificate-file = "${certs.${names.cert}.directory}/fullchain.pem";
232 };
233
234 baseListen = {
235 port = port.TLS;
236 ssl = (lib.recursiveUpdate tlsRecAttrs value.tls.extraSettings) // {
237 inherit identity;
238 };
239 }
240 // lib.optionalAttrs (value.host != null) {
241 host = value.host;
242 };
243
244 # QUIC, if used, will duplicate the TLS over TCP directive, but
245 # append some extra QUIC-related settings
246 quicListen = lib.optional (value.tls.quic != null) (baseListen // { inherit (value.tls) quic; });
247 in
248 {
249 listen = [ baseListen ] ++ quicListen;
250 };
251 in
252 value.settings // headerRecAttrs // listen;
253 };
254 in
255 # With a high likelihood of HTTP & ACME challenges being on the same port,
256 # 80, do a recursive update to merge the 2 settings together
257 (lib.recursiveUpdate acmeSettings httpSettings) // tlsSettings
258 ) cfg.hosts;
259
260 h2oConfig = settingsFormat.generate "h2o.yaml" (
261 lib.recursiveUpdate { hosts = hostsConfig; } cfg.settings
262 );
263
264 # Executing H2O with our generated configuration; `mode` added as needed
265 h2oExe = ''${lib.getExe cfg.package} ${
266 lib.strings.escapeShellArgs [
267 "--conf"
268 "${h2oConfig}"
269 ]
270 }'';
271in
272{
273 options = {
274 services.h2o = {
275 enable = mkEnableOption "H2O web server";
276
277 user = mkOption {
278 type = types.nonEmptyStr;
279 default = "h2o";
280 description = "User running H2O service";
281 };
282
283 group = mkOption {
284 type = types.nonEmptyStr;
285 default = "h2o";
286 description = "Group running H2O services";
287 };
288
289 package = lib.mkPackageOption pkgs "h2o" {
290 example = # nix
291 ''
292 pkgs.h2o.override {
293 withMruby = false;
294 openssl = pkgs.openssl_legacy;
295 }
296 '';
297 };
298
299 defaultHTTPListenPort = mkOption {
300 type = types.port;
301 default = 80;
302 description = ''
303 If hosts do not specify listen.port, use these ports for HTTP by default.
304 '';
305 example = 8080;
306 };
307
308 defaultTLSListenPort = mkOption {
309 type = types.port;
310 default = 443;
311 description = ''
312 If hosts do not specify listen.port, use these ports for SSL by default.
313 '';
314 example = 8443;
315 };
316
317 defaultTLSRecommendations = tlsRecommendationsOption;
318
319 settings = mkOption {
320 type = settingsFormat.type;
321 default = { };
322 description = "Configuration for H2O (see <https://h2o.examp1e.net/configure.html>)";
323 example =
324 literalExpression
325 # nix
326 ''
327 {
328 compress = "ON";
329 ssl-offload = "kernel";
330 http2-reprioritize-blocking-assets = "ON";
331 "file.mime.addtypes" = {
332 "text/x-rst" = {
333 extensions = [ ".rst" ];
334 is_compressible = "YES";
335 };
336 };
337 }
338 '';
339 };
340
341 hosts = mkOption {
342 type = types.attrsOf (types.submodule (import ./vhost-options.nix { inherit config lib; }));
343 default = { };
344 description = ''
345 The `hosts` config to be merged with the settings.
346
347 Note that unlike YAML used for H2O, Nix will not support duplicate
348 keys to, for instance, have multiple listens in a host block; use the
349 virtual host options in like `http` & `tls` or use `$HOST:$PORT`
350 keys if manually specifying config.
351 '';
352 example =
353 literalExpression
354 # nix
355 ''
356 {
357 "hydra.example.com" = {
358 tls = {
359 policy = "force";
360 identity = [
361 {
362 key-file = "/path/to/key";
363 certificate-file = "/path/to/cert";
364 };
365 ];
366 extraSettings = {
367 minimum-version = "TLSv1.3";
368 };
369 };
370 settings = {
371 paths."/" = {
372 "file:dir" = "/var/www/default";
373 };
374 };
375 };
376 }
377 '';
378 };
379 };
380 };
381
382 config = mkIf cfg.enable {
383 assertions = [
384 {
385 assertion =
386 !(builtins.hasAttr "hosts" h2oConfig)
387 || builtins.all (
388 host:
389 let
390 hasKeyPlusCert = attrs: (attrs.key-file or "") != "" && (attrs.certificate-file or "") != "";
391 in
392 # TLS not used
393 (lib.attrByPath [ "listen" "ssl" ] null host == null)
394 # TLS identity property
395 || (
396 builtins.hasAttr "identity" host
397 && builtins.length host.identity > 0
398 && builtins.all hasKeyPlusCert host.listen.ssl.identity
399 )
400 # TLS short-hand (was manually specified)
401 || (hasKeyPlusCert host.listen.ssl)
402 ) (lib.attrValues h2oConfig.hosts);
403 message = ''
404 TLS support will require at least one non-empty certificate & key
405 file. Use services.h2o.hosts.<name>.acme.enable,
406 services.h2o.hosts.<name>.acme.useHost,
407 services.h2o.hosts.<name>.tls.identity, or
408 services.h2o.hosts.<name>.tls.extraSettings.
409 '';
410 }
411 ]
412 ++ builtins.map (
413 name:
414 mkCertOwnershipAssertion {
415 cert = certs.${name};
416 groups = config.users.groups;
417 services = [
418 config.systemd.services.h2o
419 ]
420 ++ lib.optional (acmeCertNames.all != [ ]) config.systemd.services.h2o-config-reload;
421 }
422 ) acmeCertNames.all;
423
424 users = {
425 users.${cfg.user} = {
426 group = cfg.group;
427 }
428 // lib.optionalAttrs (cfg.user == "h2o") {
429 isSystemUser = true;
430 };
431 groups.${cfg.group} = { };
432 };
433
434 systemd.services.h2o = {
435 description = "H2O HTTP server";
436 wantedBy = [ "multi-user.target" ];
437 wants = lib.concatLists (map (certName: [ "acme-${certName}.service" ]) acmeCertNames.all);
438 # Since H2O will be hosting the challenges, H2O must be started
439 before = builtins.map (certName: "acme-order-renew-${certName}.service") acmeCertNames.all;
440 after = [
441 "network.target"
442 ]
443 ++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.all;
444
445 serviceConfig = {
446 ExecStart = "${h2oExe} --mode 'master'";
447 ExecReload = [
448 "${h2oExe} --mode 'test'"
449 "${pkgs.coreutils}/bin/kill -HUP $MAINPID"
450 ];
451 ExecStop = "${pkgs.coreutils}/bin/kill -s QUIT $MAINPID";
452 User = cfg.user;
453 Group = cfg.group;
454 Restart = "always";
455 RestartSec = "10s";
456 RuntimeDirectory = "h2o";
457 RuntimeDirectoryMode = "0750";
458 CacheDirectory = "h2o";
459 CacheDirectoryMode = "0750";
460 LogsDirectory = "h2o";
461 LogsDirectoryMode = "0750";
462 ProtectSystem = "strict";
463 ProtectHome = mkDefault true;
464 PrivateTmp = true;
465 PrivateDevices = true;
466 ProtectHostname = true;
467 ProtectClock = true;
468 ProtectKernelTunables = true;
469 ProtectKernelModules = true;
470 ProtectKernelLogs = true;
471 ProtectControlGroups = true;
472 RestrictAddressFamilies = [
473 "AF_UNIX"
474 "AF_INET"
475 "AF_INET6"
476 ];
477 RestrictNamespaces = true;
478 LockPersonality = true;
479 RestrictRealtime = true;
480 RestrictSUIDSGID = true;
481 RemoveIPC = true;
482 PrivateMounts = true;
483 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
484 CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
485 };
486
487 preStart = "${h2oExe} --mode 'test'";
488 };
489
490 # This service waits for all certificates to be available before reloading
491 # H2O configuration. `tlsTargets` are added to `wantedBy` + `before` which
492 # allows the `acme-order-renew-$cert.service` to signify the successful updating
493 # of certs end-to-end.
494 systemd.services.h2o-config-reload =
495 let
496 tlsServices = map (certName: "acme-order-renew-${certName}.service") acmeCertNames.all;
497 in
498 mkIf (acmeCertNames.all != [ ]) {
499 wantedBy = tlsServices ++ [ "multi-user.target" ];
500 after = tlsServices;
501 unitConfig = {
502 ConditionPathExists = map (
503 certName: "${certs.${certName}.directory}/fullchain.pem"
504 ) acmeCertNames.all;
505 # Disable rate limiting for this since it may be triggered quickly
506 # a bunch of times if a lot of certificates are renewed in quick
507 # succession. The reload itself is cheap, so even doing a lot of them
508 # in a short burst is fine.
509 #
510 # FIXME: like Nginx’s FIXME, there’s probably a better way to do
511 # this.
512 StartLimitIntervalSec = 0;
513 };
514 serviceConfig = {
515 Type = "oneshot";
516 TimeoutSec = 60;
517 ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active h2o.service";
518 ExecStart = "/run/current-system/systemd/bin/systemctl reload h2o.service";
519 };
520 };
521
522 security.acme.certs =
523 let
524 mkCerts =
525 acc: vhostSettings:
526 if vhostSettings.acme.useHost == null then
527 let
528 hasRoot = vhostSettings.acme.root != null;
529 in
530 acc
531 // {
532 "${vhostSettings.serverName}" = {
533 group = mkDefault cfg.group;
534 # If `acme.root` is `null`, inherit `config.security.acme`.
535 # Since `config.security.acme.certs.<cert>.webroot`’s own
536 # default value should take precedence set priority higher than
537 # mkOptionDefault
538 webroot = lib.mkOverride (if hasRoot then 1000 else 2000) vhostSettings.acme.root;
539 # Also nudge dnsProvider to null in case it is inherited
540 dnsProvider = lib.mkOverride (if hasRoot then 1000 else 2000) null;
541 extraDomainNames = vhostSettings.serverAliases;
542 };
543 }
544 else
545 acc;
546 in
547 lib.lists.foldl mkCerts { } acmeEnabledHostsConfigs;
548 };
549}