diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 9f8cd65c86fc..9700dfc4899c 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -18,6 +18,8 @@ - [ImmichFrame](https://immichframe.dev/), display your photos from Immich as a digital photo frame. Available as `services.immichframe`. +- [reaction](https://reaction.ppom.me/), a daemon that scans program outputs for repeated patterns, and takes action. A common usage is to scan ssh and webserver logs, and to ban hosts that cause multiple authentication errors. A modern alternative to fail2ban. Available as [services.reaction](#opt-services.reaction.enable). + - [LibreChat](https://www.librechat.ai/), open-source self-hostable ChatGPT clone with Agents and RAG APIs. Available as [services.librechat](#opt-services.librechat.enable). - [udp-over-tcp](https://github.com/mullvad/udp-over-tcp), a tunnel for proxying UDP traffic over a TCP stream. Available as `services.udp-over-tcp`. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2bfca447e011..67bd820ad2ca 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1498,6 +1498,7 @@ ./services/security/pass-secret-service.nix ./services/security/physlock.nix ./services/security/pocket-id.nix + ./services/security/reaction.nix ./services/security/shibboleth-sp.nix ./services/security/sks.nix ./services/security/sshguard.nix diff --git a/nixos/modules/services/security/reaction.nix b/nixos/modules/services/security/reaction.nix new file mode 100644 index 000000000000..0572913e3bc0 --- /dev/null +++ b/nixos/modules/services/security/reaction.nix @@ -0,0 +1,198 @@ +{ + lib, + pkgs, + config, + ... +}: +let + settingsFormat = pkgs.formats.yaml { }; + + inherit (lib) + mkOption + mkEnableOption + mkPackageOption + 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 = { }; + }; + }; + + 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.AmbientCapabilities = [ "CAP_NET_ADMIN" ]; + # optional, if more control over ssh logs is needed + services.openssh.settings.LogLevel = lib.mkDefault "VERBOSE"; + } + ``` + ''; + }; + }; + + config = + let + cfg = config.services.reaction; + + generatedSettings = settingsFormat.generate "reaction.yml" cfg.settings; + namedGeneratedSettings = lib.optional (cfg.settings != { }) { + name = "reaction.yml"; + path = generatedSettings; + }; + + # SAFETY: We can discard the dependencies of "file" in the name attribute because we keep them in the path attribute + # See https://nix.dev/manual/nix/2.32/language/string-context + namedSettingsFiles = builtins.map (file: { + name = builtins.unsafeDiscardStringContext (builtins.baseNameOf file); + path = file; + }) cfg.settingsFiles; + + settingsDir = pkgs.linkFarm "reaction.d" (namedSettingsFiles ++ namedGeneratedSettings); + in + lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.settings != { } || (builtins.length cfg.settingsFiles) != 0; + message = "You must specify settings and/or settingsFile options"; + } + ]; + + users = lib.mkIf (!cfg.runAsRoot) { + users.reaction = { + isSystemUser = true; + group = "reaction"; + }; + groups.reaction = { }; + }; + + system.checks = + lib.optional (cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform) + ( + pkgs.runCommand "reaction-config-validation" { } '' + ${lib.getExe cfg.package} test-config -c ${settingsDir} >/dev/null + echo "reaction config ${settingsDir} is valid" + touch $out + '' + ); + + # Easier to debug conf when we have direct access to it, + # rather than having to look for it in the systemd service file. + environment.etc."reaction".source = settingsDir; + + systemd.services.reaction = { + enable = true; + description = "Scan logs and take action"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + partOf = lib.optionals cfg.stopForFirewall [ "firewall.service" ]; + path = [ pkgs.iptables ]; + unitConfig.ConditionCapability = "CAP_NET_ADMIN"; + serviceConfig = { + Type = "simple"; + User = if (!cfg.runAsRoot) then "reaction" else "root"; + ExecStart = '' + ${lib.getExe cfg.package} start -c ${settingsDir}${ + lib.optionalString (cfg.loglevel != null) " -l ${cfg.loglevel}" + } + ''; + StateDirectory = "reaction"; + RuntimeDirectory = "reaction"; + WorkingDirectory = "/var/lib/reaction"; + + CapabilityBoundingSet = [ "CAP_NET_ADMIN" ]; + }; + }; + + environment.systemPackages = [ cfg.package ]; + }; + + meta.maintainers = + with lib.maintainers; + [ + ppom + phanirithvij + ] + ++ lib.teams.ngi.members; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index e4f0b3f121f9..409392f6227f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1322,6 +1322,8 @@ in rasdaemon = runTest ./rasdaemon.nix; rathole = runTest ./rathole.nix; rauc = runTest ./rauc.nix; + reaction = runTest ./reaction.nix; + reaction-firewall = runTest ./reaction-firewall.nix; readarr = runTest ./readarr.nix; readeck = runTest ./readeck.nix; realm = runTest ./realm.nix; diff --git a/nixos/tests/reaction-firewall.nix b/nixos/tests/reaction-firewall.nix new file mode 100644 index 000000000000..7efea1ad4ae0 --- /dev/null +++ b/nixos/tests/reaction-firewall.nix @@ -0,0 +1,79 @@ +{ + lib, + pkgs, + ... +}: +{ + name = "reaction-firewall-interaction"; + + nodes.machine = + { config, pkgs, ... }: + { + services.reaction = { + enable = true; + stopForFirewall = true; # with this enabled restarting firewall will restart reaction + settingsFiles = [ + "${pkgs.reaction}/share/examples/example.jsonnet" + # "${pkgs.reaction}/share/examples/example.yml" # can't specify both because conflicts + ]; + runAsRoot = true; + }; + networking.firewall.enable = true; + }; + + testScript = # py + '' + start_all() + machine.wait_for_unit("multi-user.target") + + # Verify both services start successfully + machine.wait_for_unit("firewall.service") + machine.wait_for_unit("reaction.service") + + # Check reaction chain exists in iptables + output = machine.succeed("iptables -w -L -n") + assert "reaction" in output, "reaction chain not found in iptables" + + # Reload firewall and verify there's no issues due to reaction chain + machine.succeed("systemctl reload firewall") + output = machine.succeed("journalctl -u reaction.service -u firewall.service --no-pager") + assert "ERROR" not in output, "firewall reload failed due to reaction" + + # Verify reaction chain still exists after reload + output = machine.succeed("iptables -w -L -n") + assert "reaction" in output, "reaction chain missing after firewall reload" + + # Restart firewall and verify reaction restarts as well + machine.succeed("systemctl restart firewall") + output = machine.succeed("journalctl -u reaction.service -u firewall.service --no-pager") + assert "INFO stop command" in output and "INFO start command" in output, "reaction did not restart when firewall was restarted" + + output = machine.succeed("iptables -w -L -n") + assert "reaction" in output, "reaction chain missing after firewall restart" + + # Stop reaction manually and verify chains are cleaned up + machine.succeed("systemctl stop reaction") + output = machine.succeed("iptables -w -L -n || true") + assert "reaction" not in output, "reaction chain still exists after stop" + + # Start reaction again and verify it works + machine.succeed("systemctl start reaction") + machine.wait_for_unit("reaction.service") + + output = machine.succeed("iptables -w -L -n") + assert "reaction" in output, "reaction chain not recreated" + ''; + + # Debug interactively with: + # - nix run .#nixosTests.reaction-firewall.driverInteractive -L + # - run_tests() + interactive.sshBackdoor.enable = true; # ssh -o User=root vsock%3 + + meta.maintainers = + with lib.maintainers; + [ + ppom + phanirithvij + ] + ++ lib.teams.ngi.members; +} diff --git a/nixos/tests/reaction.nix b/nixos/tests/reaction.nix new file mode 100644 index 000000000000..af743caa1260 --- /dev/null +++ b/nixos/tests/reaction.nix @@ -0,0 +1,107 @@ +{ + lib, + pkgs, + ... +}: +{ + name = "reaction"; + + nodes.server = _: { + services.reaction = { + enable = true; + stopForFirewall = false; + settingsFiles = [ + "${pkgs.reaction}/share/examples/example.jsonnet" + # "${pkgs.reaction}/share/examples/example.yml" # can't specify both because conflicts + ]; + runAsRoot = false; + }; + services.openssh.enable = true; + + # required to access journal of sshd.service as runAsRoot = false + users.users.reaction.extraGroups = [ "systemd-journal" ]; + # required for allowing reaction to modifiy firewall rules + systemd.services.reaction.serviceConfig.AmbientCapabilities = [ "CAP_NET_ADMIN" ]; + + 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.wait_for_unit("multi-user.target") + + 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; + environment.systemPackages = with pkgs; [ + btop + sysz + sshpass + libressl.nc + ]; + }; + + meta.maintainers = + with lib.maintainers; + [ + ppom + phanirithvij + ] + ++ lib.teams.ngi.members; +} diff --git a/pkgs/by-name/re/reaction/package.nix b/pkgs/by-name/re/reaction/package.nix index 1f57cbeb1e87..2a480eccee7f 100644 --- a/pkgs/by-name/re/reaction/package.nix +++ b/pkgs/by-name/re/reaction/package.nix @@ -54,6 +54,7 @@ rustPlatform.buildRustPackage (finalAttrs: { license = lib.licenses.agpl3Plus; mainProgram = "reaction"; maintainers = with lib.maintainers; [ ppom ]; + teams = [ lib.teams.ngi ]; platforms = lib.platforms.unix; }; })