{ 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; settings = { # The default api_url is derived from the LAPI's listen_uri, which is # "0.0.0.0:8181" — a valid bind address but not a connectable URL. # Override to the loopback address the bouncer should actually connect to. api_url = "http://127.0.0.1:${toString cfg.port}"; }; }; }; # During activation (which runs as root), check whether the machine credential in # client.yaml exists in the crowdsec SQLite DB. If not (e.g. after a DB wipe), # clear client.yaml so the subsequent crowdsec-setup ExecStartPre re-registers. # This runs before switch-to-configuration starts/restarts services, breaking the # boot-time cycle where the ExecStartPre fix can't apply until the service succeeds. system.activationScripts.crowdsec-check-machine-creds = let machineName = config.services.crowdsec.name; in { text = '' clientYaml="${cfg.configDir}/crowdsec/client.yaml" dbPath="/var/lib/crowdsec/state/crowdsec.db" if [ -f "$dbPath" ]; then if [ -s "$clientYaml" ]; then login=$(${pkgs.gnugrep}/bin/grep -oP '(?<=login: ).*' "$clientYaml" || true) if [ -n "$login" ]; then found=$(${pkgs.sqlite}/bin/sqlite3 "$dbPath" \ "SELECT COUNT(*) FROM machines WHERE machine_id='$login';" 2>/dev/null || echo "0") if [ "$found" = "0" ]; then echo "crowdsec activation: machine '$login' missing from DB — resetting credentials" ${pkgs.coreutils}/bin/rm -f "$clientYaml" # Also remove any stale entry under the configured machine name so # 'cscli machines add ${machineName} --auto' doesn't fail with "user already exist" ${pkgs.sqlite}/bin/sqlite3 "$dbPath" \ "DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true fi fi else # client.yaml absent/empty — ensure no stale name entry blocks re-registration ${pkgs.sqlite}/bin/sqlite3 "$dbPath" \ "DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true fi fi ''; deps = [ ]; }; # 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; # ProtectSystem=strict (set upstream) makes all paths read-only except # those in ReadWritePaths. The credentials file lives on the NAS mount # which is not listed by default, so cscli machines add fails with EROFS. ReadWritePaths = [ "${cfg.configDir}/crowdsec" ]; # If the machine credentials in client.yaml don't match any machine in the # SQLite DB (e.g. after a DB wipe while client.yaml persists), crowdsec # fatals on startup. Detect this mismatch before crowdsec-setup runs: # read the machine login from client.yaml, query the DB directly, and # delete client.yaml if the machine is absent so the next crowdsec-setup # invocation re-registers a fresh machine. # Use mkBefore so this runs before the upstream crowdsec-setup ExecStartPre # entries, giving crowdsec-setup a cleared client.yaml to re-register from. ExecStartPre = lib.mkBefore [ ( "+" + ( let machineName = config.services.crowdsec.name; in pkgs.writeShellScript "crowdsec-check-machine-creds" '' set -euo pipefail clientYaml="${cfg.configDir}/crowdsec/client.yaml" dbPath="/var/lib/crowdsec/state/crowdsec.db" sqlite="${pkgs.sqlite}/bin/sqlite3" rm="${pkgs.coreutils}/bin/rm" [ -f "$dbPath" ] || exit 0 # No DB yet; fresh install, nothing to fix if [ -s "$clientYaml" ]; then # Credentials file exists — verify the login it contains is in the DB login=$(${pkgs.gnugrep}/bin/grep -oP '(?<=login: ).*' "$clientYaml" || true) if [ -n "$login" ]; then found=$("$sqlite" "$dbPath" \ "SELECT COUNT(*) FROM machines WHERE machine_id='$login';" 2>/dev/null || echo "0") if [ "$found" = "0" ]; then echo "crowdsec: machine '$login' missing from DB — resetting credentials" "$rm" -f "$clientYaml" "$sqlite" "$dbPath" \ "DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true fi fi else # Credentials file absent — ensure no stale name row blocks machines add stale=$("$sqlite" "$dbPath" \ "SELECT COUNT(*) FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || echo "0") if [ "$stale" != "0" ]; then echo "crowdsec: client.yaml absent but '${machineName}' in DB — removing stale row" "$sqlite" "$dbPath" \ "DELETE FROM machines WHERE machine_id='${machineName}';" 2>/dev/null || true fi fi '' ) ) ]; } (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 ]; }