{ config, lib, pkgs, namespace, ... }: let inherit (lib.${namespace}) mkOpt mkBoolOpt; name = "crowdsec"; cfg = config.${namespace}.services.${name}; ntfyServer = "https://ntfy.mjallen.dev"; ntfyTopic = "crowdsec"; # CrowdSec HTTP notification plugin config — written to # /etc/crowdsec/notifications/ntfy.yaml at runtime. Credentials are # injected via EnvironmentFile so the plugin can reference them with # {{env "NTFY_USER"}} / {{env "NTFY_PASSWORD"}} in the URL. ntfyPluginConfig = pkgs.writeText "crowdsec-ntfy.yaml" '' type: http name: ntfy_plugin log_level: info format: | {{range . -}} CrowdSec blocked: {{.Scenario}} Source IP: {{.Source.Value}} Country: {{.Source.Cn}} Decisions: {{.Decisions | len}} {{range .Decisions -}} Action: {{.Type}} for {{.Duration}} {{end}} {{- end}} url: ${ntfyServer}/${ntfyTopic} method: POST headers: Title: "CrowdSec: {{(index . 0).Scenario}}" Priority: "high" Tags: "rotating_light,shield" Authorization: "Basic {{b64enc (print (env "NTFY_USER") ":" (env "NTFY_PASSWORD"))}}" skip_tls_verify: false timeout: 10s ''; 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; openFirewall = 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"; }; }; 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.services.crowdsec.serviceConfig.DynamicUser = lib.mkForce false; systemd.services.crowdsec-firewall-bouncer.serviceConfig.DynamicUser = lib.mkForce false; systemd.services.crowdsec-firewall-bouncer-register.serviceConfig.DynamicUser = lib.mkForce false; # 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. systemd.services.crowdsec-firewall-bouncer.after = [ "crowdsec-firewall-bouncer-register.service" ]; # 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. systemd.services.crowdsec-firewall-bouncer-register.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 config # at /etc/crowdsec/config.yaml by extracting the store path from the # crowdsec service's ExecStart list at NixOS eval time. environment.etc."crowdsec/config.yaml" = let # ExecStart is [ " " "/crowdsec -c -info" ] 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 # --------------------------------------------------------------------------- # Drop the plugin config YAML into /etc/crowdsec/notifications/. # CrowdSec scans this directory on startup and registers any plugin # config files it finds. environment.etc."crowdsec/notifications/ntfy.yaml" = lib.mkIf cfg.ntfy.enable { source = ntfyPluginConfig; mode = "0440"; user = "crowdsec"; group = "crowdsec"; }; # CrowdSec profiles.yaml: route every alert to the ntfy plugin. # This replaces the default "do nothing" profile. environment.etc."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"; }; # Inject NTFY_USER and NTFY_PASSWORD into the crowdsec service so the # HTTP plugin template can reference them. The plugin config uses # {{env "NTFY_BASIC_AUTH"}} — a pre-encoded "user:pass" base64 string # for the Authorization: Basic header — computed in ExecStartPre. systemd.services.crowdsec.serviceConfig.EnvironmentFile = lib.mkIf cfg.ntfy.enable [ cfg.ntfy.envFile ]; }; }; in { imports = [ crowdsecConfig ]; }