Unverified Commit 433a3d2b authored by Sandro Jäckel's avatar Sandro Jäckel Committed by GitHub
Browse files

nixos/crowdsec-firewall-bouncer: init module (#446106)

parents ca809e5e 3fd62da2
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -100,6 +100,8 @@

- [crowdsec](https://www.crowdsec.net/), a free, open-source and collaborative IPS. Available as [services.crowdsec](#opt-services.crowdsec.enable).

- [crowdsec-firewall-bouncer](https://www.crowdsec.net/), the CrowdSec Remediation Component for fetching new and old decisions from a CrowdSec API and adding them to a blocklist used by supported firewalls. Available as [services.crowdsec-firewall-bouncer](#opt-services.crowdsec-firewall-bouncer.enable).

- [tsidp](https://github.com/tailscale/tsidp), a simple OIDC / OAuth Identity Provider (IdP) server for your tailnet. Available as [services.tsidp](#opt-services.tsidp.enable).

- [Newt](https://github.com/fosrl/newt), a fully user space WireGuard tunnel client and TCP/UDP proxy, designed to securely expose private resources controlled by Pangolin. Available as [services.newt](options.html#opt-services.newt.enable).
+1 −0
Original line number Diff line number Diff line
@@ -1460,6 +1460,7 @@
  ./services/security/certmgr.nix
  ./services/security/cfssl.nix
  ./services/security/clamav.nix
  ./services/security/crowdsec-firewall-bouncer.nix
  ./services/security/crowdsec.nix
  ./services/security/e-imzo.nix
  ./services/security/endlessh-go.nix
+390 −0
Original line number Diff line number Diff line
{
  config,
  pkgs,
  lib,
  ...
}:
let
  cfg = config.services.crowdsec-firewall-bouncer;
  format = pkgs.formats.yaml { };
in
{
  options.services.crowdsec-firewall-bouncer =
    let
      inherit (lib)
        types
        mkOption
        mkEnableOption
        mkPackageOption
        ;
    in
    {
      enable = mkEnableOption "CrowdSec Firewall Bouncer";

      package = mkPackageOption pkgs "crowdsec-firewall-bouncer" { };

      createRulesets = mkOption {
        type = types.bool;
        description = ''
          Whether to have the module create the appropriate firewall configuration
          based on the bouncer settings.
          You may disable this option to manually configure it.
        '';
        default = true;
      };

      registerBouncer = {
        enable = mkOption {
          type = types.bool;
          description = ''
            Whether to automatically register the bouncer to the locally running
            `crowdsec` service.

            When authenticating to an external CrowdSec API, you may use the
            [](#opt-services.crowdsec-firewall-bouncer.secrets.apiKeyPath) option
            instead.
          '';
          default = config.services.crowdsec.enable;
          defaultText = lib.literalExpression ''config.services.crowdsec.enable'';
        };
        bouncerName = mkOption {
          type = types.nonEmptyStr;
          description = "Name to register the bouncer as to the CrowdSec API";
          default = "crowdsec-firewall-bouncer";
        };
      };

      secrets = {
        apiKeyPath = mkOption {
          type = types.nullOr types.path;
          description = ''
            Path to the API key to authenticate with a local CrowdSec API.

            You need to call `cscli bouncers add <bouncer-name>` to register
            the bouncer and get this API key.

            When authenticating to the locally running `crowdsec` service, you may use the
            [](#opt-services.crowdsec-firewall-bouncer.registerBouncer.enable) option instead.
          '';
          default = null;
        };
      };

      settings = mkOption {
        description = ''
          Settings for the main CrowdSec Firewall Bouncer.

          Refer to the defaults at <https://github.com/crowdsecurity/cs-firewall-bouncer/blob/main/config/crowdsec-firewall-bouncer.yaml>.
        '';
        default = { };
        type = types.submodule {
          freeformType = format.type;
          options = {
            mode = mkOption {
              type = types.str;
              description = "Firewall mode to use.";
              default = if config.networking.nftables.enable then "nftables" else "iptables";
              defaultText = lib.literalExpression ''if config.networking.nftables.enable then "nftables" else "iptables"'';
            };
            api_url = mkOption {
              type = types.str;
              description = "URL of the local API.";
              example = "http://127.0.0.1:8080";
              default = "http://${config.services.crowdsec.settings.general.api.server.listen_uri}";
              defaultText = lib.literalExpression ''http://$\{config.services.crowdsec.settings.general.api.server.listen_uri}'';
            };
            api_key = mkOption {
              type = types.nullOr types.str;
              description = ''
                API key to authenticate with a local crowdsec API.

                You need to call `cscli bouncers add <bouncer-name>` to register
                the bouncer and get this API key.

                Setting this option will store this secret in the Nix store.
                Instead, you should set the `services.crowdsec-firewall-bouncer.secrets.apiKeyPath`
                option, which will read the value at runtime.
              '';
              default = null;
            };
          };
        };
      };
    };

  config = lib.mkIf cfg.enable {
    assertions = [
      {
        assertion =
          cfg.registerBouncer.enable || (cfg.secrets.apiKeyPath != null) || (cfg.settings.api_key != null);
        message = ''
          An API key must be set for the bouncer to be able to authenticate to a local crowdsec API.

          See the `registerBouncer.enable` and `secrets.apiKeyPath` options of
          `services.crowdsec-firewall-bouncer` for more information.
        '';
      }
      {
        assertion = !(cfg.registerBouncer.enable && (cfg.secrets.apiKeyPath != null));
        message = ''
          The `registerBouncer.enable` and `secrets.apiKeyPath` options of
          `services.crowdsec-firewall-bouncer` are mutually exclusive.
        '';
      }
      {
        assertion = !(cfg.registerBouncer.enable && !config.services.crowdsec.enable);
        message = ''
          The `services.crowdsec-firewall-bouncer.registerBouncer.enable` option
          requires the `crowdsec` service to be enabled.
        '';
      }
      {
        assertion = !(cfg.settings.mode == "ipset" && cfg.createRulesets);
        message = ''
          The crowdsec-firewall-bouncer module is currently not able to configure the firewall in "ipset" mode.

          Either set the `services.crowdsec-firewall-bouncer.settings.mode` to "iptables" to leave the bouncer
          manage the firewall configuration, or disable the `services.crowdsec-firewall-bouncer.createRulesets`
          option and manually configure your firewall.
        '';
      }
    ];

    # Default settings
    services.crowdsec-firewall-bouncer.settings = {
      update_frequency = lib.mkDefault "10s";
      log_mode = lib.mkDefault "stdout";
      log_level = lib.mkDefault "info";

      # iptables-specific config
      blacklists_ipv4 = lib.mkDefault "crowdsec-blacklists";
      blacklists_ipv6 = lib.mkDefault "crowdsec6-blacklists";
      iptables_chains = lib.mkDefault [ "INPUT" ];

      # nftables-specific config
      nftables = {
        ipv4 = {
          enabled = lib.mkDefault true;
          set-only = lib.mkDefault true;
          table = lib.mkDefault "crowdsec";
          chain = lib.mkDefault "crowdsec-chain";
        };
        ipv6 = {
          enabled = lib.mkDefault true;
          set-only = lib.mkDefault true;
          table = lib.mkDefault "crowdsec6";
          chain = lib.mkDefault "crowdsec6-chain";
        };
      };
    };

    # Use a placeholder for the api_key if it is to be read from a file at runtime
    services.crowdsec-firewall-bouncer.settings.api_key = lib.mkIf (
      cfg.registerBouncer.enable || (cfg.secrets.apiKeyPath != null)
    ) "@API_KEY_FILE@";

    networking.nftables.tables = lib.mkIf (cfg.settings.mode == "nftables") {
      "${cfg.settings.nftables.ipv4.table}" =
        lib.mkIf
          (cfg.createRulesets && cfg.settings.nftables.ipv4.enabled && cfg.settings.nftables.ipv4.set-only)
          {
            family = "ip";
            content = ''
              set crowdsec-blacklists {
                type ipv4_addr
                flags timeout
              }

              chain ${cfg.settings.nftables.ipv4.chain} {
                type filter hook input priority filter; policy accept;
                ip saddr @crowdsec-blacklists drop
              }
            '';
          };
      "${cfg.settings.nftables.ipv6.table}" =
        lib.mkIf
          (cfg.createRulesets && cfg.settings.nftables.ipv6.enabled && cfg.settings.nftables.ipv6.set-only)
          {
            family = "ip6";
            content = ''
              set crowdsec6-blacklists {
                type ipv6_addr
                flags timeout
              }

              chain ${cfg.settings.nftables.ipv6.chain} {
                type filter hook input priority filter; policy accept;
                ip6 saddr @crowdsec6-blacklists drop
              }
            '';
          };
    };

    systemd.services =
      let
        apiKeyFile = "/var/lib/crowdsec-firewall-bouncer-register/api-key.cred";
      in
      {
        crowdsec-firewall-bouncer-register = lib.mkIf cfg.registerBouncer.enable rec {
          description = "Register the CrowdSec Firewall Bouncer to the local CrowdSec service";
          wantedBy = [ "multi-user.target" ];
          after = [ "crowdsec.service" ];
          wants = after;
          script = ''
            cscli=/run/current-system/sw/bin/cscli
            if $cscli bouncers list --output json | ${lib.getExe pkgs.jq} -e -- ${lib.escapeShellArg "any(.[]; .name == \"${cfg.registerBouncer.bouncerName}\")"} >/dev/null; then
              # Bouncer already registered. Verify the API key is still present
              if [ ! -f ${apiKeyFile} ]; then
                echo "Bouncer registered but API key is not present"
                exit 1
              fi
            else
              # Bouncer not registered
              # Remove any previously saved API key
              rm -f '${apiKeyFile}'
              # Register the bouncer and save the new API key
              if ! $cscli bouncers add --output raw -- ${lib.escapeShellArg cfg.registerBouncer.bouncerName} >${apiKeyFile}; then
                # Failed to register the bouncer
                rm ${apiKeyFile}
                exit 1
              fi
            fi
          '';
          serviceConfig = {
            Type = "oneshot";

            # Run as crowdsec user to be able to use cscli
            User = config.services.crowdsec.user;
            Group = config.services.crowdsec.group;

            StateDirectory = "crowdsec-firewall-bouncer-register";

            ReadWritePaths = [
              # Needs write permissions to add the bouncer
              "/var/lib/crowdsec"
            ];

            DynamicUser = true;
            LockPersonality = true;
            PrivateDevices = true;
            ProcSubset = "pid";
            ProtectClock = true;
            ProtectControlGroups = true;
            ProtectHome = true;
            ProtectHostname = true;
            ProtectKernelLogs = true;
            ProtectKernelModules = true;
            ProtectKernelTunables = true;
            ProtectProc = "invisible";
            RestrictNamespaces = true;
            RestrictRealtime = true;
            SystemCallArchitectures = "native";

            RestrictAddressFamilies = "none";
            CapabilityBoundingSet = [ "" ];
            SystemCallFilter = [
              "@system-service"
              "~@privileged"
              "~@resources"
            ];
            UMask = "0077";
          };
        };

        crowdsec-firewall-bouncer =
          let
            runtime-dir-name = "crowdsec-firewall-bouncer";
            final-config-file = "/run/${runtime-dir-name}/config.yaml";
            generateConfig = pkgs.writeShellScript "crowdsec-firewall-bouncer-config" ''
              set -euo pipefail
              umask 077

              # Copy the template to the final location
              cp ${format.generate "crowdsec-firewall-bouncer-config-template.yml" cfg.settings} ${final-config-file}
              chmod 0600 ${final-config-file}

              # Replace the api_key placeholder with the secret
              ${lib.getExe pkgs.replace-secret} '@API_KEY_FILE@' "$CREDENTIALS_DIRECTORY/API_KEY_FILE" ${final-config-file}
            '';
          in
          rec {
            description = "CrowdSec Firewall Bouncer";
            wantedBy = [ "multi-user.target" ];
            after = [ "network.target" ] ++ (lib.optional config.services.crowdsec.enable "crowdsec.service");
            wants = after;
            requires = lib.optional cfg.registerBouncer.enable "crowdsec-firewall-bouncer-register.service";

            # When using iptables/ipset modes, the bouncer calls external binaries so they must be added to the path.
            # For nftables mode, it does not depend on external binaries.
            path = lib.optionals ((cfg.settings.mode == "iptables") || (cfg.settings.mode == "ipset")) [
              pkgs.iptables
              pkgs.ipset
            ];

            serviceConfig = rec {
              Type = "notify";
              ExecStartPre = [
                generateConfig
                "${lib.getExe cfg.package} -c ${final-config-file} -t"
              ];
              ExecStart = [
                "${lib.getExe cfg.package} -c ${final-config-file}"
              ];

              # Same as upstream
              LimitNOFILE = 65536;
              KillMode = "mixed";

              # Load the api_key secret to be able to use it when generating the final config
              LoadCredential =
                if (cfg.registerBouncer.enable) then
                  "API_KEY_FILE:${apiKeyFile}"
                else if (cfg.secrets.apiKeyPath != null) then
                  "API_KEY_FILE:${cfg.secrets.apiKeyPath}"
                else
                  null;

              DynamicUser = true;
              RuntimeDirectory = runtime-dir-name;

              LockPersonality = true;
              PrivateDevices = true;
              ProcSubset = "pid";
              ProtectClock = true;
              ProtectControlGroups = true;
              ProtectHome = true;
              ProtectHostname = true;
              ProtectKernelLogs = true;
              ProtectKernelModules = true;
              ProtectKernelTunables = true;
              ProtectProc = "invisible";
              RestrictNamespaces = true;
              RestrictRealtime = true;
              SystemCallArchitectures = "native";

              RestrictAddressFamilies = [
                "AF_NETLINK"
                "AF_UNIX"
                "AF_INET"
                "AF_INET6"
              ];
              AmbientCapabilities = [
                # Needed to be able to manipulate the rulesets
                "CAP_NET_ADMIN"
              ];
              CapabilityBoundingSet = AmbientCapabilities;
              SystemCallFilter = [
                "@system-service"
                "~@privileged"
                "~@resources"
              ];
              UMask = "0077";
            };
          };
      };
  };

  meta = {
    maintainers = with lib.maintainers; [ nicomem ];
  };
}