Eris/nix/module.nix

365 lines
11 KiB
Nix

# NixOS Module for Eris Tarpit
self: {
config,
lib,
pkgs,
...
}: let
inherit (builtins) toJSON toString;
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" (toJSON {
listen_addr = cfg.listenAddress;
metrics_addr = cfg.metricsAddress;
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.stdenv.system}.eris;
defaultText = literalExpression "self.packages.\${pkgs.stdenv.system}.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";
};
metricsAddress = mkOption {
type = port;
default = "0.0.0.0:9100";
example = "127.0.0.1:9100";
description = "The IP address and 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 ms) between sending characters in tarpit mode.";
};
maxDelay = mkOption {
type = int;
default = 15000;
description = "Maximum delay (in ms) 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 = "/var/lib/eris/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 = 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 {
networking.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 = ''
${lib.getExe cfg.package} \
--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;
# FIXME: this breaks everything.
# 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 = [
# "/var/lib/eris"
# "${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"
# ];
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"];
CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];
AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
# Start with a reasonable baseline for network services
SystemCallFilter = ["@system-service" "@network-io" "@file-system"];
# 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 = "eris";
CacheDirectory = "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";
# Create commands to copy corpora files
copyCorporaCmds =
lib.mapAttrsToList (name: path: ''
cp -vf ${path} ${corporaDir}/${name}
'')
cfg.corpora;
# Create commands to copy Lua script files
copyLuaScriptCmds =
lib.mapAttrsToList (name: path: ''
cp -vf ${path} ${scriptsDir}/${name}
'')
cfg.luaScripts;
in ''
# Copy declarative files
${lib.optionalString (cfg.corpora != {}) (toString copyCorporaCmds)}
${lib.optionalString (cfg.luaScripts != {}) (toString copyLuaScriptCmds)}
'';
};
};
}