diff --git a/modules/crowdsec/crowdsec-firewall-bouncer.nix b/modules/crowdsec/crowdsec-firewall-bouncer.nix new file mode 100644 index 0000000..bd2c024 --- /dev/null +++ b/modules/crowdsec/crowdsec-firewall-bouncer.nix @@ -0,0 +1,424 @@ +{ + 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 ` 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 . + ''; + 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.config.api.server.listen_uri}"; + defaultText = lib.literalExpression ''http://$\{config.services.crowdsec.settings.config.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 ` 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"; + log_dir = lib.mkDefault "/var/log/crowdsec-firewall-bouncer"; + + # 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; + path = [ config.services.crowdsec.package ]; + script = '' + # Ensure the directory exists + mkdir -p "$(dirname ${apiKeyFile})" || true + + echo "Checking bouncer registration..." + if cscli bouncers list --output json | ${lib.getExe pkgs.jq} -e -- ${lib.escapeShellArg "any(.[]; .name == \"${cfg.registerBouncer.bouncerName}\")"} >/dev/null; then + echo "Bouncer already registered. Verify the API key is still present" + if [ ! -f ${apiKeyFile} ]; then + echo "Bouncer registered but API key is not present" + echo "Unregistering bouncer..." + cscli bouncers delete ${cfg.registerBouncer.bouncerName} || true + else + echo "API key file exists, nothing to do" + exit 0 + fi + else + echo "Bouncer not registered" + echo "Remove any previously saved API key" + rm -f '${apiKeyFile}' + fi + + echo "Register the bouncer and save the new API key" + if ! cscli bouncers add --output raw -- ${lib.escapeShellArg cfg.registerBouncer.bouncerName} > ${apiKeyFile} 2>&1; then + echo "Failed to register the bouncer" + cat ${apiKeyFile} || true # Show error message + rm -f ${apiKeyFile} + exit 1 + fi + + chmod 0440 ${apiKeyFile} || true + echo "Successfully registered bouncer and saved API key" + + cscli bouncers list + ''; + 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 crowdsec"; + StateDirectoryMode = "0750"; + + 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"; + ExecStartPost = "+systemctl --no-block try-restart crowdsec-firewall-bouncer.service"; + }; + }; + + 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} + ''; + + isIptables = (cfg.settings.mode == "iptables") || (cfg.settings.mode == "ipset"); + isNftables = cfg.settings.mode == "nftables"; + in + rec { + description = "CrowdSec Firewall Bouncer"; + wantedBy = [ "multi-user.target" ]; + partOf = lib.optional isNftables "nftables.service" ++ lib.optional isIptables "firewall.service"; + after = + lib.optional isNftables "nftables.service" + ++ lib.optional isIptables "firewall.service" + ++ 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 isIptables [ + 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; + + # Run as crowdsec user to be able to use cscli + User = config.services.crowdsec.user; + Group = config.services.crowdsec.group; + + DynamicUser = true; + RuntimeDirectory = runtime-dir-name; + + StateDirectory = "crowdsec-firewall-bouncer-register crowdsec"; + StateDirectoryMode = "0750"; + LogsDirectory = "crowdsec-firewall-bouncer"; + + 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" + ] + ++ lib.optional ((cfg.settings.mode == "iptables") || (cfg.settings.mode == "ipset")) "CAP_NET_RAW"; + CapabilityBoundingSet = AmbientCapabilities; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + UMask = "0077"; + + Restart = "always"; + }; + }; + }; + }; + + meta = { + maintainers = with lib.maintainers; [ + nicomem + tornax + ]; + }; +} diff --git a/modules/crowdsec/crowdsec.nix b/modules/crowdsec/crowdsec.nix new file mode 100644 index 0000000..eff785a --- /dev/null +++ b/modules/crowdsec/crowdsec.nix @@ -0,0 +1,1084 @@ +{ + config, + pkgs, + lib, + ... +}: +let + cfg = config.services.crowdsec; + yaml = pkgs.formats.yaml { }; + + config_paths = cfg.settings.config.config_paths; + + # Reason: + # https://github.com/NixOS/nixpkgs/pull/446307#issuecomment-3955091336 + secret_path = lib.types.either lib.types.path lib.types.nonEmptyStr; +in +{ + imports = [ + (lib.mkRemovedOptionModule [ + "services" + "crowdsec" + "localConfig" + ] "Please move options from `services.crowdsec.localConfig` to `services.crowdsec.settings`.") + + (lib.mkChangedOptionModule + [ "services" "crowdsec" "enrollKeyFile" ] + [ "services" "crowdsec" "settings" "console" "enrollKeyFile" ] + (config: config.services.crowdsec.enrollKeyFile) + ) + + (lib.mkChangedOptionModule + [ "services" "crowdsec" "settings" "capi" "credentialsFile" ] + [ + "services" + "crowdsec" + "settings" + "config" + "api" + "server" + "online_client" + "credentials_path" + ] + (config: config.services.crowdsec.settings.capi.credentialsFile) + ) + + (lib.mkChangedOptionModule + [ "services" "crowdsec" "settings" "lapi" "credentialsFile" ] + [ + "services" + "crowdsec" + "settings" + "config" + "api" + "client" + "credentials_path" + ] + (config: config.services.crowdsec.settings.lapi.credentialsFile) + ) + ]; + + options.services.crowdsec = { + enable = lib.mkEnableOption "CrowdSec Security Engine"; + + package = lib.mkPackageOption pkgs "crowdsec" { }; + + autoUpdateService = lib.mkEnableOption "if `true` `cscli hub update` will be executed daily. See `https://docs.crowdsec.net/docs/cscli/cscli_hub_update/` for more information"; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Whether to automatically open firewall ports for `crowdsec`. + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + description = "The user to run crowdsec as"; + default = "crowdsec"; + }; + + group = lib.mkOption { + type = lib.types.str; + description = "The group to run crowdsec as"; + default = "crowdsec"; + }; + + name = lib.mkOption { + type = lib.types.str; + description = '' + Name of the machine when registering it at the central or local api. + ''; + default = config.networking.hostName; + defaultText = lib.literalExpression "config.networking.hostName"; + }; + + readOnlyPaths = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = '' + Additional read-only paths of the host which the crowdsec service can access. + + Mostly relevant if you'd like to let `crowdsec` read additional log files. + ''; + default = [ ]; + example = [ + "/var/log/vaultwarden" + "/var/log/nginx" + ]; + }; + + extraGroups = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = '' + List of groups which the internal (dynamic-) user should be assigned to. + + Relevant if only some groups are able to read some logs. + ''; + default = [ "systemd-journal" ]; + example = [ + "nginx" + "log" + ]; + }; + + hub = lib.mkOption { + description = '' + Hub collections, parsers, AppSec rules, etc. + ''; + type = lib.types.submodule { + options = { + collections = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "List of hub collections to install"; + example = [ "crowdsecurity/linux" ]; + }; + + scenarios = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "List of hub scenarios to install"; + example = [ "crowdsecurity/ssh-bf" ]; + }; + + parsers = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "List of hub parsers to install"; + example = [ "crowdsecurity/sshd-logs" ]; + }; + + postoverflows = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "List of hub postoverflows to install"; + example = [ "crowdsecurity/auditd-nix-wrappers-whitelist-process" ]; + }; + + appsec-configs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "List of hub appsec configurations to install"; + example = [ "crowdsecurity/appsec-default" ]; + }; + + appsec-rules = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "List of hub appsec rules to install"; + example = [ "crowdsecurity/base-config" ]; + }; + }; + }; + default = { }; + }; + + settings = lib.mkOption { + description = "Config options for the main config file."; + type = lib.types.submodule { + options = { + config = lib.mkOption { + description = '' + Settings for the main CrowdSec configuration file. + + Defaults are _mostly_ equal to the default linux config file: . + + See here for possible values: . + ''; + type = lib.types.submodule { + freeformType = yaml.type; + options = { + common = { + log_media = lib.mkOption { + type = lib.types.enum [ + "stdout" + "file" + ]; + default = "stdout"; + description = "Log media"; + }; + }; + + config_paths = { + config_dir = lib.mkOption { + type = lib.types.path; + default = "/etc/crowdsec"; + description = "Main configuration directory of crowdsec."; + }; + + data_dir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/crowdsec/data"; + description = "This is where crowdsec is going to store data, such as files downloaded by scenarios, geolocalisation database, metabase configuration database, or even SQLite database."; + }; + + simulation_path = lib.mkOption { + type = lib.types.path; + default = yaml.generate "simulation.yaml" cfg.settings.simulation; + defaultText = "Path to the nixos generated file."; + description = '' + NOTE: This file is generated from `config.services.crowdsec.settings.simulation`. + If you change this path then `config.services.crowdsec.settings.simulation` will be ignored so you have to + write the content this file on your own. + ''; + }; + + hub_dir = lib.mkOption { + type = lib.types.path; + default = "${config_paths.data_dir}/hub"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.data_dir}/hub"; + description = "Directory where `cscli` will store parsers, scenarios, collections and such."; + }; + + index_path = lib.mkOption { + type = lib.types.path; + default = "${config_paths.hub_dir}/.index.json"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.hub_dir}/.index.json"; + description = "Path to the `.index.json` file downloaded by `cscli` to know the list of available configurations."; + }; + + notification_dir = lib.mkOption { + type = lib.types.path; + default = "${config_paths.config_dir}/notifications"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_dir}/notifications"; + description = "Path to directory where configuration files for notification plugins are kept."; + }; + + plugin_dir = lib.mkOption { + type = lib.types.path; + default = "${config_paths.config_dir}/plugins"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.data_dir}/plugins"; + description = "Path to directory where the plugin binaries/scripts are located."; + }; + + pattern_dir = lib.mkOption { + type = lib.types.path; + default = pkgs.symlinkJoin { + name = "crowdsec-patterns"; + paths = [ + cfg.settings.patterns + "${lib.attrsets.getOutput "out" cfg.package}/share/crowdsec/config/patterns/" + ]; + }; + defaultText = '' + A directory which contains the patterns of `config.services.crowdsec.settings.patterns` and the patterns + from this directory: . + ''; + description = "Path to directory where pattern files are located."; + }; + }; + + crowdsec_service = { + acquisition_dir = lib.mkOption { + type = lib.types.path; + default = "${config_paths.config_dir}/acquis.d"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.config_dir}/acquis.d"; + description = '' + Path to a directory where each yaml is considered as a acquisition configuration file containing logs that needs to be read. + If both acquisition_dir and acquisition_path are specified, the entries are merged altogether. + ''; + }; + }; + + cscli = { + hub_branch = lib.mkOption { + type = lib.types.nonEmptyStr; + default = "master"; + description = '' + The git branch on which cscli is going to fetch configurations. + + See for more information. + ''; + }; + prometheus_uri = lib.mkOption { + type = lib.types.str; + default = "http://${cfg.settings.config.prometheus.listen_addr}:${toString cfg.settings.config.prometheus.listen_port}"; + defaultText = "The prometheus address and port set in `services.crowdsec.settings.config.prometheus`."; + description = '' + (>1.0.7) An uri (without the trailing /metrics) that will be used by cscli metrics command, ie. http://127.0.0.1:6060/ + + See for more information. + ''; + }; + }; + + plugin_config = { + user = lib.mkOption { + type = lib.types.str; + description = "The user to run crowdsec plugins as"; + default = cfg.user; + defaultText = lib.literalExpression "\${config.services.crowdsec.user}"; + }; + + group = lib.mkOption { + type = lib.types.str; + description = "The group to run crowdsec plugins as"; + default = cfg.group; + defaultText = lib.literalExpression "\${config.services.crowdsec.group}"; + }; + }; + + db_config = { + db_path = lib.mkOption { + type = lib.types.path; + default = "${config_paths.data_dir}/crowdsec.db"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.data_dir}/crowdsec.db"; + description = "The path to the database file (only if the type of database is `sqlite`) or path to socket file (only if the type of database is `mysql|pgx`)"; + }; + type = lib.mkOption { + type = lib.types.str; + default = "sqlite"; + description = "The database type"; + }; + }; + + api = { + client.credentials_path = lib.mkOption { + type = secret_path; + default = "${config_paths.data_dir}/local_api_credentials.yaml"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.data_dir}/local_api_credentials.yaml"; + description = "Path to the credential files (contains API url + login/password)."; + }; + + server = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable or disable the CrowdSec Local API (`true` by default)."; + }; + + listen_uri = lib.mkOption { + type = lib.types.nonEmptyStr; + default = "127.0.0.1:8080"; + description = "Address and port listen configuration, the form `host:port`."; + }; + + profiles_path = lib.mkOption { + type = lib.types.path; + default = pkgs.writeText "profiles.yaml" '' + --- + ${lib.strings.concatMapStringsSep "\n---\n" (lib.generators.toYAML { }) cfg.settings.profiles} + --- + ''; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.config_dir}/profiles.yaml"; + description = "Path to the profiles file."; + }; + + console_path = lib.mkOption { + type = lib.types.path; + default = "${config_paths.data_dir}/console.yaml"; + defaultText = lib.literalExpression "\${config.services.crowdsec.settings.config.config_paths.data_dir}/console.yaml"; + description = "The path to the console configuration."; + }; + + online_client.credentials_path = lib.mkOption { + type = lib.types.nullOr secret_path; + default = null; + example = "\${config.services.crowdsec.settings.config.config_paths.data_dir}/online_api_credentials.yaml"; + description = '' + Path to a file containing credentials for the Central API. + To automatically register with `crowdsec-setup`, set this option (typically to ''${config.services.crowdsec.settings.config.config_paths.data_dir}/online_api_credentials.yaml). + The file will be automatically created, unless it already exists. + ''; + }; + }; + }; + + prometheus = { + enabled = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable or disable the CrowdSec prometheus exporter."; + }; + + listen_addr = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Prometheus listen address."; + }; + + listen_port = lib.mkOption { + type = lib.types.port; + default = 6060; + description = "Prometheus listen port."; + }; + }; + }; + }; + }; + + simulation = lib.mkOption { + type = yaml.type; + default = { + simulation = false; + }; + description = '' + Attributes inside the simulation.yaml file. + ''; + }; + + acquisitions = lib.mkOption { + type = lib.types.listOf yaml.type; + default = [ + { + source = "journalctl"; + journalctl_filter = [ "_SYSTEMD_UNIT=sshd.service" ]; + labels = { + type = "syslog"; + }; + } + ]; + description = '' + A list of acquisition specifications, which define the data sources you want to be parsed. + + See for details. + ''; + }; + + scenarios = lib.mkOption { + type = lib.types.listOf yaml.type; + default = [ ]; + description = '' + A list of scenarios specifications. + + See for details. + ''; + example = [ + { + type = "leaky"; + name = "crowdsecurity/myservice-bf"; + description = "Detect myservice bruteforce"; + filter = "evt.Meta.log_type == 'myservice_failed_auth'"; + leakspeed = "10s"; + capacity = 5; + groupby = "evt.Meta.source_ip"; + } + ]; + }; + + parsers = lib.mkOption { + type = lib.types.submodule { + options = { + s00Raw = lib.mkOption { + type = lib.types.listOf yaml.type; + default = [ ]; + description = '' + A list of stage s00-raw specifications. Most of the time, those are already included in the hub, but are presented here anyway. + + See for details. + ''; + }; + s01Parse = lib.mkOption { + type = lib.types.listOf yaml.type; + default = [ ]; + description = '' + A list of stage s01-parse specifications. + + See for details. + ''; + example = [ + { + filter = "1=1"; + debug = true; + onsuccess = "next_stage"; + name = "example/custom-service-logs"; + description = "Parsing custom service logs"; + grok = { + pattern = "^%{DATA:some_data}$"; + apply_on = "message"; + }; + statics = [ + { + parsed = "is_my_custom_service"; + value = "yes"; + } + ]; + } + ]; + }; + s02Enrich = lib.mkOption { + type = lib.types.listOf yaml.type; + default = [ ]; + description = '' + A list of stage s02-enrich specifications. Inside this list, you can specify Parser Whitelists. + + See for details. + ''; + example = [ + { + name = "myips/whitelist"; + description = "Whitelist parse events from my IPs"; + whitelist = { + reason = "My IP ranges"; + ip = [ + "1.2.3.4" + ]; + cidr = [ + "1.2.3.0/24" + ]; + }; + } + ]; + }; + }; + }; + description = '' + The set of parser specifications. + + See for details. + ''; + default = { }; + }; + + postOverflows = lib.mkOption { + type = lib.types.submodule { + options = { + s01Whitelist = lib.mkOption { + type = lib.types.listOf yaml.type; + default = [ ]; + description = '' + A list of stage s01-whitelist specifications. Inside this list, you can specify Postoverflows Whitelists. + + See for details. + ''; + example = [ + { + name = "postoverflows/whitelist_my_dns_domain"; + description = "Whitelist my reverse DNS"; + whitelist = { + reason = "Don't ban me"; + expression = [ + "evt.Enriched.reverse_dns endsWith '.local.'" + ]; + }; + } + ]; + }; + }; + }; + description = '' + The set of Postoverflows specifications. + + See for details. + ''; + default = { }; + }; + + contexts = lib.mkOption { + type = lib.types.listOf yaml.type; + description = '' + A list of additional contexts to specify. + + See for details. + ''; + example = [ + { + context = { + target_uri = [ "evt.Meta.http_path" ]; + user_agent = [ "evt.Meta.http_user_agent" ]; + method = [ "evt.Meta.http_verb" ]; + status = [ "evt.Meta.http_status" ]; + }; + } + ]; + default = [ ]; + }; + + notifications = lib.mkOption { + type = lib.types.listOf yaml.type; + description = '' + A list of notifications to enable and use in your profiles. Note that for now, only the plugins shipped by default with CrowdSec are supported. + + See for details. + ''; + example = [ + { + type = "http"; + name = "default_http_notification"; + log_level = "info"; + format = '' + {{.|toJson}} + ''; + url = "https://example.com/hook"; + method = "POST"; + } + ]; + default = [ ]; + }; + + profiles = lib.mkOption { + type = lib.types.listOf yaml.type; + description = '' + A list of profiles to enable. + + See for more details. + ''; + default = [ + { + name = "default_ip_remediation"; + filters = [ + "Alert.Remediation == true && Alert.GetScope() == 'Ip'" + ]; + decisions = [ + { + type = "ban"; + duration = "4h"; + } + ]; + on_success = "break"; + } + { + name = "default_range_remediation"; + filters = [ + "Alert.Remediation == true && Alert.GetScope() == 'Range'" + ]; + decisions = [ + { + type = "ban"; + duration = "4h"; + } + ]; + on_success = "break"; + } + ]; + }; + + patterns = lib.mkOption { + type = lib.types.listOf lib.types.package; + description = '' + A list of files containing custom grok patterns. + + See for more details. + ''; + default = [ ]; + example = lib.literalExpression '' + [ (pkgs.writeTextDir "custom_service_logs" (builtins.readFile ./custom_service_logs)) ] + ''; + }; + + console = lib.mkOption { + type = lib.types.submodule { + options = { + enrollKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + example = "/run/crowdsec/console_token.yaml"; + description = '' + The Console Token file to use. + + Normally you'd have to do `cscli enroll `. You can put this `` in a file instead and pass a path to this file into this option. + + Available by clicking the "Enroll command" button at https://app.crowdsec.net/security-engines?distribution=linux + ''; + default = null; + }; + }; + }; + description = '' + Console Configuration attributes + ''; + default = { }; + }; + }; + }; + }; + }; + + config = + let + setupScript = pkgs.writeShellApplication { + name = "crowdsec-setup"; + runtimeInputs = [ + cfg.package + pkgs.coreutils + ]; + text = + let + argString = arg: lib.concatMapStringsSep " " lib.escapeShellArg arg; + maybeInstall = + x: + lib.optionalString ( + builtins.isList cfg.hub.${x} && cfg.hub.${x} != [ ] + ) "cscli ${lib.toLower x} install ${argString cfg.hub.${x}}"; + + maybeCopyFile = src: dst: '' + if [ ! -e "${dst}" ]; then + install ${src} ${dst} + fi + ''; + + installNotificationPlugin = name: '' + install -m 551 -D ${cfg.package}/libexec/crowdsec/plugins/notification-${name} ${cfg.settings.config.config_paths.plugin_dir}/notification-${name} + ''; + in + '' + echo "Creating directories..." + mkdir -p ${config_paths.config_dir}/console + mkdir -p ${config_paths.data_dir} + mkdir -p ${cfg.settings.config.crowdsec_service.acquisition_dir} + mkdir -p ${config_paths.hub_dir} + + # to be able to create notifications + echo "Installing notification plugins..." + ${installNotificationPlugin "dummy"} + ${installNotificationPlugin "email"} + ${installNotificationPlugin "file"} + ${installNotificationPlugin "http"} + ${installNotificationPlugin "sentinel"} + ${installNotificationPlugin "slack"} + ${installNotificationPlugin "splunk"} + + echo "Creating files..." + + ${maybeCopyFile "${cfg.package}/share/crowdsec/config/console.yaml" cfg.settings.config.api.server.console_path} + ${maybeCopyFile "${cfg.package}/share/crowdsec/config/detect.yaml" "${cfg.settings.config.config_paths.data_dir}/detect.yaml"} + + # NOTE: THE CODE BELOW NEEDS TO STAY BELOW + # Don't move code logic below this comment to the top of this comment because + # the order in which the commands are gonna be executed is relevant! + echo "Updating hub..." + + cscli hub update + + echo "Installing resources..." + + ${maybeInstall "collections"} + ${maybeInstall "scenarios"} + ${maybeInstall "parsers"} + ${maybeInstall "postoverflows"} + ${maybeInstall "appsec-configs"} + ${maybeInstall "appsec-rules"} + + ${lib.optionalString (cfg.settings.config.api.server.online_client.credentials_path != null) '' + if [ ! -s "${cfg.settings.config.api.server.online_client.credentials_path}" ]; then + echo "No local online API credentials created. Registering..." + cscli capi register + fi + ''} + + ${lib.optionalString cfg.settings.config.api.server.enable '' + if [ ! -s ${cfg.settings.config.api.client.credentials_path} ]; then + echo "No local API credentials currently created. Generating local API credentials..." + cscli machines add "${cfg.name}" --auto --file ${cfg.settings.config.api.client.credentials_path} + fi + ''} + + ${lib.optionalString (cfg.settings.console.enrollKeyFile != null) '' + if [ -e "$CREDENTIALS_DIRECTORY/enrollKeyFile" ]; then + echo "Enrolling to the online console..." + cscli console enroll "$(<"$CREDENTIALS_DIRECTORY/enrollKeyFile")" --name ${cfg.name} + fi + ''} + echo "Completed crowdsec setup" + ''; + }; + in + lib.mkIf (cfg.enable) { + + warnings = + [ ] + ++ lib.optionals (cfg.settings.profiles == [ ]) [ + "By not specifying profiles in services.crowdsec.settings.profiles, CrowdSec will not react to any alert by default." + ] + ++ lib.optionals (cfg.settings.acquisitions == [ ]) [ + "By not specifying acquisitions in services.crowdsec.settings.acquisitions, CrowdSec will not look for any data source." + ] + ++ lib.optionals (builtins.hasAttr "daemonize" cfg.settings.config.common) [ + "[`services.crowdsec.settings.config.common.daemonize`]: It's deprecated. See " + ] + ++ lib.optionals (cfg.settings.config.config_paths.config_dir != "/etc/crowdsec") [ + "`services.crowdsec` assumes that `services.crowdsec.settings.config_paths.config_dir = '/etc/crowdsec'`. Changing that path will potentially require some manual adjustings to make crowdsec work." + ] + ++ lib.optionals (cfg.settings.config.config_paths.data_dir != "/var/lib/crowdsec/data") [ + "`services.crowdsec` assumes that `services.crowdsec.settings.config_paths.data_dir = '/var/lib/crowdsec/data'`. Changing that path will potentially require some manual adjustings to make crowdsec work." + ]; + + assertions = [ + # `cfg.settings.config.api.server` needs to be set up if the user wants + # to pull things from the hub. See: + # https://github.com/NixOS/nixpkgs/pull/446307#issuecomment-3533763091 + { + assertion = + let + usesHub = + let + cfg-hub-lists = builtins.filter (value: builtins.typeOf value == "list") ( + builtins.attrValues cfg.hub + ); + lists-are-not-empty = builtins.all (list: list != [ ]) cfg-hub-lists; + in + lists-are-not-empty; + + onlineApiCredentialsAreSet = + builtins.hasAttr "api" cfg.settings.config + && builtins.hasAttr "server" cfg.settings.config.api + && builtins.hasAttr "online_client" cfg.settings.config.api.server + && builtins.hasAttr "credentials_path" cfg.settings.config.api.server.online_client; + in + !usesHub || (usesHub && onlineApiCredentialsAreSet); + + message = "`config.services.crowdsec.settings.config.api.server.online_client.credentials_path` needs to be set."; + } + ]; + + environment = { + systemPackages = + let + cscliWrapper = pkgs.symlinkJoin { + name = "cscli"; + paths = [ + # `--working-directory=/var/lib/crowdsec/data/hub`: Because `cscli hubtest` needs to be in the `hub` directory. + (pkgs.writeShellScriptBin "cscli" '' + exec systemd-run \ + --quiet \ + --pty \ + --wait \ + --collect \ + --pipe \ + --service-type=exec \ + --working-directory=/var/lib/crowdsec/data/hub \ + --property=ExecPaths="${cfg.settings.config.config_paths.plugin_dir}" \ + --property=User=${cfg.user} \ + --property=Group=${cfg.group} \ + --property=DynamicUser=true \ + --property=StateDirectory="crowdsec" \ + --property=StateDirectoryMode="0750" \ + --property=ConfigurationDirectory="crowdsec" \ + --property=ConfigurationDirectoryMode="0750" \ + -- \ + ${lib.getExe' cfg.package "cscli"} "$@" + '') + (pkgs.runCommand "cscli-completions" { } '' + mkdir -p $out/share + ln -s ${cfg.package}/share/bash-completion $out/share/bash-completion + ln -s ${cfg.package}/share/zsh $out/share/zsh + ln -s ${cfg.package}/share/fish $out/share/fish + '') + ]; + }; + in + [ cscliWrapper ]; + + # NOTE: Is it worth it to create a script instead which removes and (re-)creates those files instead of using `environment.etc`? + # This would fix the permission issue and we wouldn't need the `chmod` and `chown` "hack" in the setup-service. + etc = + let + config_dir = "crowdsec"; + + entry_permissions = { + user = cfg.user; + group = cfg.group; + }; + + start = lib.mapAttrs (name: value: lib.mergeAttrs value entry_permissions) { + # for some reason, `-c config` gets ignored for some commands, hence we really need to create the config files + "${config_dir}/config.yaml".source = "${cfg.package}/share/crowdsec/config/config.yaml"; + "${config_dir}/config.yaml.local".source = yaml.generate "config.yaml.local" cfg.settings.config; + "${config_dir}/acquis.d/00-nixos-generated.yaml".source = pkgs.writeText "aquisitions.yaml" '' + --- + ${lib.strings.concatMapStringsSep "\n---\n" (lib.generators.toYAML { }) cfg.settings.acquisitions} + --- + ''; + }; + + attrListToEntries = + attrList: target_dir: + let + file_paths = map (yaml.generate "crowdsec-setting.yaml") attrList; + + # Example usage: + # enumerated_entries 0 ["path1" "path2"] + # => + # [ + # { name = "${target_dir}/0-nixos-generated.yaml"; source = path1; } + # { name = "${target_dir}/1-nixos-generated.yaml"; source = path2; } + # ] + enumerated_entries = + idx: paths: + if paths == [ ] then + [ ] + else + let + dst_path = "${config_dir}/${target_dir}/${toString idx}-nixos-generated.yaml"; + + src_path = builtins.head paths; + rest = builtins.tail paths; + + entry = { + name = dst_path; + + value = lib.mergeAttrs entry_permissions { + source = src_path; + }; + }; + in + [ entry ] ++ (enumerated_entries (idx + 1) rest); + in + builtins.listToAttrs (enumerated_entries 0 file_paths); + + in + builtins.foldl' lib.mergeAttrs start [ + (attrListToEntries cfg.settings.scenarios "scenarios") + (attrListToEntries cfg.settings.parsers.s00Raw "parsers/s00-raw") + (attrListToEntries cfg.settings.parsers.s01Parse "parsers/s01-parse") + (attrListToEntries cfg.settings.parsers.s02Enrich "parsers/s02-enrich") + (attrListToEntries cfg.settings.postOverflows.s01Whitelist "postoverflows/s01-whitelist") + (attrListToEntries cfg.settings.contexts "contexts") + (attrListToEntries cfg.settings.notifications "notifications") + ]; + }; + + systemd = + let + createServiceConfig = + attrs: + lib.recursiveUpdate { + User = cfg.user; + Group = cfg.group; + UMask = "0077"; + + DynamicUser = true; + ProtectHome = true; + PrivateDevices = true; + ProtectHostname = "true:${cfg.name}"; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = "strict"; + ProtectProc = "invisible"; + + LockPersonality = true; + RestrictRealtime = true; + RestrictNamespaces = true; + + LoadCredential = lib.optional ( + cfg.settings.console.enrollKeyFile != null + ) "enrollKeyFile:${cfg.settings.console.enrollKeyFile}"; + + SystemCallFilter = [ "@system-service" ]; + + SystemCallErrorNumber = "EPERM"; + SystemCallArchitectures = "native"; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + + StateDirectory = "crowdsec"; + StateDirectoryMode = "0750"; + ConfigurationDirectory = "crowdsec"; + ConfigurationDirectoryMode = "0750"; + } attrs; + in + { + timers.crowdsec-update-hub = lib.mkIf (cfg.autoUpdateService) { + description = "Update the crowdsec hub index"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "daily"; + RandomizedDelaySec = 300; + Persistent = "yes"; + Unit = "crowdsec-update-hub.service"; + }; + }; + + services = { + crowdsec-update-hub = lib.mkIf (cfg.autoUpdateService) { + description = "Update the crowdsec hub index"; + # for dns resolving + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = createServiceConfig { + Type = "oneshot"; + ExecStart = [ + "${lib.getExe' cfg.package "cscli"} --warning hub update" + "${lib.getExe' cfg.package "cscli"} --warning hub upgrade" + ]; + ExecStartPost = "+systemctl reload crowdsec.service"; + }; + }; + + crowdsec-setup = { + description = "CrowdSec setup service"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + before = [ "crowdsec.service" ]; + # for dns resolving + after = [ "network-online.target" ]; + serviceConfig = createServiceConfig { + Type = "oneshot"; + ExecStartPre = [ + # `/etc/crodwsec` MUST be writeable for crowdsec because `cscli` writes and creates new files in its `config_dir`. + # `environment.etc` and `systemd.tmpfiles` are not able to give the directories the correct owner and group + # due to `DynamicUser=true` so `environment.etc` and `systemd.tmpfiles` don't know the user and group `crowdsec`. + # That's why we are doing it ourself. + "+${lib.getExe' pkgs.coreutils "chown"} ${cfg.user}:${cfg.group} -R ${config_paths.config_dir}" + "+${lib.getExe' pkgs.coreutils "chmod"} 750 -R ${config_paths.config_dir}" + ]; + ExecStart = lib.getExe setupScript; + }; + }; + + crowdsec = { + description = "CrowdSec Security Engine"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + "crowdsec-setup.service" + ]; + + serviceConfig = + let + crowdsec = "${lib.getExe' cfg.package "crowdsec"}"; + in + createServiceConfig { + Type = "notify"; + RestartSec = 60; + + ProtectKernelLogs = false; + + ReadOnlyPaths = cfg.readOnlyPaths; + SupplementaryGroups = cfg.extraGroups; + + ExecStartPre = "${crowdsec} -t -error"; + ExecStart = "${crowdsec} -info"; + ExecReload = [ + "${crowdsec} -t -error" + "${lib.getExe' pkgs.coreutils "kill"} -HUP $MAINPID" + ]; + + ExecPaths = [ cfg.settings.config.config_paths.plugin_dir ]; + + Restart = "always"; + }; + }; + }; + }; + + networking.firewall.allowedTCPPorts = + let + parsePortFromURLOption = + url: option: + builtins.addErrorContext "extracting a port from URL: `${option}` requires a port to be specified, but we failed to parse a port from '${url}'" ( + lib.strings.toInt (lib.last (lib.strings.splitString ":" url)) + ); + in + lib.mkIf cfg.openFirewall [ + cfg.settings.config.prometheus.listen_port + (parsePortFromURLOption cfg.settings.config.api.server.listen_uri "config.services.crowdsec.settings.config.api.server.listen_uri") + ]; + }; + + meta = { + maintainers = with lib.maintainers; [ + M0ustach3 + tornax + jk + ]; + }; +} diff --git a/services/crowdsec.nix b/services/crowdsec.nix index 786ff60..940f1fa 100644 --- a/services/crowdsec.nix +++ b/services/crowdsec.nix @@ -1,13 +1,17 @@ -# services/crowdsec.nix — Community-driven IDS/IPS for the mediaserver. +# services/crowdsec.nix — Vendors the crowdsec module rewrite from +# https://github.com/NixOS/nixpkgs/pull/446307 (TornaxO7's branch) until +# it lands upstream. The upstream module in nixpkgs at the pinned revision +# is broken for first-time bootstrap (no auto cscli machines add, DynamicUser +# state ownership wedges). # -# Removes cleanly: delete this file + its import from common.nix, then -# on the server: `sudo rm -rf /var/lib/crowdsec /etc/crowdsec` after a -# final rebuild. +# When PR #446307 merges to nixpkgs unstable: +# 1. Bump flake.lock past the merge commit +# 2. Delete ../modules/crowdsec/ and the disabledModules + imports lines below +# 3. The settings/option API is the same as the PR's, so config below is forward-compatible # -# Before first deploy, create /var/secrets/ntfy-url with your ntfy topic URL: +# Before first deploy, create /var/secrets/ntfy-url with your topic URL: # echo 'https://ntfy.sh/nordhammer-' | sudo tee /var/secrets/ntfy-url # sudo chmod 640 /var/secrets/ntfy-url -# Then subscribe to the same URL in the ntfy Android/iOS app. { config, lib, ... }: let ntfyUrlFile = "/var/secrets/ntfy-url"; @@ -17,22 +21,38 @@ let else "https://ntfy.sh/CHANGE-ME-CREATE-VAR-SECRETS-NTFY-URL"; in { + disabledModules = [ + "services/security/crowdsec.nix" + "services/security/crowdsec-firewall-bouncer.nix" + ]; + + imports = [ + ../modules/crowdsec/crowdsec.nix + ../modules/crowdsec/crowdsec-firewall-bouncer.nix + ]; + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { services.crowdsec = { enable = true; + name = "fredos-mediaserver"; - # Hub collections — parsers + scenarios, pulled from the community hub. hub.collections = [ - "crowdsecurity/linux" # sshd + linux privilege escalation - "crowdsecurity/nginx" # nginx log parser - "crowdsecurity/base-http-scenarios" # generic HTTP attack patterns + "crowdsecurity/linux" # sshd + linux LPE + "crowdsecurity/nginx" # nginx parser + "crowdsecurity/base-http-scenarios" # generic HTTP attacks "crowdsecurity/http-cve" # known-CVE fingerprints "crowdsecurity/whitelist-good-actors" # don't ban legit crawlers ]; - localConfig = { - # Log sources to ingest. Labels drive which parsers apply. + # Allow the agent to read nginx logs (it runs as DynamicUser). + readOnlyPaths = [ "/var/log/nginx" ]; + + settings = { + # config.yaml — main agent + LAPI configuration + config.api.server.listen_uri = "127.0.0.1:8081"; # 8080 is qBit + + # Log sources to ingest acquisitions = [ { source = "file"; @@ -51,7 +71,7 @@ in } ]; - # Push phone notifications via ntfy.sh. + # Push notifications via ntfy.sh notifications = [ { name = "ntfy_http"; @@ -72,7 +92,7 @@ in } ]; - # Override the default profile to attach the ntfy notifier. + # Override default profiles to attach the ntfy notifier profiles = [ { name = "default_ip_remediation"; @@ -92,7 +112,7 @@ in }; }; - # Enforce CrowdSec decisions at the firewall level via nftables. + # Firewall bouncer enforces decisions via nftables; auto-registers with LAPI services.crowdsec-firewall-bouncer = { enable = true; registerBouncer.enable = true;