diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index adc084518d04..004ad3614dd5 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -72,6 +72,8 @@ - [Draupnir](https://github.com/the-draupnir-project/draupnir), a Matrix moderation bot. Available as [services.draupnir](#opt-services.draupnir.enable). +- [Pangolin](https://github.com/fosrl/pangolin), a tunneled reverse proxy server with access control. Available as [services.pangolin](#opt-services.pangolin.enable). + - [postfix-tlspol](https://github.com/Zuplu/postfix-tlspol), MTA-STS and DANE resolver and TLS policy server for Postfix. Available as [services.postfix-tlspol](#opt-services.postfix-tlspol.enable). - [crowdsec](https://www.crowdsec.net/), a free, open-source and collaborative IPS. Available as [services.crowdsec](#opt-services.crowdsec.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 20f7cb34220c..62a90d843020 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1293,6 +1293,7 @@ ./services/networking/openvpn.nix ./services/networking/ostinato.nix ./services/networking/owamp.nix + ./services/networking/pangolin.nix ./services/networking/pdns-recursor.nix ./services/networking/pdnsd.nix ./services/networking/peroxide.nix diff --git a/nixos/modules/services/networking/pangolin.nix b/nixos/modules/services/networking/pangolin.nix new file mode 100644 index 000000000000..d612f6953904 --- /dev/null +++ b/nixos/modules/services/networking/pangolin.nix @@ -0,0 +1,555 @@ +{ + utils, + config, + options, + lib, + pkgs, + ... +}: +let + cfg = config.services.pangolin; + format = pkgs.formats.yaml { }; + finalSettings = lib.attrsets.recursiveUpdate pangolinConf cfg.settings; + cfgFile = format.generate "config.yml" finalSettings; + # override the type to allow for optionality + nullOrOpt = t: lib.types.nullOr t // { _optional = true; }; + + gerbil-wg0-fix-script = pkgs.writeShellApplication { + name = "gerbil-wg0-fix-script"; + runtimeInputs = with pkgs; [ + coreutils + iproute2 + ]; + # will not work if the interface is renamed + # https://github.com/fosrl/newt/issues/37#issuecomment-3193385911 + text = '' + if [ ! -f /var/lib/pangolin/config/wg0 ]; then + until ip l d wg0 + do + sleep 2 + done + touch /var/lib/pangolin/config/wg0 + systemctl restart gerbil --no-block + fi + ''; + }; + + pangolinConf = { + app.dashboard_url = "https://${cfg.dashboardDomain}"; + domains.domain1 = { + base_domain = cfg.baseDomain; + prefer_wildcard_cert = false; + }; + server = { + external_port = 3000; + internal_port = 3001; + next_port = 3002; + integration_port = 3004; + # needs to be set, otherwise this fails silently + # see https://github.com/fosrl/newt/issues/37 + internal_hostname = "localhost"; + }; + gerbil.base_endpoint = cfg.dashboardDomain; + flags.enable_integration_api = false; + }; +in +{ + options.services = { + pangolin = { + enable = lib.mkEnableOption "Pangolin reverse proxy server"; + package = lib.mkPackageOption pkgs "fosrl-pangolin" { }; + + settings = lib.mkOption { + inherit (format) type; + default = { }; + description = '' + Additional attributes to be merged with the configuration options and written to Pangolin's `config.yml` file. + ''; + example = { + app = { + save_logs = true; + }; + server = { + external_port = 3007; + internal_port = 3008; + }; + domains.domain1 = { + prefer_wildcard_cert = true; + }; + }; + }; + + openFirewall = lib.mkEnableOption "opening TCP ports 80 and 443, and UDP port 51820 in the firewall for the Pangolin service(s)"; + + baseDomain = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + description = '' + Your base fully qualified domain name (without any subdomains). + ''; + example = "example.com"; + }; + + dashboardDomain = lib.mkOption { + type = lib.types.str; + default = if (isNull cfg.baseDomain) then "" else "pangolin.${cfg.baseDomain}"; + defaultText = "pangolin.\${config.services.pangolin.baseDomain}"; + description = '' + The domain where the application will be hosted. This is used for many things, including generating links. You can run Pangolin on a subdomain or root domain. Do not prefix with `http` or `https`. + ''; + example = "auth.example.com"; + }; + + letsEncryptEmail = lib.mkOption { + type = with lib.types; nullOr str; + default = config.security.acme.defaults.email; + defaultText = lib.literalExpression "config.security.acme.defaults.email"; + description = '' + An email address for SSL certificate registration with Let's Encrypt. This should be an email you have access to. + ''; + }; + + # this assumes that all domains are hosted by the same provider + dnsProvider = lib.mkOption { + type = nullOrOpt lib.types.str; + default = null; + description = '' + The DNS provider Traefik will request wildcard certificates from. See the [Traefik Documentation](https://doc.traefik.io/traefik/https/acme/#providers) for more information. + ''; + }; + + # provide path to file to keep secrets out of the nix store + environmentFile = lib.mkOption { + type = with lib.types; nullOr path; + default = null; + description = '' + Path to a file containing sensitive environment variables for Pangolin. See the [Pangolin Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information. + These will overwrite anything defined in the config. + The file should contain environment-variable assignments like: + ``` + SERVER_SECRET=1234567890abc + ``` + ''; + example = "/etc/nixos/secrets/pangolin.env"; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/pangolin"; + example = "/srv/pangolin"; + description = "Path to variable state data directory for Pangolin."; + }; + }; + gerbil = { + port = lib.mkOption { + type = lib.types.port; + default = 3003; + description = '' + Specifies the port to listen on for Gerbil. + ''; + }; + + environmentFile = lib.mkOption { + type = nullOrOpt lib.types.path; + default = null; + description = '' + Path to a file containing sensitive environment variables for Gerbil. See the [Gerbil Documentation](https://docs.fossorial.io/Pangolin/Configuration/config) for more information. + These will overwrite anything defined in the config. + ''; + example = "/etc/nixos/secrets/gerbil.env"; + }; + }; + }; + + config = lib.mkIf cfg.enable { + + assertions = + (lib.mapAttrsToList (name: value: { + # check if the value is optional by looking at the type + assertion = (value == null) -> options.services.pangolin."${name}".type._optional or false; + message = "services.pangolin.${name} must be provided when Pangolin is enabled."; + }) cfg) + ++ [ + { + # wildcards implies (dnsProvider and traefikEnvironmentFile) + assertion = + (finalSettings.traefik.prefer_wildcard_cert or finalSettings.domains.domain1.prefer_wildcard_cert) + -> (cfg.dnsProvider != "" && config.services.traefik.environmentFiles != [ ]); + message = "services.pangolin.dnsProvider and services.traefik.environmentFile must be provided when prefer_wildcard_cert is true."; + } + ]; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 80 + 443 + ]; + allowedUDPPorts = [ 51820 ]; + }; + + users = { + users = { + pangolin = { + description = "Pangolin service user"; + group = "fossorial"; + isSystemUser = true; + packages = [ cfg.package ]; + }; + gerbil = { + description = "Gerbil service user"; + group = "fossorial"; + isSystemUser = true; + }; + }; + groups.fossorial = { + members = [ + "pangolin" + "gerbil" + "traefik" + ]; + }; + }; + # order is as follows + # "pangolin.service" + # "gerbil.service" + # "traefik.service" + ### TODO: + # make tunnels declarative by calling API + ### + systemd = { + tmpfiles.settings."10-fossorial-paths" = { + "${cfg.dataDir}".d = { + user = "pangolin"; + group = "fossorial"; + mode = "0770"; + }; + "${cfg.dataDir}/config".d = { + user = "pangolin"; + group = "fossorial"; + mode = "0770"; + }; + "${cfg.dataDir}/config/letsencrypt".d = { + user = "traefik"; + group = "fossorial"; + mode = "0700"; + }; + }; + services = { + pangolin = { + description = "Pangolin reverse proxy tunneling service"; + wantedBy = [ "multi-user.target" ]; + requires = [ "network.target" ]; + after = [ "network.target" ]; + + preStart = '' + mkdir -p ${cfg.dataDir}/config + cp -f ${cfgFile} ${cfg.dataDir}/config/config.yml + ''; + + serviceConfig = { + User = "pangolin"; + Group = "fossorial"; + WorkingDirectory = cfg.dataDir; + Restart = "always"; + EnvironmentFile = cfg.environmentFile; + # hardening + ProtectSystem = "full"; + ProtectHome = true; + PrivateTmp = "disconnected"; + PrivateDevices = true; + PrivateMounts = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + LockPersonality = true; + RestrictRealtime = true; + ProtectClock = true; + ProtectProc = "noaccess"; + ProtectHostname = true; + NoNewPrivileges = true; + RestrictSUIDSGID = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + SocketBindDeny = [ + "ipv4:tcp" + "ipv4:udp" + "ipv6:udp" + ]; + CapabilityBoundingSet = [ + "~CAP_BLOCK_SUSPEND" + "~CAP_BPF" + "~CAP_CHOWN" + "~CAP_MKNOD" + "~CAP_NET_RAW" + "~CAP_PERFMON" + "~CAP_SYS_BOOT" + "~CAP_SYS_CHROOT" + "~CAP_SYS_MODULE" + "~CAP_SYS_NICE" + "~CAP_SYS_PACCT" + "~CAP_SYS_PTRACE" + "~CAP_SYS_TIME" + "~CAP_SYSLOG" + "~CAP_WAKE_ALARM" + ]; + SystemCallFilter = [ + "~@chown:EPERM" + "~@clock:EPERM" + "~@cpu-emulation:EPERM" + "~@debug:EPERM" + "~@keyring:EPERM" + "~@memlock:EPERM" + "~@module:EPERM" + "~@mount:EPERM" + "~@obsolete:EPERM" + "~@pkey:EPERM" + "~@privileged:EPERM" + "~@raw-io:EPERM" + "~@reboot:EPERM" + "~@resources:EPERM" + "~@sandbox:EPERM" + "~@setuid:EPERM" + "~@swap:EPERM" + "~@timer:EPERM" + ]; + ExecStart = lib.getExe cfg.package; + }; + }; + gerbil = { + description = "Gerbil Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "pangolin.service" ]; + requires = [ "pangolin.service" ]; + before = [ "traefik.service" ]; + requiredBy = [ "traefik.service" ]; + # restarting gerbil restarts traefik + upholds = [ "traefik.service" ]; + + # provide default to use correct port without envfile + environment = { + LISTEN = "localhost:" + toString config.services.gerbil.port; + }; + + serviceConfig = { + User = "gerbil"; + Group = "fossorial"; + WorkingDirectory = cfg.dataDir; + Restart = "always"; + EnvironmentFile = cfg.environmentFile; + ReadWritePaths = "${cfg.dataDir}/config"; + # hardening + AmbientCapabilities = [ + "CAP_NET_ADMIN" + "CAP_SYS_MODULE" + ]; + CapabilityBoundingSet = [ + "CAP_NET_ADMIN" + "CAP_SYS_MODULE" + "~CAP_BLOCK_SUSPEND" + "~CAP_BPF" + "~CAP_CHOWN" + "~CAP_MKNOD" + "~CAP_PERFMON" + "~CAP_SYS_BOOT" + "~CAP_SYS_CHROOT" + "~CAP_SYS_NICE" + "~CAP_SYS_PACCT" + "~CAP_SYS_PTRACE" + "~CAP_SYS_TIME" + "~CAP_SYS_TTY_CONFIG" + "~CAP_SYSLOG" + "~CAP_WAKE_ALARM" + ]; + ProtectSystem = "full"; + ProtectHome = true; + PrivateTmp = "disconnected"; + PrivateDevices = true; + PrivateMounts = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + LockPersonality = true; + RestrictRealtime = true; + ProtectClock = true; + ProtectProc = "noaccess"; + ProtectHostname = true; + NoNewPrivileges = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + SystemCallFilter = [ + "~@aio:EPERM" + "~@chown:EPERM" + "~@clock:EPERM" + "~@cpu-emulation:EPERM" + "~@debug:EPERM" + "~@keyring:EPERM" + "~@memlock:EPERM" + "~@mount:EPERM" + "~@obsolete:EPERM" + "~@pkey:EPERM" + "~@privileged:EPERM" + "~@raw-io:EPERM" + "~@reboot:EPERM" + "~@resources:EPERM" + "~@sandbox:EPERM" + "~@setuid:EPERM" + "~@swap:EPERM" + "~@sync:EPERM" + "~@timer:EPERM" + ]; + ExecStart = utils.escapeSystemdExecArgs [ + (lib.getExe pkgs.fosrl-gerbil) + "--reachableAt=http://localhost:${toString config.services.gerbil.port}" + "--generateAndSaveKeyTo=${toString cfg.dataDir}/config/key" + "--remoteConfig=http://localhost:${toString finalSettings.server.internal_port}/api/v1/gerbil/get-config" + ]; + # will not work if the interface is renamed + # https://github.com/fosrl/newt/issues/37#issuecomment-3193385911 + ExecStartPost = lib.getExe gerbil-wg0-fix-script; + }; + }; + traefik = { + wantedBy = [ "multi-user.target" ]; + after = [ "gerbil.service" ]; + requires = [ "gerbil.service" ]; + partOf = [ "gerbil.service" ]; + }; + }; + }; + + services.traefik = { + enable = true; + group = "fossorial"; + dataDir = "${cfg.dataDir}/config/traefik"; + staticConfigOptions = { + providers.http = { + endpoint = "http://localhost:${toString finalSettings.server.internal_port}/api/v1/traefik-config"; + pollInterval = "5s"; + }; + # TODO to change this once #437073 is merged. + experimental.plugins.badger = { + moduleName = "github.com/fosrl/badger"; + version = "v1.2.0"; + }; + certificatesResolvers.letsencrypt.acme = + ( + if finalSettings.domains.domain1.prefer_wildcard_cert then + { + # see https://doc.traefik.io/traefik/https/acme/#providers + dnsChallenge.provider = cfg.dnsProvider; + } + else + { + httpChallenge.entryPoint = "web"; + } + ) + // + # common + { + email = cfg.letsEncryptEmail; + storage = "${cfg.dataDir}/config/letsencrypt/acme.json"; + caServer = "https://acme-v02.api.letsencrypt.org/directory"; + }; + entryPoints = { + web.address = ":80"; + websecure = { + address = ":443"; + transport.respondingTimeouts.readTimeout = "30m"; + http.tls.certResolver = "letsencrypt"; + }; + }; + }; + dynamicConfigOptions = { + http = { + middlewares.redirect-to-https.redirectScheme.scheme = "https"; + routers = { + # HTTP to HTTPS redirect router + main-app-router-redirect = { + rule = "Host(`${cfg.dashboardDomain}`)"; + service = "next-service"; + entryPoints = [ "web" ]; + middlewares = [ "redirect-to-https" ]; + }; + # Next.js router (handles everything except API and WebSocket paths) + next-router = { + rule = "Host(`${cfg.dashboardDomain}`) && !PathPrefix(`/api/v1`)"; + service = "next-service"; + entryPoints = [ "websecure" ]; + tls = + lib.optionalAttrs (finalSettings.domains.domain1.prefer_wildcard_cert) { + domains = [ + { main = cfg.baseDomain; } + { sans = "*.${cfg.baseDomain}"; } + ]; + } + // + # common + { + certResolver = "letsencrypt"; + }; + }; + # API router (handles /api/v1 paths) + api-router = { + rule = "Host(`${cfg.dashboardDomain}`) && PathPrefix(`/api/v1`)"; + service = "api-service"; + entryPoints = [ "websecure" ]; + tls.certResolver = "letsencrypt"; + }; + # WebSocket router + ws-router = { + rule = "Host(`${cfg.dashboardDomain}`)"; + service = "api-service"; + entryPoints = [ "websecure" ]; + tls.certResolver = "letsencrypt"; + }; + # Integration API router + int-api-router-redirect = lib.mkIf (finalSettings.flags.enable_integration_api) { + rule = "Host(`api.${cfg.baseDomain}`)"; + service = "int-api-service"; + entryPoints = [ "web" ]; + middlewares = [ "redirect-to-https" ]; + }; + int-api-router = lib.mkIf (finalSettings.flags.enable_integration_api) { + rule = "Host(`api.${cfg.baseDomain}`)"; + service = "int-api-service"; + entryPoints = [ "websecure" ]; + tls.certResolver = "letsencrypt"; + }; + }; + # could be map + services = { + # Next.js server + next-service.loadBalancer.servers = [ + { url = "http://localhost:${toString finalSettings.server.next_port}"; } + ]; + # API/WebSocket server + api-service.loadBalancer.servers = [ + { url = "http://localhost:${toString finalSettings.server.external_port}"; } + ]; + # Integration API server + int-api-service.loadBalancer.servers = lib.mkIf (finalSettings.flags.enable_integration_api) [ + { url = "http://localhost:${toString finalSettings.server.integration_port}"; } + ]; + }; + }; + }; + }; + }; + + meta.maintainers = with lib.maintainers; [ + jackr + sigmasquadron + ]; +}