diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 8b3a14483687..fff675121edb 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -80,6 +80,8 @@ - [Corteza](https://cortezaproject.org/), a low-code platform. Available as [services.corteza](#opt-services.corteza.enable). +- [Warpgate](https://warpgate.null.page), a SSH, HTTPS, MySQL and Postgres bastion. Available as [services.warpgate](#opt-services.warpgate.enable). Note that you need to run `warpgate recover-access` to recover builtin admin account, as the initialisation script uses a throwaway value to initialise its database. + - [TuneD](https://tuned-project.org/), a system tuning service for Linux. Available as [services.tuned](#opt-services.tuned.enable). - [yubikey-manager](https://github.com/Yubico/yubikey-manager), a tool for configuring YubiKey devices. Available as [programs.yubikey-manager](#opt-programs.yubikey-manager.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2b98c1cc23e1..88748d8af01a 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1497,6 +1497,7 @@ ./services/security/vault-agent.nix ./services/security/vault.nix ./services/security/vaultwarden/default.nix + ./services/security/warpgate.nix ./services/security/yubikey-agent.nix ./services/system/automatic-timezoned.nix ./services/system/bpftune.nix diff --git a/nixos/modules/services/security/warpgate.nix b/nixos/modules/services/security/warpgate.nix new file mode 100644 index 000000000000..a52d0eefcb42 --- /dev/null +++ b/nixos/modules/services/security/warpgate.nix @@ -0,0 +1,444 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.warpgate; + yaml = pkgs.formats.yaml { }; +in +{ + options.services.warpgate = + let + inherit (lib.types) + nullOr + enum + str + bool + port + listOf + attrsOf + submodule + ; + inherit (lib.options) mkOption mkPackageOption literalExpression; + in + { + enable = mkOption { + description = '' + Whether to enable Warpgate. + This module will initialize Warpgate base on your config automatically. Please run `warpgate recover-access` to gain access. + ''; + type = bool; + default = false; + }; + + package = mkPackageOption pkgs "warpgate" { }; + + databaseUrlFile = mkOption { + description = '' + Path to file containing database connection string with credentials. + Should be a one line file with: `database_url: ://:@/`. + See [SeaORM documentation](https://www.sea-ql.org/SeaORM/docs/install-and-config/connection/). + ''; + type = nullOr str; + default = null; + }; + + settings = mkOption { + description = "Warpgate configuration."; + type = submodule { + freeformType = yaml.type; + options = { + sso_providers = mkOption { + description = "Configure OIDC single sign-on providers."; + default = [ ]; + type = listOf (submodule { + freeformType = yaml.type; + options = { + name = mkOption { + description = "Internal identifier of SSO provider."; + type = str; + }; + label = mkOption { + description = "SSO provider name displayed on login page."; + type = str; + }; + provider = mkOption { + description = "SSO provider configurations."; + type = attrsOf yaml.type; + }; + }; + }); + example = literalExpression '' + [ + { + name = "3rd party SSO"; + label = "ACME SSO"; + provider = { + type = "custom"; + client_id = "123..."; + client_secret = "BC..."; + issuer_url = "https://sso.acme.inc"; + scopes = ["email"]; + }; + } + { + ... + } + ] + ''; + }; + recordings = { + enable = mkOption { + description = "Whether to enable session recording."; + default = true; + type = bool; + }; + path = mkOption { + description = "Path to store session recordings."; + default = "/var/lib/warpgate/recordings"; + type = str; + }; + }; + external_host = mkOption { + description = '' + Configure the domain name of this Warpgate instance. + See [HTTP domain binding](https://warpgate.null.page/http-domain-binding/). + ''; + default = null; + type = nullOr str; + }; + database_url = mkOption { + description = '' + Database connection string. + See [SeaORM documentation](https://www.sea-ql.org/SeaORM/docs/install-and-config/connection/). + ''; + default = "sqlite:/var/lib/warpgate/db"; + type = nullOr str; + }; + ssh = { + enable = mkOption { + description = "Whether to enable SSH listener."; + default = false; + type = bool; + }; + listen = mkOption { + description = "Listen endpoint of SSH listener."; + default = "[::]:2222"; + type = str; + }; + external_port = mkOption { + description = "The SSH listener is reachable via this port externally."; + default = null; + type = nullOr port; + }; + keys = mkOption { + description = "Path to store SSH host & client keys."; + default = "/var/lib/warpgate/ssh-keys"; + type = str; + }; + host_key_verification = mkOption { + description = "Specify host key verification action when connecting to a SSH target with unknown/differing host key."; + default = "prompt"; + type = enum [ + "prompt" + "auto_accept" + "auto_reject" + ]; + }; + inactivity_timeout = mkOption { + description = "How long can user be inactive until Warpgate terminates the connection."; + default = "5m"; + type = str; + }; + keepalive_interval = mkOption { + description = "If nothing is received from the client for this amount of time, server will send a keepalive message."; + default = null; + type = nullOr str; + }; + }; + http = { + listen = mkOption { + description = "Listen endpoint of HTTP listener."; + default = "[::]:8888"; + type = str; + }; + external_port = mkOption { + description = "The HTTP listener is reachable via this port externally."; + default = null; + type = nullOr port; + }; + certificate = mkOption { + description = "Path to HTTPS listener certificate."; + default = "/var/lib/warpgate/tls.certificate.pem"; + type = str; + }; + key = mkOption { + description = "Path to HTTPS listener private key."; + default = "/var/lib/warpgate/tls.key.pem"; + type = str; + }; + sni_certificates = mkOption { + description = "Certificates for additional domains."; + default = [ ]; + type = listOf (submodule { + freeformType = yaml.type; + options = { + certificate = mkOption { + description = "Path to certificate."; + default = ""; + type = str; + }; + key = mkOption { + description = "Path to private key."; + default = ""; + type = str; + }; + }; + }); + example = literalExpression '' + [ + { + certificate = "/var/lib/warpgate/example.tld.pem"; + key = "/var/lib/warpgate/example.tld.key.pem"; + } + { + ... + } + ] + ''; + }; + trust_x_forwarded_headers = mkOption { + description = '' + Trust X-Forwarded-* headers. Required when being reverse proxied. + See [Running behind a reverse proxy](https://warpgate.null.page/reverse-proxy/). + ''; + default = false; + type = bool; + }; + session_max_age = mkOption { + description = "How long until a logged in session expires."; + default = "30m"; + type = str; + }; + cookie_max_age = mkOption { + description = "How long until logged in cookie expires."; + default = "1day"; + type = str; + }; + }; + mysql = { + enable = mkOption { + description = "Whether to enable MySQL listener."; + default = false; + type = bool; + }; + listen = mkOption { + description = "Listen endpoint of MySQL listener."; + default = "[::]:33306"; + type = str; + }; + external_port = mkOption { + description = "The MySQL listener is reachable via this port externally."; + default = null; + type = nullOr port; + }; + certificate = mkOption { + description = "Path to MySQL listener certificate."; + default = "/var/lib/warpgate/tls.certificate.pem"; + type = str; + }; + key = mkOption { + description = "Path to MySQL listener private key."; + default = "/var/lib/warpgate/tls.key.pem"; + type = str; + }; + }; + postgres = { + enable = mkOption { + description = "Whether to enable PostgreSQL listener."; + default = false; + type = bool; + }; + listen = mkOption { + description = "Listen endpoint of PostgreSQL listener."; + default = "[::]:55432"; + type = str; + }; + external_port = mkOption { + description = "The PostgreSQL listener is reachable via this port externally."; + default = null; + type = nullOr str; + }; + certificate = mkOption { + description = "Path to PostgreSQL listener certificate."; + default = "/var/lib/warpgate/tls.certificate.pem"; + type = str; + }; + key = mkOption { + description = "Path to PostgreSQL listener private key."; + default = "/var/lib/warpgate/tls.key.pem"; + type = str; + }; + }; + log = { + retention = mkOption { + description = "How long Warpgate keep its logs."; + default = "7days"; + type = str; + }; + send_to = mkOption { + description = '' + Path of UNIX socket of log forwarder. + See [Log forwarding](https://warpgate.null.page/log-forwarding/); + ''; + default = null; + type = nullOr str; + }; + }; + config_provider = mkOption { + description = '' + Source of truth of users. + DO NOT change this, Warpgate only implemented database provider. + ''; + default = "database"; + type = enum [ + "file" + "database" + ]; + }; + }; + }; + default = { }; + example = { + ssh = { + enable = true; + listen = "[::]:2211"; + }; + http = { + listen = "[::]:8011"; + }; + }; + }; + }; + + config = + let + inherit (lib.lists) + any + map + head + reverseList + ; + inherit (lib.strings) splitString toIntBase10; + + preStartScript = pkgs.writers.writeBash "warpgate-init" '' + CFGFILE=/var/lib/warpgate/config.yaml + if [ ! -O $CFGFILE ] || [ ! -s $CFGFILE ]; then + INITPWD=$(tr -dc 'A-Za-z0-9!?%=' /dev/null | head -c 16) + ${lib.getExe cfg.package} \ + --config $CFGFILE unattended-setup \ + --data-path /var/lib/warpgate \ + --http-port 8888 \ + --admin-password $INITPWD + fi + ${ + if cfg.databaseUrlFile != null then + '' + sed -e '/^database_url: null/d' ${yaml.generate "warpgate-config" cfg.settings} > $CFGFILE + cat /run/credentials/warpgate.service/databaseUrl >> $CFGFILE + '' + else + "cp --no-preserve=ownership ${yaml.generate "warpgate-config" cfg.settings} $CFGFILE" + } + ''; + bindOnPrivilegedPorts = any (x: toIntBase10 x < 1025) ( + map (x: head (reverseList (splitString ":" x))) ( + [ cfg.settings.http.listen ] + ++ lib.optional cfg.settings.ssh.enable cfg.settings.ssh.listen + ++ lib.optional cfg.settings.mysql.enable cfg.settings.mysql.listen + ++ lib.optional cfg.settings.postgres.enable cfg.settings.postgres.listen + ) + ); + in + lib.mkIf cfg.enable { + assertions = [ + { + assertion = !((cfg.databaseUrlFile != null) && (cfg.settings.database_url != null)); + message = "You cannot configure databaseUrlFile and settings.database_url at the same time."; + } + { + assertion = !((cfg.databaseUrlFile == null) && (cfg.settings.database_url == null)); + message = "Either databaseUrlFile or settings.database_url must be set; Set the other to null."; + } + ]; + + environment.systemPackages = [ cfg.package ]; + + systemd.services.warpgate = { + description = "Warpgate smart bastion"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + startLimitBurst = 5; + serviceConfig = { + LoadCredential = "${ + if cfg.databaseUrlFile != null then "databaseUrl:${cfg.databaseUrlFile}" else "" + }"; + ExecStartPre = preStartScript; + ExecStart = "${lib.getExe cfg.package} --config /var/lib/warpgate/config.yaml run"; + DynamicUser = true; + RestartSec = 3; + Restart = "on-failure"; + StateDirectory = "warpgate"; + StateDirectoryMode = "0700"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + PrivateDevices = true; + DeviceAllow = [ + "/dev/null rw" + "/dev/urandom r" + ]; + DevicePolicy = "strict"; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + RestrictNamespaces = true; + ProtectProc = "invisible"; + ProtectSystem = "full"; + ProtectClock = true; + ProtectControlGroups = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ]; + } + // ( + if bindOnPrivilegedPorts then + { + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + } + else + { + PrivateUsers = true; + } + ); + }; + }; + + meta.maintainers = with lib.maintainers; [ alemonmk ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 3af4002a6aa2..b7f47487738b 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1609,6 +1609,7 @@ in vsftpd = runTest ./vsftpd.nix; waagent = runTest ./waagent.nix; wakapi = runTest ./wakapi.nix; + warpgate = runTest ./warpgate.nix; warzone2100 = runTest ./warzone2100.nix; wasabibackend = runTest ./wasabibackend.nix; wastebin = runTest ./wastebin.nix; diff --git a/nixos/tests/warpgate.nix b/nixos/tests/warpgate.nix new file mode 100644 index 000000000000..64ec9bd6dc22 --- /dev/null +++ b/nixos/tests/warpgate.nix @@ -0,0 +1,49 @@ +{ + name = "warpgate"; + + nodes = { + machine = { + services.warpgate = { + enable = true; + }; + }; + + machine2 = { + environment.etc."warpgate-db-url".text = "database: sqlite:/var/lib/warpgate/db/"; + services.warpgate = { + enable = true; + databaseUrlFile = "/etc/warpgate-db-url"; + settings = { + database_url = null; + }; + }; + }; + + machine3 = { + services.warpgate = { + enable = true; + settings = { + http.listen = "[::]:443"; + }; + }; + }; + }; + + testScript = '' + machine.wait_for_unit("warpgate.service") + machine.wait_for_open_port(8888) + machine.succeed("stat /var/lib/warpgate/db/db.sqlite3") + machine.succeed("curl -k --fail https://localhost:8888/@warpgate") + machine.shutdown() + + machine2.wait_for_unit("warpgate.service") + machine2.wait_for_open_port(8888) + machine2.succeed("curl -k --fail https://localhost:8888/@warpgate") + machine2.shutdown() + + machine3.wait_for_unit("warpgate.service") + machine3.wait_for_open_port(443) + machine3.succeed("curl -k --fail https://localhost/@warpgate") + machine3.shutdown() + ''; +}