nixpkgs/nixos/modules/services/security/reaction.nix

308 lines
9.6 KiB
Nix

{
lib,
pkgs,
config,
...
}:
let
settingsFormat = pkgs.formats.yaml { };
cfg = config.services.reaction;
inherit (lib)
concatMapStringsSep
filterAttrs
getExe
mkDefault
mkEnableOption
mkIf
mkOption
mkPackageOption
mapAttrs
optional
optionals
optionalString
types
;
in
{
options.services.reaction = {
enable = mkEnableOption "enable reaction";
package = mkPackageOption pkgs "reaction" { };
settings = mkOption {
description = ''
Configuration for reaction. See the [wiki](https://framagit.org/ppom/reaction-wiki).
The settings are written as a YAML file.
Can be used in combination with `settingsFiles` option, both will be present in the configuration directory.
'';
default = { };
type = types.submodule {
freeformType = settingsFormat.type;
options = {
plugins = mkOption {
description = ''
Nixpkgs provides a `reaction-plugins` package set which includes both offical and community plugins for reaction.
To use the plugins in your module configuration, in `settings.plugins` you can use for e.g. `''${lib.getExe reaction-plugins.reaction-plugin-ipset}`
See https://reaction.ppom.me/plugins/ to configure plugins.
'';
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = {
enable = mkOption {
description = "enable reaction-plugin-${name}";
type = types.bool;
default = true;
};
path = mkOption {
description = "path to the plugin binary";
type = types.str;
default = "${cfg.package.plugins."reaction-plugin-${name}"}/bin/reaction-plugin-${name}";
defaultText = lib.literalExpression ''''${cfg.package.plugins."reaction-plugin-${name}"}/bin/reaction-plugin-${name}'';
};
check_root = mkOption {
description = "Whether reaction must check that the executable is owned by root";
type = types.bool;
default = true;
};
systemd = mkOption {
description = "Whether reaction must isolate the plugin using systemd's run0";
type = types.bool;
default = cfg.runAsRoot;
defaultText = "config.services.reaction.runAsRoot";
};
systemd_options = mkOption {
description = ''
A key-value map of systemd options.
Keys must be strings and values must be string arrays.
See `man systemd.directives` for all supported options, and particularly options in `man systemd.exec`
'';
type = types.attrsOf (types.listOf types.str);
default = { };
};
};
}
)
);
# Filter plugins which are disabled
apply =
self:
lib.pipe self [
(filterAttrs (name: p: p.enable))
(mapAttrs (name: p: removeAttrs p [ "enable" ]))
];
};
};
};
};
settingsFiles = mkOption {
description = ''
Configuration for reaction, see the [wiki](https://framagit.org/ppom/reaction-wiki).
reaction supports JSON, YAML and JSONnet. For those who prefer to take advantage of JSONnet rather than Nix.
Can be used in combination with `settings` option, both will be present in the configuration directory.
'';
default = [ ];
type = types.listOf types.path;
};
loglevel = mkOption {
description = ''
reaction's loglevel. One of DEBUG, INFO, WARN, ERROR.
'';
default = null;
type = types.nullOr (
types.enum [
"DEBUG"
"INFO"
"WARN"
"ERROR"
]
);
};
stopForFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Whether to stop reaction when reloading the firewall.
The presence of a reaction chain in the INPUT table may cause the firewall
reload to fail.
One can alternatively cherry-pick the right iptables commands to execute before and after the firewall
```nix
{
systemd.services.firewall.serviceConfig = {
ExecStopPre = [ "''${pkgs.iptables}/bin/iptables -w -D INPUT -p all -j reaction" ];
ExecStartPost = [ "''${pkgs.iptables}/bin/iptables -w -I INPUT -p all -j reaction" ];
};
}
```
'';
};
checkConfig = mkOption {
type = types.bool;
default = true;
description = "Check the syntax of the configuration files at build time";
};
runAsRoot = mkOption {
type = types.bool;
default = false;
description = ''
Whether to run reaction as root.
Defaults to false, where an unprivileged reaction user is created.
Be sure to give it sufficient permissions.
Example config permitting `iptables` and `journalctl` use
```nix
{
# allows reading journal logs of processess
users.users.reaction.extraGroups = [ "systemd-journal" ];
# allows modifying ip firewall rules
systemd.services.reaction.unitConfig.ConditionCapability = "CAP_NET_ADMIN";
systemd.services.reaction.serviceConfig = {
CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
AmbientCapabilities = [ "CAP_NET_ADMIN" ];
};
# optional, if more control over ssh logs is needed
services.openssh.settings.LogLevel = lib.mkDefault "VERBOSE";
}
```
```nix
# core ipset plugin requires these if running as non-root
systemd.services.reaction.serviceConfig = {
CapabilityBoundingSet = [
"CAP_NET_ADMIN"
"CAP_NET_RAW"
"CAP_DAC_READ_SEARCH" # for journalctl
];
AmbientCapabilities = [
"CAP_NET_ADMIN"
"CAP_NET_RAW"
"CAP_DAC_READ_SEARCH"
];
};
```
'';
};
};
config =
let
generatedSettings = settingsFormat.generate "reaction.yml" cfg.settings;
settingsDir = pkgs.runCommand "reaction-settings-dir" { } ''
mkdir -p $out
${concatMapStringsSep "\n" (file: ''
filename=$(basename "${file}")
ln -s "${file}" "$out/$filename"
'') cfg.settingsFiles}
ln -s ${generatedSettings} $out/reaction.yml
'';
in
mkIf cfg.enable {
assertions = [
{
assertion = cfg.settings != { } || (builtins.length cfg.settingsFiles) != 0;
message = "You must specify settings and/or settingsFile options";
}
];
users = mkIf (!cfg.runAsRoot) {
users.reaction = {
isSystemUser = true;
group = "reaction";
};
groups.reaction = { };
};
system.checks =
optional (cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform)
(
pkgs.runCommand "reaction-config-validation" { } ''
${getExe cfg.package} test-config -c ${settingsDir} >/dev/null
echo "reaction config ${settingsDir} is valid"
touch $out
''
);
systemd.services.reaction = {
description = "A daemon that scans program outputs for repeated patterns, and takes action.";
documentation = [ "https://reaction.ppom.me" ];
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
partOf = optionals cfg.stopForFirewall [ "firewall.service" ];
path = [ pkgs.iptables ];
serviceConfig = {
Type = "simple";
KillMode = "mixed"; # for plugins
User = if (!cfg.runAsRoot) then "reaction" else "root";
ExecStart = ''
${getExe cfg.package} start -c ${settingsDir}${
optionalString (cfg.loglevel != null) " -l ${cfg.loglevel}"
}
'';
NoNewPrivileges = true;
RuntimeDirectory = "reaction";
RuntimeDirectoryMode = "0750";
WorkingDirectory = "%S/reaction";
StateDirectory = "reaction";
StateDirectoryMode = "0750";
LogsDirectory = "reaction";
LogsDirectoryMode = "0750";
UMask = 0077;
RemoveIPC = true;
PrivateTmp = true;
ProtectHome = true;
ProtectClock = true;
PrivateDevices = true;
ProtectHostname = true;
ProtectSystem = "strict";
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
};
};
# pre-configure official plugins
services.reaction.settings.plugins = {
ipset = {
enable = mkDefault true;
systemd_options = {
CapabilityBoundingSet = [
"~CAP_NET_ADMIN"
"~CAP_PERFMON"
];
};
};
virtual.enable = mkDefault true;
};
environment.systemPackages = [ cfg.package ];
};
meta.maintainers =
with lib.maintainers;
[
ppom
]
++ lib.teams.ngi.members;
}