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 {
236 port = port.TLS;
237 ssl = (lib.recursiveUpdate tlsRecAttrs value.tls.extraSettings) // {
238 inherit identity;
239 };
240 }
241 // lib.optionalAttrs (value.host != null) {
242 host = value.host;
243 };
244
245 # QUIC, if used, will duplicate the TLS over TCP directive, but
246 # append some extra QUIC-related settings
247 quicListen = lib.optional (value.tls.quic != null) (baseListen // { inherit (value.tls) quic; });
248 in
249 {
250 listen = [ baseListen ] ++ quicListen;
251 };
252 in
253 value.settings // headerRecAttrs // listen;
254 };
255 in
256 # With a high likelihood of HTTP & ACME challenges being on the same port,
257 # 80, do a recursive update to merge the 2 settings together
258 (lib.recursiveUpdate acmeSettings httpSettings) // tlsSettings
259 ) cfg.hosts;
260
261 h2oConfig = settingsFormat.generate "h2o.yaml" (
262 lib.recursiveUpdate { hosts = hostsConfig; } cfg.settings
263 );
264
265 # Executing H2O with our generated configuration; `mode` added as needed
266 h2oExe = ''${lib.getExe cfg.package} ${
267 lib.strings.escapeShellArgs [
268 "--conf"
269 "${h2oConfig}"
270 ]
271 }'';
272in
273{
274 options = {
275 services.h2o = {
276 enable = mkEnableOption "H2O web server";
277
278 user = mkOption {
279 type = types.nonEmptyStr;
280 default = "h2o";
281 description = "User running H2O service";
282 };
283
284 group = mkOption {
285 type = types.nonEmptyStr;
286 default = "h2o";
287 description = "Group running H2O services";
288 };
289
290 package = lib.mkPackageOption pkgs "h2o" {
291 example = # nix
292 ''
293 pkgs.h2o.override {
294 withMruby = false;
295 openssl = pkgs.openssl_legacy;
296 }
297 '';
298 };
299
300 defaultHTTPListenPort = mkOption {
301 type = types.port;
302 default = 80;
303 description = ''
304 If hosts do not specify listen.port, use these ports for HTTP by default.
305 '';
306 example = 8080;
307 };
308
309 defaultTLSListenPort = mkOption {
310 type = types.port;
311 default = 443;
312 description = ''
313 If hosts do not specify listen.port, use these ports for SSL by default.
314 '';
315 example = 8443;
316 };
317
318 defaultTLSRecommendations = tlsRecommendationsOption;
319
320 settings = mkOption {
321 type = settingsFormat.type;
322 default = { };
323 description = "Configuration for H2O (see <https://h2o.examp1e.net/configure.html>)";
324 example =
325 literalExpression
326 # nix
327 ''
328 {
329 compress = "ON";
330 ssl-offload = "kernel";
331 http2-reprioritize-blocking-assets = "ON";
332 "file.mime.addtypes" = {
333 "text/x-rst" = {
334 extensions = [ ".rst" ];
335 is_compressible = "YES";
336 };
337 };
338 }
339 '';
340 };
341
342 hosts = mkOption {
343 type = types.attrsOf (types.submodule (import ./vhost-options.nix { inherit config lib; }));
344 default = { };
345 description = ''
346 The `hosts` config to be merged with the settings.
347
348 Note that unlike YAML used for H2O, Nix will not support duplicate
349 keys to, for instance, have multiple listens in a host block; use the
350 virtual host options in like `http` & `tls` or use `$HOST:$PORT`
351 keys if manually specifying config.
352 '';
353 example =
354 literalExpression
355 # nix
356 ''
357 {
358 "hydra.example.com" = {
359 tls = {
360 policy = "force";
361 identity = [
362 {
363 key-file = "/path/to/key";
364 certificate-file = "/path/to/cert";
365 };
366 ];
367 extraSettings = {
368 minimum-version = "TLSv1.3";
369 };
370 };
371 settings = {
372 paths."/" = {
373 "file:dir" = "/var/www/default";
374 };
375 };
376 };
377 }
378 '';
379 };
380 };
381 };
382
383 config = mkIf cfg.enable {
384 assertions =
385 [
386 {
387 assertion =
388 !(builtins.hasAttr "hosts" h2oConfig)
389 || builtins.all (
390 host:
391 let
392 hasKeyPlusCert = attrs: (attrs.key-file or "") != "" && (attrs.certificate-file or "") != "";
393 in
394 # TLS not used
395 (lib.attrByPath [ "listen" "ssl" ] null host == null)
396 # TLS identity property
397 || (
398 builtins.hasAttr "identity" host
399 && builtins.length host.identity > 0
400 && builtins.all hasKeyPlusCert host.listen.ssl.identity
401 )
402 # TLS short-hand (was manually specified)
403 || (hasKeyPlusCert host.listen.ssl)
404 ) (lib.attrValues h2oConfig.hosts);
405 message = ''
406 TLS support will require at least one non-empty certificate & key
407 file. Use services.h2o.hosts.<name>.acme.enable,
408 services.h2o.hosts.<name>.acme.useHost,
409 services.h2o.hosts.<name>.tls.identity, or
410 services.h2o.hosts.<name>.tls.extraSettings.
411 '';
412 }
413 ]
414 ++ builtins.map (
415 name:
416 mkCertOwnershipAssertion {
417 cert = certs.${name};
418 groups = config.users.groups;
419 services = [
420 config.systemd.services.h2o
421 ] ++ lib.optional (acmeCertNames.all != [ ]) config.systemd.services.h2o-config-reload;
422 }
423 ) acmeCertNames.all;
424
425 users = {
426 users.${cfg.user} =
427 {
428 group = cfg.group;
429 }
430 // lib.optionalAttrs (cfg.user == "h2o") {
431 isSystemUser = true;
432 };
433 groups.${cfg.group} = { };
434 };
435
436 systemd.services.h2o = {
437 description = "H2O HTTP server";
438 wantedBy = [ "multi-user.target" ];
439 wants = lib.concatLists (map (certName: [ "acme-finished-${certName}.target" ]) acmeCertNames.all);
440 # Since H2O will be hosting the challenges, H2O must be started
441 before = builtins.map (certName: "acme-${certName}.service") acmeCertNames.dependent;
442 after =
443 [ "network.target" ]
444 ++ builtins.map (certName: "acme-selfsigned-${certName}.service") acmeCertNames.all
445 ++ builtins.map (certName: "acme-${certName}.service") acmeCertNames.independent; # avoid loading self-signed key w/ real cert, or vice-versa
446
447 serviceConfig = {
448 ExecStart = "${h2oExe} --mode 'master'";
449 ExecReload = [
450 "${h2oExe} --mode 'test'"
451 "${pkgs.coreutils}/bin/kill -HUP $MAINPID"
452 ];
453 ExecStop = "${pkgs.coreutils}/bin/kill -s QUIT $MAINPID";
454 User = cfg.user;
455 Group = cfg.group;
456 Restart = "always";
457 RestartSec = "10s";
458 RuntimeDirectory = "h2o";
459 RuntimeDirectoryMode = "0750";
460 CacheDirectory = "h2o";
461 CacheDirectoryMode = "0750";
462 LogsDirectory = "h2o";
463 LogsDirectoryMode = "0750";
464 ProtectSystem = "strict";
465 ProtectHome = mkDefault true;
466 PrivateTmp = true;
467 PrivateDevices = true;
468 ProtectHostname = true;
469 ProtectClock = true;
470 ProtectKernelTunables = true;
471 ProtectKernelModules = true;
472 ProtectKernelLogs = true;
473 ProtectControlGroups = true;
474 RestrictAddressFamilies = [
475 "AF_UNIX"
476 "AF_INET"
477 "AF_INET6"
478 ];
479 RestrictNamespaces = true;
480 LockPersonality = true;
481 RestrictRealtime = true;
482 RestrictSUIDSGID = true;
483 RemoveIPC = true;
484 PrivateMounts = true;
485 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
486 CapabilitiesBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
487 };
488
489 preStart = "${h2oExe} --mode 'test'";
490 };
491
492 # This service waits for all certificates to be available before reloading
493 # H2O configuration. `tlsTargets` are added to `wantedBy` + `before` which
494 # allows the `acme-finished-$cert.target` to signify the successful updating
495 # of certs end-to-end.
496 systemd.services.h2o-config-reload =
497 let
498 tlsTargets = map (certName: "acme-${certName}.target") acmeCertNames.all;
499 tlsServices = map (certName: "acme-${certName}.service") acmeCertNames.all;
500 in
501 mkIf (acmeCertNames.all != [ ]) {
502 wantedBy = tlsServices ++ [ "multi-user.target" ];
503 before = tlsTargets;
504 after = tlsServices;
505 unitConfig = {
506 ConditionPathExists = map (
507 certName: "${certs.${certName}.directory}/fullchain.pem"
508 ) acmeCertNames.all;
509 # Disable rate limiting for this since it may be triggered quickly
510 # a bunch of times if a lot of certificates are renewed in quick
511 # succession. The reload itself is cheap, so even doing a lot of them
512 # in a short burst is fine.
513 #
514 # FIXME: like Nginx’s FIXME, there’s probably a better way to do
515 # this.
516 StartLimitIntervalSec = 0;
517 };
518 serviceConfig = {
519 Type = "oneshot";
520 TimeoutSec = 60;
521 ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active h2o.service";
522 ExecStart = "/run/current-system/systemd/bin/systemctl reload h2o.service";
523 };
524 };
525
526 security.acme.certs =
527 let
528 mkCerts =
529 acc: vhostSettings:
530 if vhostSettings.acme.useHost == null then
531 let
532 hasRoot = vhostSettings.acme.root != null;
533 in
534 acc
535 // {
536 "${vhostSettings.serverName}" = {
537 group = mkDefault cfg.group;
538 # If `acme.root` is `null`, inherit `config.security.acme`.
539 # Since `config.security.acme.certs.<cert>.webroot`’s own
540 # default value should take precedence set priority higher than
541 # mkOptionDefault
542 webroot = lib.mkOverride (if hasRoot then 1000 else 2000) vhostSettings.acme.root;
543 # Also nudge dnsProvider to null in case it is inherited
544 dnsProvider = lib.mkOverride (if hasRoot then 1000 else 2000) null;
545 extraDomainNames = vhostSettings.serverAliases;
546 };
547 }
548 else
549 acc;
550 in
551 lib.lists.foldl mkCerts { } acmeEnabledHostsConfigs;
552 };
553}