diff --git a/nixos/modules/services/security/reaction.nix b/nixos/modules/services/security/reaction.nix index 0572913e3bc0..1edf4bcc698d 100644 --- a/nixos/modules/services/security/reaction.nix +++ b/nixos/modules/services/security/reaction.nix @@ -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; }; }; diff --git a/nixos/tests/reaction-firewall.nix b/nixos/tests/reaction-firewall.nix index 7efea1ad4ae0..a3d46cbfda8d 100644 --- a/nixos/tests/reaction-firewall.nix +++ b/nixos/tests/reaction-firewall.nix @@ -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}" - # 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" + check_reaction_in_iptables() - # Verify reaction chain still exists after reload - output = machine.succeed("iptables -w -L -n") - assert "reaction" in output, "reaction chain missing after firewall reload" + with subtest("reload firewall"): + machine.succeed("systemctl reload firewall") + output = machine.succeed("journalctl -u firewall.service --no-pager") + assert "ERROR" not in output, "firewall reload failed due to reaction" - # 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" + check_reaction_in_iptables(context="after firewall reload") - output = machine.succeed("iptables -w -L -n") - assert "reaction" in output, "reaction chain missing after firewall restart" + with subtest("restart firewall"): + machine.succeed("systemctl restart firewall") + 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" - # 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" + check_reaction_in_iptables(context="after firewall restart") - # Start reaction again and verify it works - machine.succeed("systemctl start reaction") - machine.wait_for_unit("reaction.service") + with subtest("stop reaction manually and verify chains are cleaned up"): + machine.succeed("systemctl stop reaction") + machine.sleep(3) + output = machine.succeed("iptables -nvL") + assert "reaction" not in output, "reaction chain still exists after the service was stopped" - output = machine.succeed("iptables -w -L -n") - assert "reaction" in output, "reaction chain not recreated" + with subtest("start reaction again and verify it works"): + machine.succeed("systemctl start reaction") + machine.wait_for_unit("reaction.service") + + check_reaction_in_iptables() ''; # Debug interactively with: diff --git a/nixos/tests/reaction.nix b/nixos/tests/reaction.nix index af743caa1260..730c230e4226 100644 --- a/nixos/tests/reaction.nix +++ b/nixos/tests/reaction.nix @@ -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}"