Unverified Commit 1eae933e authored by Fernando Rodrigues's avatar Fernando Rodrigues Committed by GitHub
Browse files

nixos/pangolin: init (#416148)

parents fc106e06 d3676c73
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -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).
+1 −0
Original line number Diff line number Diff line
@@ -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
+555 −0
Original line number Diff line number Diff line
{
  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
  ];
}
+1 −0
Original line number Diff line number Diff line
@@ -1142,6 +1142,7 @@ in
  pam-u2f = runTest ./pam/pam-u2f.nix;
  pam-ussh = runTest ./pam/pam-ussh.nix;
  pam-zfs-key = runTest ./pam/zfs-key.nix;
  pangolin = runTest ./pangolin.nix;
  pantalaimon = runTest ./matrix/pantalaimon.nix;
  pantheon = runTest ./pantheon.nix;
  paperless = runTest ./paperless.nix;
+138 −0
Original line number Diff line number Diff line
{
  lib,
  pkgs,
  ...
}:
let
  # cant use .test, since that gets caught by traefik
  domain = "nixos.eu";
  secret = "1234567890";

  dnsServerIP = nodes: nodes.dnsserver.networking.primaryIPAddress;

in
{
  name = "pangolin";
  meta.maintainers = with lib.maintainers; [
    jackr
    sigmasquadron
  ];

  # The full test is not yet implemented, but once upstream supports a way to
  # configure Pangolin non-interactively, the full test will look like the following:
  # - 'acme': ACME server to replace the real servers at Let's Encrypt.
  # - 'dnsserver': The pebble challenge test server so we can use a private DNS
  #                for everything here.
  # - 'VPS': The Pangolin instance, running Gerbil, Traefik, and Badger as well.
  # - 'privateHost': The private server running an HTTP server on its local
  #                  network that will be tunnelled via Newt to the VPS.
  # - 'client': An outside node that will test if the service hosted in
  #             'privateHost' is publicly accessible.
  # TODO: In the future, we should also have a machine to test the
  #       functionality of Olm, as well as a split Pangolin/Gerbil
  #       configuration once that is implemented into the module.
  nodes = {
    acme =
      { nodes, ... }:
      {
        imports = [ ./common/acme/server ];
        networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
      };

    dnsserver =
      { nodes, ... }:
      {
        networking = {
          firewall.allowedTCPPorts = [
            8055
            53
          ];
          firewall.allowedUDPPorts = [ 53 ];

          # nixos/lib/testing/network.nix will provide name resolution via /etc/hosts
          # for all nodes based on their host names and domain
          hostName = "dnsserver";
          domain = "eu";
        };
        systemd.services.pebble-challtestsrv = {
          description = "Pebble ACME challenge test server";
          wantedBy = [ "network.target" ];
          serviceConfig = {
            ExecStart = "${lib.getExe' pkgs.pebble "pebble-challtestsrv"} -dns01 ':53' -defaultIPv6 '' -defaultIPv4 '${nodes.VPS.networking.primaryIPAddress}'";
            # Required to bind on privileged ports.
            AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
          };
        };
      };

    VPS =
      { nodes, ... }:
      {
        imports = [ ./common/acme/client ];
        networking = {
          inherit domain;
          hosts.${nodes.VPS.networking.primaryIPAddress} = [
            domain
            "pangolin.${domain}"
          ];
          nameservers = lib.mkForce [ (dnsServerIP nodes) ];
        };

        environment = {
          etc = {
            "nixos/secrets/pangolin.env".text = ''
              SERVER_SECRET=${secret}
            '';
          };
        };

        services = {
          pangolin = {
            enable = true;
            baseDomain = domain;
            letsEncryptEmail = "pangolin@${domain}";
            openFirewall = true;
            environmentFile = "/etc/nixos/secrets/pangolin.env";
            settings = {
              flags.enable_integration_api = true;
            };
          };
          # set up local ca server, so we can get our certs signed without going on the internet
          traefik.staticConfigOptions.certificatesResolvers.letsencrypt.acme.caServer =
            lib.mkForce "https://${nodes.acme.test-support.acme.caDomain}/dir";
        };
      };

  };
  testScript = ''
    ${(import ./acme/utils.nix).pythonUtils}

    with subtest("start ACME and DNS server"):
      acme.start()
      wait_for_running(acme)
      acme.wait_for_open_port(443)
      dnsserver.start()
      dnsserver.wait_for_open_port(53)

    VPS.start()

    with subtest("start Pangolin"):
      VPS.wait_for_unit("pangolin.service")
      VPS.wait_for_open_port(3000)
      VPS.wait_for_open_port(3001)
      VPS.wait_for_open_port(3002)
      VPS.wait_for_open_port(3003)

    with subtest("start Gerbil"):
      VPS.wait_for_unit("gerbil.service")

    with subtest("start Traefik"):
      VPS.wait_for_unit("traefik.service")
      VPS.wait_for_open_port(80)
      VPS.wait_for_open_port(443)

    with subtest("check traefik certs}"):
      download_ca_certs(VPS, "acme.test")

  '';
}
Loading