Unverified Commit 3f6da092 authored by Ivan Mincik's avatar Ivan Mincik Committed by GitHub
Browse files

nixos/reaction: init (#468019)

parents 22e9923e 3e6e83db
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -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).

- [DankMaterialShell](https://danklinux.com), a complete desktop shell for Wayland compositors built with Quickshell. Available as [programs.dms-shell](#opt-programs.dms-shell.enable).
+1 −0
Original line number Diff line number Diff line
@@ -1499,6 +1499,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
+198 −0
Original line number Diff line number Diff line
{
  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;
}
+2 −0
Original line number Diff line number Diff line
@@ -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;
+79 −0
Original line number Diff line number Diff line
{
  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;
}
Loading