# NixOS Module for Eris Tarpit self: { config, lib, pkgs, ... }: let inherit (lib.modules) mkIf; inherit (lib.options) mkOption mkEnableOption literalExpression; inherit (lib.types) package str port int listOf enum bool attrsOf path; inherit (lib.lists) optionals; cfg = config.services.eris; # Generate the config.json content erisConfigFile = pkgs.writeText "eris-config.json" (builtins.toJSON { listen_addr = cfg.listenAddress; metrics_port = cfg.metricsPort; backend_addr = cfg.backendAddress; min_delay = cfg.minDelay; max_delay = cfg.maxDelay; max_tarpit_time = cfg.maxTarpitTime; block_threshold = cfg.blockThreshold; trap_patterns = cfg.trapPatterns; whitelist_networks = cfg.whitelistNetworks; markov_corpora_dir = "${cfg.dataDir}/corpora"; lua_scripts_dir = "${cfg.dataDir}/scripts"; data_dir = cfg.dataDir; config_dir = "${cfg.stateDir}/conf"; cache_dir = cfg.cacheDir; }); in { ###### interface options = { services.eris = { enable = mkEnableOption "Eris Tarpit Service"; package = mkOption { type = package; default = self.packages.${pkgs.system}.eris; defaultText = literalExpression "pkgs.eris"; description = "The Eris package to use."; }; listenAddress = mkOption { type = str; default = "0.0.0.0:8888"; description = "The IP address and port for the main tarpit service to listen on."; example = "127.0.0.1:9999"; }; metricsPort = mkOption { type = port; default = 9100; description = "The port for the Prometheus metrics endpoint."; }; backendAddress = mkOption { type = str; default = "127.0.0.1:80"; description = "The address of the backend service to proxy legitimate requests to."; example = "10.0.0.5:8080"; }; minDelay = mkOption { type = int; default = 1000; description = "Minimum delay (in milliseconds) between sending characters in tarpit mode."; }; maxDelay = mkOption { type = int; default = 15000; description = "Maximum delay (in milliseconds) between sending characters in tarpit mode."; }; maxTarpitTime = mkOption { type = int; default = 600; description = "Maximum time (in seconds) a single connection can be held in the tarpit."; }; blockThreshold = mkOption { type = int; default = 3; description = "Number of hits from an IP before it gets permanently blocked."; }; trapPatterns = mkOption { type = listOf str; default = [ "/vendor/phpunit" "eval-stdin.php" "/wp-admin" "/wp-login.php" "/xmlrpc.php" "/phpMyAdmin" "/solr/" "/.env" "/config" "/api/" "/actuator/" ]; description = "List of URL path substrings that trigger the tarpit."; example = literalExpression '' [ "/.git/config", "/admin" ] ''; }; whitelistNetworks = mkOption { type = listOf str; default = [ "192.168.0.0/16" "10.0.0.0/8" "172.16.0.0/12" "127.0.0.0/8" ]; example = literalExpression "[ \"192.168.1.0/24\" ]"; description = "List of CIDR networks whose IPs should never be tarpitted."; }; logLevel = mkOption { type = enum ["error" "warn" "info" "debug" "trace"]; default = "info"; description = "Logging level for the Eris service."; }; user = mkOption { type = str; default = "eris"; description = "User account under which Eris runs."; }; group = mkOption { type = str; default = "eris"; description = "Group under which Eris runs."; }; stateDir = mkOption { type = path; default = "/var/lib/eris"; description = '' Directory to store persistent state like blocked IPs. Subdirectories `conf` and `data` will be used. ''; }; cacheDir = mkOption { type = path; default = "/var/cache/eris"; description = "Directory to store cache data like hit counters."; }; dataDir = mkOption { # This derives from stateDir by default to keep persistent data together type = path; default = "${cfg.stateDir}/data"; description = "Directory containing `corpora` and `scripts` subdirectories."; }; corpora = mkOption { type = attrsOf path; default = {}; description = '' An attribute set where keys are corpus filenames (e.g., "generic", "wordpress") and values are paths to the corpus files. These will be placed in {path}`''${cfg.dataDir}/corpora`. ''; example = literalExpression '' { generic = ./my-generic-corpus.txt; wordpress = ./wp-corpus.txt; } ''; }; luaScripts = mkOption { type = attrsOf path; default = {}; description = '' An attribute set where keys are Lua script filenames (e.g., "`my_script.lua`") and values are paths to the script files. These will be placed in {path}`''${cfg.dataDir}/scripts`. ''; example = lib.literalExpression '' { "custom_tokens.lua" = ./custom_tokens.lua; } ''; }; # Firewall integration options nftablesIntegration = mkOption { type = bool; default = true; description = '' Whether to enable nftables, and create the `eris_blacklist` and potentially a basic drop rule. :::{.note} This does *not* grant the Eris service permission to modify the set using the `nft` command. Such permissions must be configured separately (e.g., via Polkit or sudo rules for the service user). Set this to false if you manage nftables entirely separately. ::: ''; }; nftablesDropRule = mkOption { type = bool; default = true; description = '' If {option}`nftablesIntegration` is `true`, also add a rule to the 'input' chain to drop traffic matching the 'eris_blacklist' set. Set to `false` if you want to manage the drop rule placement yourself. ''; }; }; }; config = mkIf cfg.enable { services.nftables = { enable = mkIf cfg.nftablesIntegration cfg.nftablesIntegration; ruleset = mkIf cfg.nftablesIntegration '' table inet filter { set eris_blacklist { type ipv4_addr; flags interval; comment "Managed by Eris NixOS module"; } chain input { ${lib.optionalString cfg.nftablesDropRule '' ip saddr @eris_blacklist counter drop comment "Drop traffic from Eris blacklist"; ''} } } ''; }; users.users = mkIf (cfg.user == "eris") { eris = { isSystemUser = true; group = cfg.group; home = cfg.stateDir; }; }; users.groups = mkIf (cfg.group == "eris") { eris = {}; }; systemd.services.eris = { description = "Eris Tarpit Service"; wantedBy = ["multi-user.target"]; after = ["network.target"] ++ optionals cfg.nftablesIntegration "nftables.service"; requires = optionals cfg.nftablesIntegration "nftables.service"; serviceConfig = { # User and Group configuration User = cfg.user; Group = cfg.group; # Process management ExecStart = '' ${cfg.package}/bin/eris \ --config-file ${erisConfigFile} \ --log-level ${cfg.logLevel} ''; Restart = "on-failure"; RestartSec = "5s"; WorkingDirectory = cfg.stateDir; # state/data is accessed via config paths # Resource limits LimitNOFILE = 65536; # can handle many connections LimitNPROC = 1024; # limit number of processes/threads # Sandboxing and Security Hardening # Deny privilege escalation NoNewPrivileges = true; # Filesystem access control ProtectSystem = "strict"; # Mount /usr, /boot, /etc read-only ProtectHome = true; # Make /home, /root inaccessible # Explicitly allow writes to state/cache/data dirs ReadWritePaths = [ cfg.stateDir cfg.cacheDir cfg.dataDir ]; # Allow reads from config file path ReadOnlyPaths = [erisConfigFile]; # Explicitly deny access to sensitive paths InaccessiblePaths = [ "/boot" "/root" "/home" "/srv" # Add others as needed ]; PrivateTmp = true; # Use private /tmp and /var/tmp PrivateDevices = true; # Restrict device access (/dev) ProtectHostname = true; # Prevent changing hostname ProtectClock = true; # Prevent changing system clock ProtectKernelTunables = true; # Prevent changing kernel variables (/proc, /sys) ProtectKernelModules = true; # Prevent loading/unloading kernel modules ProtectKernelLogs = true; # Prevent reading kernel logs via dmesg ProtectControlGroups = true; # Make Control Group hierarchies read-only # Network access control RestrictAddressFamilies = ["AF_INET" "AF_INET6"]; # Allow only standard IP protocols CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; # Allow binding to ports < 1024 if needed AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; # System call filtering (adjust based on Eris's needs) # Start with a reasonable baseline for network services SystemCallFilter = ["@system-service" "@network-io" "@file-system"]; # TODO: Consider adding more specific filters or removing groups if issues arise # e.g., SystemCallArchitectures=native. This probably will not be enough # Other hardening RestrictNamespaces = true; # Prevent creation of new namespaces LockPersonality = true; # Lock down legacy personality settings MemoryDenyWriteExecute = true; # Prevent RWX memory mappings RemoveIPC = true; # Remove access to System V IPC objects RestrictRealtime = true; # Prevent use of realtime scheduling policies RestrictSUIDSGID = true; # Ignore SUID/SGID bits on execution # Directories managed by systemd StateDirectory = lib.baseNameOf cfg.stateDir; # e.g., "eris" CacheDirectory = lib.baseNameOf cfg.cacheDir; # e.g., "eris" StateDirectoryMode = "0750"; CacheDirectoryMode = "0750"; # Logging StandardOutput = "journal"; StandardError = "journal"; }; # Pre-start script to ensure directories and copy declarative files preStart = let corporaDir = "${cfg.dataDir}/corpora"; scriptsDir = "${cfg.dataDir}/scripts"; chownCmd = "${pkgs.coreutils}/bin/chown ${cfg.user}:${cfg.group}"; # Create commands to copy corpora files copyCorporaCmds = lib.mapAttrsToList (name: path: '' cp -vf ${path} ${corporaDir}/${name} ${chownCmd} ${corporaDir}/${name} '') cfg.corpora; # Create commands to copy Lua script files copyLuaScriptCmds = lib.mapAttrsToList (name: path: '' cp -vf ${path} ${scriptsDir}/${name} ${chownCmd} ${scriptsDir}/${name} '') cfg.luaScripts; in '' # Systemd creates StateDirectory and CacheDirectory, but we need subdirs mkdir -p ${cfg.stateDir}/conf ${cfg.dataDir} ${corporaDir} ${scriptsDir} # Ensure ownership is correct for all relevant dirs managed by systemd or created here ${chownCmd} /var/lib/${lib.baseNameOf cfg.stateDir} \ /var/cache/${lib.baseNameOf cfg.cacheDir} \ ${cfg.stateDir}/conf \ ${cfg.dataDir} \ ${corporaDir} \ ${scriptsDir} # Copy declarative files ${lib.toString copyCorporaCmds} ${lib.toString copyLuaScriptCmds} ''; }; }; }