408 lines
17 KiB
Nix
Executable File
408 lines
17 KiB
Nix
Executable File
{
|
|
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 ];
|
|
}
|