Unverified Commit 513cc10c authored by phanirithvij's avatar phanirithvij
Browse files

nixos/reaction: systemd harden, improve nixostest

parent c92a2155
Loading
Loading
Loading
Loading
+37 −25
Original line number Diff line number Diff line
@@ -17,7 +17,6 @@ in
{
  options.services.reaction = {
    enable = mkEnableOption "enable reaction";

    package = mkPackageOption pkgs "reaction" { };

    settings = mkOption {
@@ -102,8 +101,14 @@ in
        {
          # allows reading journal logs of processess
          users.users.reaction.extraGroups = [ "systemd-journal" ];

          # allows modifying ip firewall rules
          systemd.services.reaction.AmbientCapabilities = [ "CAP_NET_ADMIN" ];
          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";
        }
@@ -117,19 +122,14 @@ in
      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);
      settingsDir = pkgs.runCommand "reaction-settings-dir" { } ''
        mkdir -p $out
        ${lib.concatMapStringsSep "\n" (file: ''
          filename=$(basename "${file}")
          ln -s "${file}" "$out/$filename"
        '') cfg.settingsFiles}
        ln -s ${generatedSettings} $out/reaction.yml
      '';
    in
    lib.mkIf cfg.enable {
      assertions = [
@@ -157,18 +157,12 @@ in
            ''
          );

      # 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";
@@ -177,11 +171,29 @@ in
              lib.optionalString (cfg.loglevel != null) " -l ${cfg.loglevel}"
            }
          '';
          StateDirectory = "reaction";
          RuntimeDirectory = "reaction";
          WorkingDirectory = "/var/lib/reaction";

          CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
          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;
        };
      };

+27 −27
Original line number Diff line number Diff line
@@ -24,44 +24,44 @@
  testScript = # py
    ''
      start_all()
      machine.wait_for_unit("multi-user.target")

      # Verify both services start successfully
      machine.wait_for_unit("multi-user.target")
      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"
      def check_reaction_in_iptables(context = ""):
        with subtest("check reaction chain exists"):
          machine.sleep(3)
          output = machine.succeed("iptables -nvL")
          assert "reaction" in output, f"error: reaction chain missing in iptables, {context}"

      check_reaction_in_iptables()

      # Reload firewall and verify there's no issues due to reaction chain
      with subtest("reload firewall"):
        machine.succeed("systemctl reload firewall")
      output = machine.succeed("journalctl -u reaction.service -u firewall.service --no-pager")
        output = machine.succeed("journalctl -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"
        check_reaction_in_iptables(context="after firewall reload")

      # Restart firewall and verify reaction restarts as well
      with subtest("restart firewall"):
        machine.succeed("systemctl restart firewall")
      output = machine.succeed("journalctl -u reaction.service -u firewall.service --no-pager")
        output = machine.succeed("journalctl -u reaction.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"
        check_reaction_in_iptables(context="after firewall restart")

      # Stop reaction manually and verify chains are cleaned up
      with subtest("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"
        machine.sleep(3)
        output = machine.succeed("iptables -nvL")
        assert "reaction" not in output, "reaction chain still exists after the service was stopped"

      # Start reaction again and verify it works
        with subtest("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"
          check_reaction_in_iptables()
    '';

  # Debug interactively with:
+11 −8
Original line number Diff line number Diff line
@@ -10,18 +10,22 @@
    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
      ];
      # example.jsonnet/example.yml can be copied and modified from ${pkgs.reaction}/share/examples
      settingsFiles = [ "${pkgs.reaction}/share/examples/example.jsonnet" ];
      runAsRoot = false;
    };
    services.openssh.enable = true;
    # If not running as root you need to give the reaction user and service the proper permissions

    # required to access journal of sshd.service as runAsRoot = false
    # allows reading journal logs of processess
    users.users.reaction.extraGroups = [ "systemd-journal" ];
    # required for allowing reaction to modifiy firewall rules
    systemd.services.reaction.serviceConfig.AmbientCapabilities = [ "CAP_NET_ADMIN" ];

    # 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" ];
    };

    users.users.nixos.isNormalUser = true; # neeeded to establish a ssh connection, by default root login is succeeding without any password
  };
@@ -42,7 +46,6 @@
      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}"