diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index e1000541c104..41b2c21ba690 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1608,6 +1608,7 @@ ./services/web-servers/darkhttpd.nix ./services/web-servers/fcgiwrap.nix ./services/web-servers/garage.nix + ./services/web-servers/h2o/default.nix ./services/web-servers/hitch/default.nix ./services/web-servers/jboss/default.nix ./services/web-servers/keter diff --git a/nixos/modules/services/web-servers/h2o/default.nix b/nixos/modules/services/web-servers/h2o/default.nix new file mode 100644 index 000000000000..7e021281f91a --- /dev/null +++ b/nixos/modules/services/web-servers/h2o/default.nix @@ -0,0 +1,263 @@ +{ + config, + lib, + pkgs, + ... +}: + +# TODO: ACME +# TODO: Gems includes for Mruby +# TODO: Recommended options +let + cfg = config.services.h2o; + + inherit (lib) + literalExpression + mkDefault + mkEnableOption + mkIf + mkOption + types + ; + + settingsFormat = pkgs.formats.yaml { }; + + hostsConfig = lib.concatMapAttrs ( + name: value: + let + port = { + HTTP = lib.attrByPath [ "http" "port" ] cfg.defaultHTTPListenPort value; + TLS = lib.attrByPath [ "tls" "port" ] cfg.defaultTLSListenPort value; + }; + serverName = if value.serverName != null then value.serverName else name; + in + # HTTP settings + lib.optionalAttrs (value.tls == null || value.tls.policy == "add") { + "${serverName}:${builtins.toString port.HTTP}" = value.settings // { + listen.port = port.HTTP; + }; + } + # Redirect settings + // lib.optionalAttrs (value.tls != null && value.tls.policy == "force") { + "${serverName}:${builtins.toString port.HTTP}" = { + listen.port = port.HTTP; + paths."/" = { + redirect = { + status = value.tls.redirectCode; + url = "https://${serverName}:${builtins.toString port.TLS}"; + }; + }; + }; + } + # TLS settings + // + lib.optionalAttrs + ( + value.tls != null + && builtins.elem value.tls.policy [ + "add" + "only" + "force" + ] + ) + { + "${serverName}:${builtins.toString port.TLS}" = value.settings // { + listen = + let + identity = value.tls.identity; + in + { + port = port.TLS; + ssl = value.tls.extraSettings or { } // { + inherit identity; + }; + }; + }; + } + ) cfg.hosts; + + h2oConfig = settingsFormat.generate "h2o.yaml" ( + lib.recursiveUpdate { hosts = hostsConfig; } cfg.settings + ); +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 = '' + pkgs.h2o.override { + withMruby = true; + }; + ''; + }; + + 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; + }; + + mode = mkOption { + type = + with types; + nullOr (enum [ + "daemon" + "master" + "worker" + "test" + ]); + default = "master"; + description = "Operating mode of H2O"; + }; + + settings = mkOption { + type = settingsFormat.type; + description = "Configuration for H2O (see )"; + }; + + 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"; + indentity = [ + { + 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 { + users = { + users.${cfg.user} = + { + group = cfg.group; + } + // lib.optionalAttrs (cfg.user == "h2o") { + isSystemUser = true; + }; + groups.${cfg.group} = { }; + }; + + systemd.services.h2o = { + description = "H2O web server service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + ExecStop = "${pkgs.coreutils}/bin/kill -s QUIT $MAINPID"; + User = cfg.user; + 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" ]; + }; + + script = + let + args = + [ + "--conf" + "${h2oConfig}" + ] + ++ lib.optionals (cfg.mode != null) [ + "--mode" + cfg.mode + ]; + in + '' + ${lib.getExe cfg.package} ${lib.strings.escapeShellArgs args} + ''; + }; + }; + +} diff --git a/nixos/modules/services/web-servers/h2o/vhost-options.nix b/nixos/modules/services/web-servers/h2o/vhost-options.nix new file mode 100644 index 000000000000..26abf8eb4f6a --- /dev/null +++ b/nixos/modules/services/web-servers/h2o/vhost-options.nix @@ -0,0 +1,151 @@ +{ config, lib, ... }: + +let + inherit (lib) + literalExpression + mkOption + types + ; +in +{ + options = { + serverName = mkOption { + type = types.nullOr types.nonEmptyStr; + default = null; + description = '' + Server name to be used for this virtual host. Defaults to attribute + name in hosts. + ''; + example = "example.org"; + }; + + http = mkOption { + type = types.nullOr ( + types.submodule { + options = { + port = mkOption { + type = types.port; + default = config.services.h2o.defaultHTTPListenPort; + defaultText = literalExpression '' + config.services.h2o.defaultHTTPListenPort + ''; + description = '' + Override the default HTTP port for this virtual host. + ''; + example = literalExpression "8080"; + }; + }; + } + ); + default = null; + description = "HTTP options for virtual host"; + }; + + tls = mkOption { + type = types.nullOr ( + types.submodule { + options = { + port = mkOption { + type = types.port; + default = config.services.h2o.defaultTLSListenPort; + defaultText = literalExpression '' + config.services.h2o.defaultTLSListenPort + ''; + description = '' + Override the default TLS port for this virtual host."; + ''; + example = 8443; + }; + policy = mkOption { + type = types.enum [ + "add" + "only" + "force" + ]; + description = '' + `add` will additionally listen for TLS connections. `only` will + disable TLS connections. `force` will redirect non-TLS traffic + to the TLS connection. + ''; + example = "force"; + }; + redirectCode = mkOption { + type = types.ints.between 300 399; + default = 301; + example = 308; + description = '' + HTTP status used by `globalRedirect` & `forceSSL`. Possible + usecases include temporary (302, 307) redirects, keeping the + request method & body (307, 308), or explicitly resetting the + method to GET (303). See + . + ''; + }; + identity = mkOption { + type = types.nonEmptyListOf ( + types.submodule { + options = { + key-file = mkOption { + type = types.path; + description = "Path to key file"; + }; + certificate-file = mkOption { + type = types.path; + description = "Path to certificate file"; + }; + }; + } + ); + default = null; + description = '' + Key / certificate pairs for the virtual host. + ''; + example = + literalExpression + # nix + '' + { + indentities = [ + { + key-file = "/path/to/rsa.key"; + certificate-file = "/path/to/rsa.crt"; + } + { + key-file = "/path/to/ecdsa.key"; + certificate-file = "/path/to/ecdsa.crt"; + } + ]; + } + ''; + }; + extraSettings = mkOption { + type = types.nullOr types.attrs; + default = null; + description = '' + Additional TLS/SSL-related configuration options. + ''; + example = + literalExpression + # nix + '' + { + minimum-version = "TLSv1.3"; + } + ''; + }; + }; + } + ); + default = null; + description = "TLS options for virtual host"; + }; + + settings = mkOption { + type = types.attrs; + description = '' + Attrset to be transformed into YAML for host config. Note that the HTTP + / TLS configurations will override these config values. + ''; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 6e475d968379..458c65e58150 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -420,6 +420,7 @@ in { guacamole-server = handleTest ./guacamole-server.nix {}; guix = handleTest ./guix {}; gvisor = handleTest ./gvisor.nix {}; + h2o = discoverTests (import ./web-servers/h2o { inherit handleTestOn; }); hadoop = import ./hadoop { inherit handleTestOn; package=pkgs.hadoop; }; hadoop_3_3 = import ./hadoop { inherit handleTestOn; package=pkgs.hadoop_3_3; }; hadoop2 = import ./hadoop { inherit handleTestOn; package=pkgs.hadoop2; }; diff --git a/nixos/tests/web-servers/h2o/basic.nix b/nixos/tests/web-servers/h2o/basic.nix new file mode 100644 index 000000000000..673e082322bb --- /dev/null +++ b/nixos/tests/web-servers/h2o/basic.nix @@ -0,0 +1,138 @@ +import ../../make-test-python.nix ( + { lib, pkgs, ... }: + + # Tests basics such as TLS, creating a mime-type & serving Unicode characters. + + let + domain = { + HTTP = "h2o.local"; + TLS = "acme.test"; + }; + + port = { + HTTP = 8080; + TLS = 8443; + }; + + sawatdi_chao_lok = "สวัสดีชาวโลก"; + + hello_world_txt = pkgs.writeTextFile { + name = "/hello_world.txt"; + text = sawatdi_chao_lok; + }; + + hello_world_rst = pkgs.writeTextFile { + name = "/hello_world.rst"; + text = # rst + '' + ==================== + Thaiger Sprint 2025‼ + ==================== + + ${sawatdi_chao_lok} + ''; + }; + in + { + name = "h2o-basic"; + + meta = { + maintainers = with lib.maintainers; [ toastal ]; + }; + + nodes = { + server = + { pkgs, ... }: + { + services.h2o = { + enable = true; + defaultHTTPListenPort = port.HTTP; + defaultTLSListenPort = port.TLS; + hosts = { + "${domain.HTTP}" = { + settings = { + paths = { + "/hello_world.txt" = { + "file.file" = "${hello_world_txt}"; + }; + }; + }; + }; + "${domain.TLS}" = { + tls = { + policy = "force"; + identity = [ + { + key-file = ../../common/acme/server/acme.test.key.pem; + certificate-file = ../../common/acme/server/acme.test.cert.pem; + } + ]; + extraSettings = { + minimum-version = "TLSv1.3"; + }; + }; + settings = { + paths = { + "/hello_world.rst" = { + "file.file" = "${hello_world_rst}"; + }; + }; + }; + }; + }; + settings = { + compress = "ON"; + compress-minimum-size = 32; + "file.mime.addtypes" = { + "text/x-rst" = { + extensions = [ ".rst" ]; + is_compressible = "YES"; + }; + }; + ssl-offload = "kernel"; + }; + }; + + security.pki.certificates = [ + (builtins.readFile ../../common/acme/server/ca.cert.pem) + ]; + + networking = { + firewall.allowedTCPPorts = with port; [ + HTTP + TLS + ]; + extraHosts = '' + 127.0.0.1 ${domain.HTTP} + 127.0.0.1 ${domain.TLS} + ''; + }; + }; + }; + + testScript = # python + '' + server.wait_for_unit("h2o.service") + + http_hello_world_body = server.succeed("curl --fail-with-body 'http://${domain.HTTP}:${builtins.toString port.HTTP}/hello_world.txt'") + assert "${sawatdi_chao_lok}" in http_hello_world_body + + tls_hello_world_head = server.succeed("curl -v --head --compressed --http2 --tlsv1.3 --fail-with-body 'https://${domain.TLS}:${builtins.toString port.TLS}/hello_world.rst'").lower() + print(tls_hello_world_head) + assert "http/2 200" in tls_hello_world_head + assert "server: h2o" in tls_hello_world_head + assert "content-type: text/x-rst" in tls_hello_world_head + + tls_hello_world_body = server.succeed("curl -v --http2 --tlsv1.3 --compressed --fail-with-body 'https://${domain.TLS}:${builtins.toString port.TLS}/hello_world.rst'") + assert "${sawatdi_chao_lok}" in tls_hello_world_body + + tls_hello_world_head_redirected = server.succeed("curl -v --head --fail-with-body 'http://${domain.TLS}:${builtins.toString port.HTTP}/hello_world.rst'").lower() + assert "redirected" in tls_hello_world_head_redirected + + server.fail("curl --location --max-redirs 0 'http://${domain.TLS}:${builtins.toString port.HTTP}/hello_world.rst'") + + tls_hello_world_body_redirected = server.succeed("curl -v --location --fail-with-body 'http://${domain.TLS}:${builtins.toString port.HTTP}/hello_world.rst'") + assert "${sawatdi_chao_lok}" in tls_hello_world_body_redirected + ''; + } +) diff --git a/nixos/tests/web-servers/h2o/default.nix b/nixos/tests/web-servers/h2o/default.nix new file mode 100644 index 000000000000..23d419842834 --- /dev/null +++ b/nixos/tests/web-servers/h2o/default.nix @@ -0,0 +1,16 @@ +{ + system ? builtins.currentSystem, + handleTestOn, +}: + +let + supportedSystems = [ + "x86_64-linux" + "i686-linux" + "aarch64-linux" + ]; +in +{ + basic = handleTestOn supportedSystems ./basic.nix { inherit system; }; + mruby = handleTestOn supportedSystems ./mruby.nix { inherit system; }; +} diff --git a/nixos/tests/web-servers/h2o/file_handler.rb b/nixos/tests/web-servers/h2o/file_handler.rb new file mode 100644 index 000000000000..6e14da191dee --- /dev/null +++ b/nixos/tests/web-servers/h2o/file_handler.rb @@ -0,0 +1,3 @@ +Proc.new do |env| + [200, {'content-type' => 'text/plain'}, ["FILE_HANDLER"]] +end diff --git a/nixos/tests/web-servers/h2o/mruby.nix b/nixos/tests/web-servers/h2o/mruby.nix new file mode 100644 index 000000000000..e8cedf300446 --- /dev/null +++ b/nixos/tests/web-servers/h2o/mruby.nix @@ -0,0 +1,64 @@ +import ../../make-test-python.nix ( + { lib, pkgs, ... }: + + let + domain = "h2o.local"; + + port = 8080; + + sawatdi_chao_lok = "สวัสดีชาวโลก"; + in + { + name = "h2o-mruby"; + + meta = { + maintainers = with lib.maintainers; [ toastal ]; + }; + + nodes = { + server = + { pkgs, ... }: + { + services.h2o = { + enable = true; + package = pkgs.h2o.override { withMruby = true; }; + settings = { + listen = port; + hosts = { + "${domain}" = { + paths = { + "/hello_world" = { + "mruby.handler" = # ruby + '' + Proc.new do |env| + [200, {'content-type' => 'text/plain'}, ["${sawatdi_chao_lok}"]] + end + ''; + }; + "/file_handler" = { + "mruby.handler-file" = ./file_handler.rb; + }; + }; + }; + }; + }; + }; + + networking.extraHosts = '' + 127.0.0.1 ${domain} + ''; + }; + }; + + testScript = # python + '' + server.wait_for_unit("h2o.service") + + hello_world = server.succeed("curl --fail-with-body http://${domain}:${builtins.toString port}/hello_world") + assert "${sawatdi_chao_lok}" in hello_world + + file_handler = server.succeed("curl --fail-with-body http://${domain}:${builtins.toString port}/file_handler") + assert "FILE_HANDLER" in file_handler + ''; + } +) diff --git a/pkgs/by-name/h2/h2o/package.nix b/pkgs/by-name/h2/h2o/package.nix index 6a6df40b8dd5..d908919589d4 100644 --- a/pkgs/by-name/h2/h2o/package.nix +++ b/pkgs/by-name/h2/h2o/package.nix @@ -66,7 +66,8 @@ stdenv.mkDerivation (finalAttrs: { EXES="$(find "$out/share/h2o" -type f -executable)" for exe in $EXES; do wrapProgram "$exe" \ - --set "H2O_PERL" "${lib.getExe perl}" + --set "H2O_PERL" "${lib.getExe perl}" \ + --prefix "PATH" : "${lib.getBin openssl}/bin" done '';