From 8a736599fd4a0c415748c19fdc5d44c1608d8ada Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 1 May 2025 05:49:33 +0300 Subject: [PATCH] nix: initial nixos module implementation --- nix/module.nix | 377 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 nix/module.nix diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..50f6fb0 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,377 @@ +# 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} + ''; + }; + }; +}