From 3bbcd489b9f46f951df5774291ebd346842a6d6d Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Thu, 12 Feb 2026 13:05:55 +0530 Subject: [PATCH 1/6] reaction: 2.2.1 -> 2.3.0 Co-authored-by: ppom <38916722+ppom0@users.noreply.github.com> Signed-off-by: phanirithvij --- pkgs/by-name/re/reaction/package.nix | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pkgs/by-name/re/reaction/package.nix b/pkgs/by-name/re/reaction/package.nix index e5780fb044cf..e83a254f4df4 100644 --- a/pkgs/by-name/re/reaction/package.nix +++ b/pkgs/by-name/re/reaction/package.nix @@ -1,26 +1,28 @@ { lib, stdenv, - nixosTests, rustPlatform, fetchFromGitLab, + versionCheckHook, installShellFiles, nix-update-script, + + nixosTests, }: rustPlatform.buildRustPackage (finalAttrs: { pname = "reaction"; - version = "2.2.1"; + version = "2.3.0"; src = fetchFromGitLab { domain = "framagit.org"; owner = "ppom"; repo = "reaction"; tag = "v${finalAttrs.version}"; - hash = "sha256-81i0bkrf86adQWxeZgIoZp/zQQbRJwPqQqZci0ANRFw="; + hash = "sha256-OvNJsR9W5MlicqUpr1aOLJ7pI7H7guq1vAlC/hh1Q2o="; }; - cargoHash = "sha256-Bf9XmlY0IMPY4Convftd0Hv8mQbYoiE8WrkkAeaS6Z8="; + cargoHash = "sha256-BOFZlVBKf6fjW1L1J8u7Vf+fzNJHlEtQI6YafDjlZ4U="; nativeBuildInputs = [ installShellFiles ]; @@ -40,13 +42,13 @@ rustPlatform.buildRustPackage (finalAttrs: { # flaky and fails in hydra "--skip=concepts::config::tests::merge_config_distinct_concurrency" ]; + cargoTestFlags = [ # Skip integration tests for the same reason "--lib" ]; postInstall = '' - installBin $releaseDir/ip46tables $releaseDir/nft46 installManPage $releaseDir/reaction*.1 installShellCompletion --cmd reaction \ --bash $releaseDir/reaction.bash \ @@ -62,15 +64,16 @@ rustPlatform.buildRustPackage (finalAttrs: { passthru.updateScript = nix-update-script { }; passthru.tests = { inherit (nixosTests) reaction reaction-firewall; }; + passthru.plugins = reaction-plugins; meta = { + changelog = "https://framagit.org/ppom/reaction/-/releases/v${finalAttrs.version}"; description = "Scan logs and take action: an alternative to fail2ban"; homepage = "https://framagit.org/ppom/reaction"; - changelog = "https://framagit.org/ppom/reaction/-/releases/v${finalAttrs.version}"; license = lib.licenses.agpl3Plus; mainProgram = "reaction"; maintainers = with lib.maintainers; [ ppom ]; - teams = [ lib.teams.ngi ]; platforms = lib.platforms.unix; + teams = [ lib.teams.ngi ]; }; }) From 7b46dedf1d46904f48fcf2baebd0ea90fd4b4272 Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Tue, 17 Feb 2026 23:07:13 +0530 Subject: [PATCH 2/6] reaction: fix build on macos Signed-off-by: phanirithvij --- .../re/reaction/add-support-for-macos.patch | 24 +++++++++++++++++++ pkgs/by-name/re/reaction/package.nix | 5 ++++ 2 files changed, 29 insertions(+) create mode 100644 pkgs/by-name/re/reaction/add-support-for-macos.patch diff --git a/pkgs/by-name/re/reaction/add-support-for-macos.patch b/pkgs/by-name/re/reaction/add-support-for-macos.patch new file mode 100644 index 000000000000..f68e0d625873 --- /dev/null +++ b/pkgs/by-name/re/reaction/add-support-for-macos.patch @@ -0,0 +1,24 @@ +From dc51d7d432eeb408551ae84c6b08d172efbf4649 Mon Sep 17 00:00:00 2001 +From: ppom +Date: Tue, 17 Feb 2026 12:00:00 +0100 +Subject: [PATCH] Add support for macOS + +--- + src/concepts/plugin.rs | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/src/concepts/plugin.rs b/src/concepts/plugin.rs +index c5bc330..8c3c142 100644 +--- a/src/concepts/plugin.rs ++++ b/src/concepts/plugin.rs +@@ -1,5 +1,7 @@ + use std::{collections::BTreeMap, io::Error, path, process::Stdio}; + ++#[cfg(target_os = "macos")] ++use std::os::darwin::fs::MetadataExt; + #[cfg(target_os = "freebsd")] + use std::os::freebsd::fs::MetadataExt; + #[cfg(target_os = "illumos")] +-- +GitLab + diff --git a/pkgs/by-name/re/reaction/package.nix b/pkgs/by-name/re/reaction/package.nix index e83a254f4df4..de94c20e9d9b 100644 --- a/pkgs/by-name/re/reaction/package.nix +++ b/pkgs/by-name/re/reaction/package.nix @@ -22,6 +22,11 @@ rustPlatform.buildRustPackage (finalAttrs: { hash = "sha256-OvNJsR9W5MlicqUpr1aOLJ7pI7H7guq1vAlC/hh1Q2o="; }; + patches = [ + # remove patch in next tagged version + ./add-support-for-macos.patch + ]; + cargoHash = "sha256-BOFZlVBKf6fjW1L1J8u7Vf+fzNJHlEtQI6YafDjlZ4U="; nativeBuildInputs = [ installShellFiles ]; From 0f1dc709de61d2ea539d687797e795ee8239fea5 Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Mon, 23 Feb 2026 18:12:42 +0530 Subject: [PATCH 3/6] reaction-plugins: init Co-authored-by: eljamm Signed-off-by: phanirithvij --- pkgs/by-name/re/reaction/package.nix | 9 +++-- pkgs/by-name/re/reaction/plugins/default.nix | 37 +++++++++++++++++++ .../plugins/reaction-plugin-ipset.nix | 14 +++++++ .../plugins/reaction-plugin-virtual.nix | 5 +++ pkgs/top-level/all-packages.nix | 2 + 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 pkgs/by-name/re/reaction/plugins/default.nix create mode 100644 pkgs/by-name/re/reaction/plugins/reaction-plugin-ipset.nix create mode 100644 pkgs/by-name/re/reaction/plugins/reaction-plugin-virtual.nix diff --git a/pkgs/by-name/re/reaction/package.nix b/pkgs/by-name/re/reaction/package.nix index de94c20e9d9b..4edea0b94ab5 100644 --- a/pkgs/by-name/re/reaction/package.nix +++ b/pkgs/by-name/re/reaction/package.nix @@ -1,6 +1,7 @@ { lib, stdenv, + callPackage, rustPlatform, fetchFromGitLab, @@ -67,9 +68,11 @@ rustPlatform.buildRustPackage (finalAttrs: { versionCheckProgramArg = "--version"; doInstallCheck = true; - passthru.updateScript = nix-update-script { }; - passthru.tests = { inherit (nixosTests) reaction reaction-firewall; }; - passthru.plugins = reaction-plugins; + passthru = { + inherit (callPackage ./plugins { }) mkReactionPlugin plugins; + updateScript = nix-update-script { }; + tests = { inherit (nixosTests) reaction reaction-firewall; }; + }; meta = { changelog = "https://framagit.org/ppom/reaction/-/releases/v${finalAttrs.version}"; diff --git a/pkgs/by-name/re/reaction/plugins/default.nix b/pkgs/by-name/re/reaction/plugins/default.nix new file mode 100644 index 000000000000..c6a7fa124699 --- /dev/null +++ b/pkgs/by-name/re/reaction/plugins/default.nix @@ -0,0 +1,37 @@ +{ + lib, + rustPlatform, + callPackage, + reaction, +}: +{ + # NOTE: plugins are binaries, so no special integration with the derivation is required + # mkReactionPlugin is meant for only official plugins living in the reaction source tree + mkReactionPlugin = + name: extra: + rustPlatform.buildRustPackage ( + { + pname = name; + inherit (reaction) version src cargoHash; + buildAndTestSubdir = "plugins/${name}"; + + meta = { + changelog = "https://framagit.org/ppom/reaction/-/releases/v${reaction.version}"; + description = "Official reaction plugin ${name}"; + homepage = "https://framagit.org/ppom/reaction"; + license = lib.licenses.agpl3Plus; + mainProgram = name; + maintainers = with lib.maintainers; [ ppom ]; + platforms = lib.platforms.unix; + teams = [ lib.teams.ngi ]; + }; + } + // extra + ); + + # capture all plugins except default.nix (this file) + plugins = lib.removeAttrs (lib.packagesFromDirectoryRecursive { + inherit callPackage; + directory = ./.; + }) [ "default" ]; +} diff --git a/pkgs/by-name/re/reaction/plugins/reaction-plugin-ipset.nix b/pkgs/by-name/re/reaction/plugins/reaction-plugin-ipset.nix new file mode 100644 index 000000000000..23ae70ee4f57 --- /dev/null +++ b/pkgs/by-name/re/reaction/plugins/reaction-plugin-ipset.nix @@ -0,0 +1,14 @@ +{ + ipset, + pkg-config, + rustPlatform, + reaction, + ... +}: +reaction.mkReactionPlugin "reaction-plugin-ipset" { + buildInputs = [ ipset ]; + nativeBuildInputs = [ + rustPlatform.bindgenHook + pkg-config + ]; +} diff --git a/pkgs/by-name/re/reaction/plugins/reaction-plugin-virtual.nix b/pkgs/by-name/re/reaction/plugins/reaction-plugin-virtual.nix new file mode 100644 index 000000000000..049a96613285 --- /dev/null +++ b/pkgs/by-name/re/reaction/plugins/reaction-plugin-virtual.nix @@ -0,0 +1,5 @@ +{ + reaction, + ... +}: +reaction.mkReactionPlugin "reaction-plugin-virtual" { } diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 4a786b2e13f0..4f4531fbf6a6 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -3878,6 +3878,8 @@ with pkgs; dprint-plugins = recurseIntoAttrs (callPackage ../by-name/dp/dprint/plugins { }); + reaction-plugins = reaction.passthru.plugins; + elm2nix = haskell.lib.compose.justStaticExecutables haskellPackages.elm2nix; elmPackages = recurseIntoAttrs (callPackage ../development/compilers/elm { }); From 43580e4331377be7e6305b0cd70a027b0576f192 Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Fri, 13 Feb 2026 15:31:37 +0530 Subject: [PATCH 4/6] nixos/reaction: document and include plugins Co-authored-by: eljamm Co-authored-by: ppom <38916722+ppom0@users.noreply.github.com> Signed-off-by: phanirithvij --- nixos/modules/services/security/reaction.nix | 125 +++++++++++++++++-- nixos/tests/reaction-firewall.nix | 9 ++ nixos/tests/reaction.nix | 1 + 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/nixos/modules/services/security/reaction.nix b/nixos/modules/services/security/reaction.nix index 63e7eb586f60..039e507ae57f 100644 --- a/nixos/modules/services/security/reaction.nix +++ b/nixos/modules/services/security/reaction.nix @@ -7,10 +7,21 @@ let settingsFormat = pkgs.formats.yaml { }; + cfg = config.services.reaction; + inherit (lib) - mkOption + concatMapStringsSep + filterAttrs + getExe + mkDefault mkEnableOption + mkIf + mkOption mkPackageOption + mapAttrs + optional + optionals + optionalString types ; in @@ -30,7 +41,65 @@ in default = { }; type = types.submodule { freeformType = settingsFormat.type; - options = { }; + 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" ])) + ]; + }; + }; }; }; @@ -113,25 +182,39 @@ in 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 - cfg = config.services.reaction; - generatedSettings = settingsFormat.generate "reaction.yml" cfg.settings; settingsDir = pkgs.runCommand "reaction-settings-dir" { } '' mkdir -p $out - ${lib.concatMapStringsSep "\n" (file: '' + ${concatMapStringsSep "\n" (file: '' filename=$(basename "${file}") ln -s "${file}" "$out/$filename" '') cfg.settingsFiles} ln -s ${generatedSettings} $out/reaction.yml ''; in - lib.mkIf cfg.enable { + mkIf cfg.enable { assertions = [ { assertion = cfg.settings != { } || (builtins.length cfg.settingsFiles) != 0; @@ -139,7 +222,7 @@ in } ]; - users = lib.mkIf (!cfg.runAsRoot) { + users = mkIf (!cfg.runAsRoot) { users.reaction = { isSystemUser = true; group = "reaction"; @@ -148,27 +231,29 @@ in }; system.checks = - lib.optional (cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform) + optional (cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform) ( pkgs.runCommand "reaction-config-validation" { } '' - ${lib.getExe cfg.package} test-config -c ${settingsDir} >/dev/null + ${getExe cfg.package} test-config -c ${settingsDir} >/dev/null echo "reaction config ${settingsDir} is valid" touch $out '' ); systemd.services.reaction = { - description = "Scan logs and take action"; + 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 = lib.optionals cfg.stopForFirewall [ "firewall.service" ]; + 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 = '' - ${lib.getExe cfg.package} start -c ${settingsDir}${ - lib.optionalString (cfg.loglevel != null) " -l ${cfg.loglevel}" + ${getExe cfg.package} start -c ${settingsDir}${ + optionalString (cfg.loglevel != null) " -l ${cfg.loglevel}" } ''; @@ -197,6 +282,20 @@ in }; }; + # 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 ]; }; diff --git a/nixos/tests/reaction-firewall.nix b/nixos/tests/reaction-firewall.nix index 49d4d276da02..9286c036a025 100644 --- a/nixos/tests/reaction-firewall.nix +++ b/nixos/tests/reaction-firewall.nix @@ -17,6 +17,12 @@ # "${pkgs.reaction}/share/examples/example.yml" # can't specify both because conflicts ]; runAsRoot = true; + settings = { + # In the qemu vm `run0 ls` as root prints nothing, so we can't use it + # see https://reaction.ppom.me/reference.html#systemd + plugins.ipset.systemd = false; + plugins.virtual.systemd = false; + }; }; networking.firewall.enable = true; }; @@ -68,6 +74,9 @@ # - nix run .#nixosTests.reaction-firewall.driverInteractive -L # - run_tests() interactive.sshBackdoor.enable = true; # ssh -o User=root vsock%3 + interactive.nodes.machine = _: { + virtualisation.graphics = false; + }; meta.maintainers = with lib.maintainers; diff --git a/nixos/tests/reaction.nix b/nixos/tests/reaction.nix index 4cef6ead87a9..ff20f687a7e1 100644 --- a/nixos/tests/reaction.nix +++ b/nixos/tests/reaction.nix @@ -92,6 +92,7 @@ { # not needed, only for manual interactive debugging virtualisation.memorySize = 4096; + virtualisation.graphics = false; environment.systemPackages = with pkgs; [ btop sysz From 94cf274c92a5ca71336b90f4a6f4f407884cff18 Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Fri, 20 Feb 2026 10:54:16 +0530 Subject: [PATCH 5/6] nixos/reaction: refactor tests Signed-off-by: phanirithvij --- nixos/tests/all-tests.nix | 6 ++++-- nixos/tests/{reaction.nix => reaction/basic.nix} | 8 +++++++- nixos/tests/reaction/default.nix | 5 +++++ .../{reaction-firewall.nix => reaction/firewall.nix} | 0 pkgs/by-name/re/reaction/package.nix | 2 +- 5 files changed, 17 insertions(+), 4 deletions(-) rename nixos/tests/{reaction.nix => reaction/basic.nix} (89%) create mode 100644 nixos/tests/reaction/default.nix rename nixos/tests/{reaction-firewall.nix => reaction/firewall.nix} (100%) diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 0ad508dd87ec..6261b2f02cba 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1378,8 +1378,10 @@ in rasdaemon = runTest ./rasdaemon.nix; rathole = runTest ./rathole.nix; rauc = runTest ./rauc.nix; - reaction = runTest ./reaction.nix; - reaction-firewall = runTest ./reaction-firewall.nix; + reaction = import ./reaction { + inherit (pkgs) lib; + inherit runTest; + }; readarr = runTest ./readarr.nix; readeck = runTest ./readeck.nix; realm = runTest ./realm.nix; diff --git a/nixos/tests/reaction.nix b/nixos/tests/reaction/basic.nix similarity index 89% rename from nixos/tests/reaction.nix rename to nixos/tests/reaction/basic.nix index ff20f687a7e1..1001eb80ca95 100644 --- a/nixos/tests/reaction.nix +++ b/nixos/tests/reaction/basic.nix @@ -10,8 +10,14 @@ services.reaction = { enable = true; stopForFirewall = false; - # example.jsonnet/example.yml can be copied and modified from ${pkgs.reaction}/share/examples + # example.jsonnet or example.yml can be copied and modified from ${pkgs.reaction}/share/examples settingsFiles = [ "${pkgs.reaction}/share/examples/example.jsonnet" ]; + settings = { + # In the qemu vm `run0 ls` as root prints nothing, so we can't use it + # see https://reaction.ppom.me/reference.html#systemd + plugins.ipset.systemd = false; + plugins.virtual.systemd = false; + }; runAsRoot = false; }; services.openssh.enable = true; diff --git a/nixos/tests/reaction/default.nix b/nixos/tests/reaction/default.nix new file mode 100644 index 000000000000..e4667e9e8b1d --- /dev/null +++ b/nixos/tests/reaction/default.nix @@ -0,0 +1,5 @@ +{ lib, runTest }: +lib.recurseIntoAttrs { + basic = runTest ./basic.nix; + firewall = runTest ./firewall.nix; +} diff --git a/nixos/tests/reaction-firewall.nix b/nixos/tests/reaction/firewall.nix similarity index 100% rename from nixos/tests/reaction-firewall.nix rename to nixos/tests/reaction/firewall.nix diff --git a/pkgs/by-name/re/reaction/package.nix b/pkgs/by-name/re/reaction/package.nix index 4edea0b94ab5..b8a693d4349a 100644 --- a/pkgs/by-name/re/reaction/package.nix +++ b/pkgs/by-name/re/reaction/package.nix @@ -71,7 +71,7 @@ rustPlatform.buildRustPackage (finalAttrs: { passthru = { inherit (callPackage ./plugins { }) mkReactionPlugin plugins; updateScript = nix-update-script { }; - tests = { inherit (nixosTests) reaction reaction-firewall; }; + tests = nixosTests.reaction; }; meta = { From 4d4b662ab1a3f51c85b9a249d9b7d6dcb3045d46 Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Fri, 20 Feb 2026 11:16:44 +0530 Subject: [PATCH 6/6] nixos/reaction: test core plugins Co-authored-by: ppom <38916722+ppom0@users.noreply.github.com> Signed-off-by: phanirithvij --- nixos/tests/reaction/basic.nix | 6 -- nixos/tests/reaction/default.nix | 1 + nixos/tests/reaction/plugins.nix | 125 ++++++++++++++++++++++++++++++ nixos/tests/reaction/settings.nix | 74 ++++++++++++++++++ 4 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 nixos/tests/reaction/plugins.nix create mode 100644 nixos/tests/reaction/settings.nix diff --git a/nixos/tests/reaction/basic.nix b/nixos/tests/reaction/basic.nix index 1001eb80ca95..2b454723c8b2 100644 --- a/nixos/tests/reaction/basic.nix +++ b/nixos/tests/reaction/basic.nix @@ -12,12 +12,6 @@ stopForFirewall = false; # example.jsonnet or example.yml can be copied and modified from ${pkgs.reaction}/share/examples settingsFiles = [ "${pkgs.reaction}/share/examples/example.jsonnet" ]; - settings = { - # In the qemu vm `run0 ls` as root prints nothing, so we can't use it - # see https://reaction.ppom.me/reference.html#systemd - plugins.ipset.systemd = false; - plugins.virtual.systemd = false; - }; runAsRoot = false; }; services.openssh.enable = true; diff --git a/nixos/tests/reaction/default.nix b/nixos/tests/reaction/default.nix index e4667e9e8b1d..38328b56c5ca 100644 --- a/nixos/tests/reaction/default.nix +++ b/nixos/tests/reaction/default.nix @@ -2,4 +2,5 @@ lib.recurseIntoAttrs { basic = runTest ./basic.nix; firewall = runTest ./firewall.nix; + plugins = runTest ./plugins.nix; } diff --git a/nixos/tests/reaction/plugins.nix b/nixos/tests/reaction/plugins.nix new file mode 100644 index 000000000000..68bec4408469 --- /dev/null +++ b/nixos/tests/reaction/plugins.nix @@ -0,0 +1,125 @@ +{ + lib, + pkgs, + ... +}: +{ + name = "reaction-core-plugins"; + + nodes.server = args: { + services.reaction = { + enable = true; + stopForFirewall = false; + runAsRoot = false; + settings = import ./settings.nix args; + /* + # NOTE: When runAsRoot is true, disable run0 + settings = { + # In the qemu vm `run0 ls` as root prints nothing, so we can't use it + # see https://reaction.ppom.me/reference.html#systemd + plugins.ipset.systemd = false; + plugins.virtual.systemd = false; + }; + */ + }; + /* + NOTE: + - if reaction is run as non-root, the plugins need these capabilities, remove these if runAsRoot is true + - CAP_DAC_READ_SEARCH is for journalctl for accessing ssh logs + - useful tools: capable (from package bcc), captree, getpcaps (from libpcap) + */ + systemd.services.reaction.serviceConfig = { + CapabilityBoundingSet = [ + "CAP_NET_ADMIN" + "CAP_NET_RAW" + "CAP_DAC_READ_SEARCH" + ]; + AmbientCapabilities = [ + "CAP_NET_ADMIN" + "CAP_NET_RAW" + "CAP_DAC_READ_SEARCH" + ]; + }; + services.openssh.enable = true; + + users.users.nixos.isNormalUser = true; # neeeded to establish a ssh connection, by default root login is succeeding without any password + }; + + nodes.client = _: { + environment.systemPackages = [ + pkgs.sshpass + pkgs.libressl.nc + ]; + }; + + testScript = + { nodes, ... }: # py + '' + start_all() + + # Wait for everything to be ready. + server.wait_for_unit("multi-user.target") + server.wait_for_unit("reaction") + server.wait_for_unit("sshd") + + client_addr = "${(lib.head nodes.client.networking.interfaces.eth1.ipv4.addresses).address}" + server_addr = "${(lib.head nodes.server.networking.interfaces.eth1.ipv4.addresses).address}" + + # Verify there is not ban and the port is reachable from the client. + server.succeed(f"reaction show | grep -q {client_addr} || test $? -eq 1") + client.succeed(f"nc -w3 -z {server_addr} 22") + + # Cause authentication failure log entries. + for _ in range(2): + client.fail(f""" + sshpass -p 'wrongpassword' \ + ssh -o StrictHostKeyChecking=no \ + -o User=nixos \ + -o ServerAliveInterval=1 \ + -o ServerAliveCountMax=2 \ + {server_addr} + """) + + # Verify there is a ban and the port is unreachable from the client. + server.sleep(2) + output = server.succeed("reaction show") + print(output) + assert client_addr in output, f"client did not get banned, {client_addr}" + + client.fail(f"nc -w3 -z {server_addr} 22") + + # Check that unbanning works + output = server.succeed("reaction flush") + print(output) + + client.succeed(f"nc -w3 -z {server_addr} 22") + ''; + + # Debug interactively with: + # - nix run .#nixosTests.reaction.driverInteractive -L + # - run_tests() + # ssh -o User=root vsock%3 (can also do vsock/3, but % works with scp etc.) + # ssh -o User=root vsock%4 + interactive.sshBackdoor.enable = true; + + interactive.nodes.server = + { config, ... }: + { + # not needed, only for manual interactive debugging + virtualisation.memorySize = 4096; + virtualisation.graphics = false; + environment.systemPackages = with pkgs; [ + btop + sysz + sshpass + libressl.nc + ]; + }; + + meta.maintainers = + with lib.maintainers; + [ + ppom + ] + ++ lib.teams.ngi.members; +} diff --git a/nixos/tests/reaction/settings.nix b/nixos/tests/reaction/settings.nix new file mode 100644 index 000000000000..c342f4384f78 --- /dev/null +++ b/nixos/tests/reaction/settings.nix @@ -0,0 +1,74 @@ +{ config, ... }: +let + banFor = name: duration: { + ban = { + type = "ipset"; + options = { + set = "reaction-${name}"; + action = "add"; + }; + }; + unban = { + after = duration; + type = "ipset"; + options = { + set = "reaction-${name}"; + action = "del"; + }; + }; + }; + + journalctl = "${config.systemd.package}/bin/journalctl"; +in +{ + patterns = { + ip = { + type = "ip"; + ipv6mask = 64; + ignore = [ + "127.0.0.1" + "::1" + ]; + ignorecidr = [ + "10.1.1.0/24" + "2a01:e0a:b3a:1dd0::/64" + ]; + }; + }; + + streams = { + ssh = { + cmd = [ + journalctl + "-fn0" + "-o" + "cat" + "-u" + "sshd.service" + ]; + filters = { + failedlogin = { + regex = [ + "authentication failure;.*rhost=(?: |$)" + "Failed password for .* from port" + "Invalid user .* from " + "Connection (?:reset|closed) by invalid user .* port" + ]; + retry = 2; + retryperiod = "6h"; + actions = banFor "ssh" "48h"; + }; + connectionreset = { + regex = [ + "Connection (?:reset|closed) by(?: authenticating user .*)? port" + "Received disconnect from port .*[preauth]" + "Timeout before authentication for connection from to" + ]; + retry = 2; + retryperiod = "6h"; + actions = banFor "sshreset" "48h"; + }; + }; + }; + }; +}