diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index bbd98618e3e7..7bb31005ff41 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -599,6 +599,7 @@ in gerrit = runTest ./gerrit.nix; geth = runTest ./geth.nix; ghostunnel = runTest ./ghostunnel.nix; + ghostunnel-modular = runTest ./ghostunnel-modular.nix; gitdaemon = runTest ./gitdaemon.nix; gitea = handleTest ./gitea.nix { giteaPackage = pkgs.gitea; }; github-runner = runTest ./github-runner.nix; diff --git a/nixos/tests/ghostunnel-modular.nix b/nixos/tests/ghostunnel-modular.nix new file mode 100644 index 000000000000..a1f17bc03400 --- /dev/null +++ b/nixos/tests/ghostunnel-modular.nix @@ -0,0 +1,120 @@ +{ hostPkgs, lib, ... }: +{ + _class = "nixosTest"; + name = "ghostunnel"; + nodes = { + backend = + { pkgs, ... }: + { + services.nginx.enable = true; + services.nginx.virtualHosts."backend".root = pkgs.runCommand "webroot" { } '' + mkdir $out + echo hi >$out/hi.txt + ''; + networking.firewall.allowedTCPPorts = [ 80 ]; + }; + service = + { pkgs, ... }: + { + system.services."ghostunnel-plain-old" = { + imports = [ pkgs.ghostunnel.services.default ]; + ghostunnel = { + listen = "0.0.0.0:443"; + cert = "/root/service-cert.pem"; + key = "/root/service-key.pem"; + disableAuthentication = true; + target = "backend:80"; + unsafeTarget = true; + }; + }; + system.services."ghostunnel-client-cert" = { + imports = [ pkgs.ghostunnel.services.default ]; + ghostunnel = { + listen = "0.0.0.0:1443"; + cert = "/root/service-cert.pem"; + key = "/root/service-key.pem"; + cacert = "/root/ca.pem"; + target = "backend:80"; + allowCN = [ "client" ]; + unsafeTarget = true; + }; + }; + networking.firewall.allowedTCPPorts = [ + 443 + 1443 + ]; + }; + client = + { pkgs, ... }: + { + environment.systemPackages = [ + pkgs.curl + ]; + }; + }; + + testScript = '' + + # prepare certificates + + def cmd(command): + print(f"+{command}") + r = os.system(command) + if r != 0: + raise Exception(f"Command {command} failed with exit code {r}") + + # Create CA + cmd("${hostPkgs.openssl}/bin/openssl genrsa -out ca-key.pem 4096") + cmd("${hostPkgs.openssl}/bin/openssl req -new -x509 -days 365 -key ca-key.pem -sha256 -subj '/C=NL/ST=Zuid-Holland/L=The Hague/O=Stevige Balken en Planken B.V./OU=OpSec/CN=Certificate Authority' -out ca.pem") + + # Create service + cmd("${hostPkgs.openssl}/bin/openssl genrsa -out service-key.pem 4096") + cmd("${hostPkgs.openssl}/bin/openssl req -subj '/CN=service' -sha256 -new -key service-key.pem -out service.csr") + cmd("echo subjectAltName = DNS:service,IP:127.0.0.1 >> extfile.cnf") + cmd("echo extendedKeyUsage = serverAuth >> extfile.cnf") + cmd("${hostPkgs.openssl}/bin/openssl x509 -req -days 365 -sha256 -in service.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out service-cert.pem -extfile extfile.cnf") + + # Create client + cmd("${hostPkgs.openssl}/bin/openssl genrsa -out client-key.pem 4096") + cmd("${hostPkgs.openssl}/bin/openssl req -subj '/CN=client' -new -key client-key.pem -out client.csr") + cmd("echo extendedKeyUsage = clientAuth > extfile-client.cnf") + cmd("${hostPkgs.openssl}/bin/openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -extfile extfile-client.cnf") + + cmd("ls -al") + + start_all() + + # Configuration + service.copy_from_host("ca.pem", "/root/ca.pem") + service.copy_from_host("service-cert.pem", "/root/service-cert.pem") + service.copy_from_host("service-key.pem", "/root/service-key.pem") + client.copy_from_host("ca.pem", "/root/ca.pem") + client.copy_from_host("service-cert.pem", "/root/service-cert.pem") + client.copy_from_host("client-cert.pem", "/root/client-cert.pem") + client.copy_from_host("client-key.pem", "/root/client-key.pem") + + backend.wait_for_unit("nginx.service") + service.wait_for_unit("multi-user.target") + service.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + + # Check assumptions before the real test + client.succeed("bash -c 'diff <(curl -v --no-progress-meter http://backend/hi.txt) <(echo hi)'") + + # Plain old simple TLS can connect, ignoring cert + client.succeed("bash -c 'diff <(curl -v --no-progress-meter --insecure https://service/hi.txt) <(echo hi)'") + + # Plain old simple TLS provides correct signature with its cert + client.succeed("bash -c 'diff <(curl -v --no-progress-meter --cacert /root/ca.pem https://service/hi.txt) <(echo hi)'") + + # Client can authenticate with certificate + client.succeed("bash -c 'diff <(curl -v --no-progress-meter --cert /root/client-cert.pem --key /root/client-key.pem --cacert /root/ca.pem https://service:1443/hi.txt) <(echo hi)'") + + # Client must authenticate with certificate + client.fail("bash -c 'diff <(curl -v --no-progress-meter --cacert /root/ca.pem https://service:1443/hi.txt) <(echo hi)'") + ''; + + meta.maintainers = with lib.maintainers; [ + roberth + ]; +} diff --git a/pkgs/by-name/gh/ghostunnel/package.nix b/pkgs/by-name/gh/ghostunnel/package.nix index f99f296c8bbc..a057dd263d39 100644 --- a/pkgs/by-name/gh/ghostunnel/package.nix +++ b/pkgs/by-name/gh/ghostunnel/package.nix @@ -4,6 +4,7 @@ fetchFromGitHub, lib, nixosTests, + ghostunnel, apple-sdk_12, darwinMinVersionHook, }: @@ -39,6 +40,11 @@ buildGoModule rec { podman = nixosTests.podman-tls-ghostunnel; }; + passthru.services.default = { + imports = [ ./service.nix ]; + ghostunnel.package = ghostunnel; # FIXME: finalAttrs.finalPackage + }; + meta = { description = "TLS proxy with mutual authentication support for securing non-TLS backend applications"; homepage = "https://github.com/ghostunnel/ghostunnel#readme"; diff --git a/pkgs/by-name/gh/ghostunnel/service.nix b/pkgs/by-name/gh/ghostunnel/service.nix new file mode 100644 index 000000000000..dbc49478a6cb --- /dev/null +++ b/pkgs/by-name/gh/ghostunnel/service.nix @@ -0,0 +1,229 @@ +{ + lib, + config, + options, + pkgs, + ... +}: +let + inherit (lib) + concatStringsSep + escapeShellArg + mkDefault + mkIf + mkOption + optional + types + ; + cfg = config.ghostunnel; + +in +{ + # https://nixos.org/manual/nixos/unstable/#modular-services + _class = "service"; + options = { + ghostunnel = { + package = mkOption { + description = "Package to use for ghostunnel"; + type = types.package; + }; + + listen = mkOption { + description = '' + Address and port to listen on (can be HOST:PORT, unix:PATH). + ''; + type = types.str; + }; + + target = mkOption { + description = '' + Address to forward connections to (can be HOST:PORT or unix:PATH). + ''; + type = types.str; + }; + + keystore = mkOption { + description = '' + Path to keystore (combined PEM with cert/key, or PKCS12 keystore). + + NB: storepass is not supported because it would expose credentials via `/proc/*/cmdline`. + + Specify this or `cert` and `key`. + ''; + type = types.nullOr types.str; + default = null; + }; + + cert = mkOption { + description = '' + Path to certificate (PEM with certificate chain). + + Not required if `keystore` is set. + ''; + type = types.nullOr types.str; + default = null; + }; + + key = mkOption { + description = '' + Path to certificate private key (PEM with private key). + + Not required if `keystore` is set. + ''; + type = types.nullOr types.str; + default = null; + }; + + cacert = mkOption { + description = '' + Path to CA bundle file (PEM/X509). Uses system trust store if `null`. + ''; + type = types.nullOr types.str; + }; + + disableAuthentication = mkOption { + description = '' + Disable client authentication, no client certificate will be required. + ''; + type = types.bool; + default = false; + }; + + allowAll = mkOption { + description = '' + If true, allow all clients, do not check client cert subject. + ''; + type = types.bool; + default = false; + }; + + allowCN = mkOption { + description = '' + Allow client if common name appears in the list. + ''; + type = types.listOf types.str; + default = [ ]; + }; + + allowOU = mkOption { + description = '' + Allow client if organizational unit name appears in the list. + ''; + type = types.listOf types.str; + default = [ ]; + }; + + allowDNS = mkOption { + description = '' + Allow client if DNS subject alternative name appears in the list. + ''; + type = types.listOf types.str; + default = [ ]; + }; + + allowURI = mkOption { + description = '' + Allow client if URI subject alternative name appears in the list. + ''; + type = types.listOf types.str; + default = [ ]; + }; + + extraArguments = mkOption { + description = "Extra arguments to pass to `ghostunnel server` (shell syntax)"; + type = types.separatedString " "; + default = ""; + }; + + unsafeTarget = mkOption { + description = '' + If set, does not limit target to localhost, 127.0.0.1, [::1], or UNIX sockets. + + This is meant to protect against accidental unencrypted traffic on + untrusted networks. + ''; + type = types.bool; + default = false; + }; + }; + }; + + config = { + assertions = [ + { + message = '' + At least one access control flag is required. + Set at least one of: + - ${options.ghostunnel.disableAuthentication} + - ${options.ghostunnel.allowAll} + - ${options.ghostunnel.allowCN} + - ${options.ghostunnel.allowOU} + - ${options.ghostunnel.allowDNS} + - ${options.ghostunnel.allowURI} + ''; + assertion = + cfg.disableAuthentication + || cfg.allowAll + || cfg.allowCN != [ ] + || cfg.allowOU != [ ] + || cfg.allowDNS != [ ] + || cfg.allowURI != [ ]; + } + ]; + + ghostunnel = { + # Clients should not be authenticated with the public root certificates + # (afaict, it doesn't make sense), so we only provide that default when + # client cert auth is disabled. + cacert = mkIf cfg.disableAuthentication (mkDefault null); + }; + + # TODO assertions + + process = { + executable = pkgs.writeScriptBin "run-ghostunnel" '' + #!${pkgs.runtimeShell} + exec ${lib.getExe cfg.package} ${ + concatStringsSep " " ( + optional (cfg.keystore != null) "--keystore=$CREDENTIALS_DIRECTORY/keystore" + ++ optional (cfg.cert != null) "--cert=$CREDENTIALS_DIRECTORY/cert" + ++ optional (cfg.key != null) "--key=$CREDENTIALS_DIRECTORY/key" + ++ optional (cfg.cacert != null) "--cacert=$CREDENTIALS_DIRECTORY/cacert" + ++ [ + "server" + "--listen" + cfg.listen + "--target" + cfg.target + ] + ++ optional cfg.allowAll "--allow-all" + ++ map (v: "--allow-cn=${escapeShellArg v}") cfg.allowCN + ++ map (v: "--allow-ou=${escapeShellArg v}") cfg.allowOU + ++ map (v: "--allow-dns=${escapeShellArg v}") cfg.allowDNS + ++ map (v: "--allow-uri=${escapeShellArg v}") cfg.allowURI + ++ optional cfg.disableAuthentication "--disable-authentication" + ++ optional cfg.unsafeTarget "--unsafe-target" + ++ [ cfg.extraArguments ] + ) + } + ''; + }; + + # refine the service + systemd.service = { + after = [ "network.target" ]; + wants = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Restart = "always"; + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + DynamicUser = true; + LoadCredential = + optional (cfg.keystore != null) "keystore:${cfg.keystore}" + ++ optional (cfg.cert != null) "cert:${cfg.cert}" + ++ optional (cfg.key != null) "key:${cfg.key}" + ++ optional (cfg.cacert != null) "cacert:${cfg.cacert}"; + }; + }; + }; +}