at 25.11-pre 19 kB view raw
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}