diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 121696b17b81..83c18a384826 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -689,6 +689,7 @@ ./services/networking/gale.nix ./services/networking/gateone.nix ./services/networking/gdomap.nix + ./services/networking/ghostunnel.nix ./services/networking/git-daemon.nix ./services/networking/gnunet.nix ./services/networking/go-neb.nix diff --git a/nixos/modules/services/networking/ghostunnel.nix b/nixos/modules/services/networking/ghostunnel.nix new file mode 100644 index 000000000000..58a51df6cca2 --- /dev/null +++ b/nixos/modules/services/networking/ghostunnel.nix @@ -0,0 +1,242 @@ +{ config, lib, pkgs, ... }: +let + inherit (lib) + attrValues + concatMap + concatStringsSep + escapeShellArg + literalExample + mapAttrs' + mkDefault + mkEnableOption + mkIf + mkOption + nameValuePair + optional + types + ; + + mainCfg = config.services.ghostunnel; + + module = { config, name, ... }: + { + options = { + + 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"; + 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; + }; + + # Definitions to apply at the root of the NixOS configuration. + atRoot = mkOption { + internal = true; + }; + }; + + # 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. + config.cacert = mkIf config.disableAuthentication (mkDefault null); + + config.atRoot = { + assertions = [ + { message = '' + services.ghostunnel.servers.${name}: At least one access control flag is required. + Set at least one of: + - services.ghostunnel.servers.${name}.disableAuthentication + - services.ghostunnel.servers.${name}.allowAll + - services.ghostunnel.servers.${name}.allowCN + - services.ghostunnel.servers.${name}.allowOU + - services.ghostunnel.servers.${name}.allowDNS + - services.ghostunnel.servers.${name}.allowURI + ''; + assertion = config.disableAuthentication + || config.allowAll + || config.allowCN != [] + || config.allowOU != [] + || config.allowDNS != [] + || config.allowURI != [] + ; + } + ]; + + systemd.services."ghostunnel-server-${name}" = { + after = [ "network.target" ]; + wants = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Restart = "always"; + AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; + DynamicUser = true; + LoadCredential = optional (config.keystore != null) "keystore:${config.keystore}" + ++ optional (config.cert != null) "cert:${config.cert}" + ++ optional (config.key != null) "key:${config.key}" + ++ optional (config.cacert != null) "cacert:${config.cacert}"; + }; + script = concatStringsSep " " ( + [ "${mainCfg.package}/bin/ghostunnel" ] + ++ optional (config.keystore != null) "--keystore=$CREDENTIALS_DIRECTORY/keystore" + ++ optional (config.cert != null) "--cert=$CREDENTIALS_DIRECTORY/cert" + ++ optional (config.key != null) "--key=$CREDENTIALS_DIRECTORY/key" + ++ optional (config.cacert != null) "--cacert=$CREDENTIALS_DIRECTORY/cacert" + ++ [ + "server" + "--listen ${config.listen}" + "--target ${config.target}" + ] ++ optional config.allowAll "--allow-all" + ++ map (v: "--allow-cn=${escapeShellArg v}") config.allowCN + ++ map (v: "--allow-ou=${escapeShellArg v}") config.allowOU + ++ map (v: "--allow-dns=${escapeShellArg v}") config.allowDNS + ++ map (v: "--allow-uri=${escapeShellArg v}") config.allowURI + ++ optional config.disableAuthentication "--disable-authentication" + ++ optional config.unsafeTarget "--unsafe-target" + ++ [ config.extraArguments ] + ); + }; + }; + }; + +in +{ + + options = { + services.ghostunnel.enable = mkEnableOption "ghostunnel"; + + services.ghostunnel.package = mkOption { + description = "The ghostunnel package to use."; + type = types.package; + default = pkgs.ghostunnel; + defaultText = literalExample ''pkgs.ghostunnel''; + }; + + services.ghostunnel.servers = mkOption { + description = '' + Server mode ghostunnels (TLS listener -> plain TCP/UNIX target) + ''; + type = types.attrsOf (types.submodule module); + default = {}; + }; + }; + + config = mkIf mainCfg.enable { + assertions = lib.mkMerge (map (v: v.atRoot.assertions) (attrValues mainCfg.servers)); + systemd = lib.mkMerge (map (v: v.atRoot.systemd) (attrValues mainCfg.servers)); + }; + + meta.maintainers = with lib.maintainers; [ + roberth + ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 2d96eeacf221..420e6eaf2e87 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -135,6 +135,7 @@ in fsck = handleTest ./fsck.nix {}; ft2-clone = handleTest ./ft2-clone.nix {}; gerrit = handleTest ./gerrit.nix {}; + ghostunnel = handleTest ./ghostunnel.nix {}; gitdaemon = handleTest ./gitdaemon.nix {}; gitea = handleTest ./gitea.nix {}; gitlab = handleTest ./gitlab.nix {}; diff --git a/nixos/tests/ghostunnel.nix b/nixos/tests/ghostunnel.nix new file mode 100644 index 000000000000..a82cff8082b7 --- /dev/null +++ b/nixos/tests/ghostunnel.nix @@ -0,0 +1,104 @@ +{ pkgs, ... }: import ./make-test-python.nix { + + 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 = { ... }: { + services.ghostunnel.enable = true; + services.ghostunnel.servers."plain-old" = { + listen = "0.0.0.0:443"; + cert = "/root/service-cert.pem"; + key = "/root/service-key.pem"; + disableAuthentication = true; + target = "backend:80"; + unsafeTarget = true; + }; + services.ghostunnel.servers."client-cert" = { + 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("${pkgs.openssl}/bin/openssl genrsa -out ca-key.pem 4096") + cmd("${pkgs.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("${pkgs.openssl}/bin/openssl genrsa -out service-key.pem 4096") + cmd("${pkgs.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("${pkgs.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("${pkgs.openssl}/bin/openssl genrsa -out client-key.pem 4096") + cmd("${pkgs.openssl}/bin/openssl req -subj '/CN=client' -new -key client-key.pem -out client.csr") + cmd("echo extendedKeyUsage = clientAuth > extfile-client.cnf") + cmd("${pkgs.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 pkgs.lib.maintainers; [ + roberth + ]; +} diff --git a/pkgs/tools/networking/ghostunnel/default.nix b/pkgs/tools/networking/ghostunnel/default.nix index eda349e6ad62..5d00b493bc37 100644 --- a/pkgs/tools/networking/ghostunnel/default.nix +++ b/pkgs/tools/networking/ghostunnel/default.nix @@ -2,6 +2,7 @@ buildGoModule, fetchFromGitHub, lib, + nixosTests, }: buildGoModule rec { @@ -23,4 +24,6 @@ buildGoModule rec { license = licenses.asl20; maintainers = with maintainers; [ roberth ]; }; + + passthru.tests.nixos = nixosTests.ghostunnel; }