{ config, lib, pkgs, namespace, ... }: let inherit (lib.${namespace}) mkOpt mkBoolOpt; name = "crowdsec"; cfg = config.${namespace}.services.${name}; # Build the notification-http plugin binary from the crowdsec source. # The nixpkgs crowdsec package omits all notification plugin binaries; # we build just the http one we need. crowdsecHttpPlugin = pkgs.buildGoModule { pname = "crowdsec-notification-http"; inherit (pkgs.crowdsec) version src; vendorHash = pkgs.crowdsec.vendorHash or null; subPackages = [ "cmd/notification-http" ]; ldflags = [ "-s" "-w" ]; meta.description = "CrowdSec HTTP notification plugin"; }; crowdsecConfig = lib.${namespace}.mkModule { inherit config name; description = "crowdsec"; options = with lib; { apiKey = mkOpt types.str "" "API key for crowdsec bouncer"; ntfy = { enable = mkBoolOpt false "Send ntfy notifications on new CrowdSec alerts"; envFile = mkOpt types.str "" "Path to env file containing NTFY_USER and NTFY_PASSWORD"; }; }; moduleConfig = { services = { crowdsec = { enable = true; inherit (cfg) openFirewall; hub = { appSecConfigs = [ "crowdsecurity/appsec-default" ]; appSecRules = [ "crowdsecurity/base-config" ]; collections = [ "crowdsecurity/http-cve" "crowdsecurity/http-dos" "crowdsecurity/linux" "crowdsecurity/nextcloud" "crowdsecurity/pgsql" "crowdsecurity/smb" "crowdsecurity/sshd" "crowdsecurity/traefik" "firix/authentik" ]; parsers = [ "crowdsecurity/actual-budget-whitelist" "crowdsecurity/jellyfin-whitelist" "crowdsecurity/jellyseerr-whitelist" "crowdsecurity/nextcloud-logs" "crowdsecurity/nextcloud-whitelist" "crowdsecurity/pgsql-logs" "crowdsecurity/smb-logs" "crowdsecurity/sshd-logs" "crowdsecurity/sshd-success-logs" "crowdsecurity/syslog-logs" ]; postOverflows = [ "crowdsecurity/auditd-nix-wrappers-whitelist-process" ]; scenarios = [ "crowdsecurity/ssh-bf" ]; }; localConfig = { acquisitions = [ { journalctl_filter = [ "_SYSTEMD_UNIT=authentik.service" ]; labels = { type = "syslog"; }; source = "journalctl"; } { journalctl_filter = [ "_SYSTEMD_UNIT=postgresql.service" ]; labels = { type = "syslog"; }; source = "journalctl"; } { journalctl_filter = [ "_SYSTEMD_UNIT=smbd.service" ]; labels = { type = "syslog"; }; source = "journalctl"; } { journalctl_filter = [ "_SYSTEMD_UNIT=sshd.service" ]; labels = { type = "syslog"; }; source = "journalctl"; } { journalctl_filter = [ "_SYSTEMD_UNIT=traefik.service" ]; labels = { type = "syslog"; }; source = "journalctl"; } ]; }; settings = { general = { api = { server = { enable = true; listen_uri = "${cfg.listenAddress}:${toString cfg.port}"; }; client = { credentials_path = lib.mkForce "${cfg.configDir}/crowdsec/client.yaml"; }; }; # plugin_config must be in settings.general so it ends up in the # NixOS-generated crowdsec.yaml that the daemon reads via -c. plugin_config = lib.mkIf cfg.ntfy.enable { user = "crowdsec"; group = "crowdsec"; }; }; capi.credentialsFile = lib.mkDefault "${cfg.configDir}/crowdsec/capi.yaml"; }; }; crowdsec-firewall-bouncer = { enable = true; registerBouncer = { enable = true; bouncerName = "nas-bouncer"; }; # secrets.apiKeyPath = config.sops.secrets."jallen-nas/crowdsec-firewall-bouncer-api-key".path; }; }; # The upstream crowdsec module uses ReadWritePaths (not StateDirectory) on # crowdsec.service, meaning it expects /var/lib/crowdsec to exist as a real # directory (created by tmpfiles). However, crowdsec-firewall-bouncer-register # declares StateDirectory=crowdsec with DynamicUser=true, which conflicts: it # tries to create /var/lib/private/crowdsec and symlink /var/lib/crowdsec → it, # but /var/lib/crowdsec already exists as a real dir. Disabling DynamicUser on # those two services lets them use the real crowdsec user/group instead, which is # consistent with how crowdsec.service itself runs. systemd = { # The ntfy plugin config YAML (with credentials baked in) is managed as a # SOPS template in sops.nix — it renders to /run/secrets/rendered/crowdsec/ # notifications/ntfy.yaml at runtime. We use a tmpfiles symlink to expose # it at the path CrowdSec scans, since environment.etc can't reference # /run paths as source. tmpfiles.rules = lib.mkIf cfg.ntfy.enable [ "L /etc/crowdsec/notifications/ntfy.yaml - - - - ${ config.sops.templates."crowdsec/notifications/ntfy.yaml".path }" ]; services = { crowdsec = { serviceConfig = lib.mkMerge [ { DynamicUser = lib.mkForce false; } (lib.mkIf (cfg.ntfy.enable && cfg.ntfy.envFile != "") { EnvironmentFile = [ cfg.ntfy.envFile ]; }) ]; }; # The upstream unit has Requires= but no After= for the register service, so # the bouncer starts in parallel and hits LoadCredential before the key file # exists. Adding After= enforces that the register service completes first. crowdsec-firewall-bouncer = { serviceConfig.DynamicUser = lib.mkForce false; after = [ "crowdsec-firewall-bouncer-register.service" ]; }; crowdsec-firewall-bouncer-register = { serviceConfig.DynamicUser = lib.mkForce false; # The upstream register script exits with an error when the bouncer is already # registered in the LAPI but the local api-key.cred file is missing (e.g. after # a system wipe or impermanence rotation). Override the script so that when the # key file is absent it deletes the stale registration and re-registers, producing # a fresh key file. script = let apiKeyFile = "/var/lib/crowdsec-firewall-bouncer-register/api-key.cred"; bouncerName = "nas-bouncer"; cscli = lib.getExe' config.services.crowdsec.package "cscli"; jq = lib.getExe pkgs.jq; in lib.mkForce '' if ${cscli} bouncers list --output json | ${jq} -e -- 'any(.[]; .name == "${bouncerName}")' >/dev/null; then # Bouncer already registered. Verify the API key is still present. if [ ! -f ${apiKeyFile} ]; then echo "Bouncer registered but API key file missing — deleting stale registration and re-registering" ${cscli} bouncers delete -- ${bouncerName} rm -f '${apiKeyFile}' if ! ${cscli} bouncers add --output raw -- ${bouncerName} >${apiKeyFile}; then rm -f '${apiKeyFile}' exit 1 fi fi else # Bouncer not registered — fresh registration. rm -f '${apiKeyFile}' if ! ${cscli} bouncers add --output raw -- ${bouncerName} >${apiKeyFile}; then rm -f '${apiKeyFile}' exit 1 fi fi ''; }; }; }; # crowdsec-firewall-bouncer-register calls cscli without -c, so cscli # looks for /etc/crowdsec/config.yaml. The upstream crowdsec.service uses # a nix store path via -c and never creates that file. Expose the full # NixOS-generated config (which includes plugin_config via # settings.general.plugin_config) at the well-known path. environment.etc = { "crowdsec/config.yaml" = let execStart = builtins.elemAt config.systemd.services.crowdsec.serviceConfig.ExecStart 1; configPath = builtins.head (builtins.match ".* -c ([^ ]+) .*" execStart); in { source = configPath; mode = "0440"; user = "crowdsec"; group = "crowdsec"; }; # --------------------------------------------------------------------------- # ntfy notifications via the CrowdSec HTTP notification plugin # --------------------------------------------------------------------------- # Place the notification-http binary at the path the NixOS crowdsec module # hardcodes for plugin_dir (/etc/crowdsec/plugins/). CrowdSec matches # plugins by their filename — it expects "notification-http" for type=http. "crowdsec/plugins/notification-http" = lib.mkIf cfg.ntfy.enable { source = "${crowdsecHttpPlugin}/bin/notification-http"; mode = "0550"; user = "crowdsec"; group = "crowdsec"; }; # CrowdSec profiles.yaml: route every alert to the ntfy plugin. # This replaces the default "do nothing" profile. "crowdsec/profiles.yaml" = lib.mkIf cfg.ntfy.enable { text = '' name: default_ip_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Ip" decisions: - type: ban duration: 4h notifications: - ntfy_plugin on_success: break --- name: default_range_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Range" decisions: - type: ban duration: 4h notifications: - ntfy_plugin on_success: break ''; mode = "0440"; user = "crowdsec"; group = "crowdsec"; }; }; }; }; in { imports = [ crowdsecConfig ]; }