Loading nixos/modules/services/security/reaction.nix +37 −25 Original line number Diff line number Diff line Loading @@ -17,7 +17,6 @@ in { options.services.reaction = { enable = mkEnableOption "enable reaction"; package = mkPackageOption pkgs "reaction" { }; settings = mkOption { Loading Loading @@ -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"; } Loading @@ -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 = [ Loading Loading @@ -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"; Loading @@ -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; }; }; Loading nixos/tests/reaction-firewall.nix +27 −27 Original line number Diff line number Diff line Loading @@ -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: Loading nixos/tests/reaction.nix +11 −8 Original line number Diff line number Diff line Loading @@ -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 }; Loading @@ -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}" Loading Loading
nixos/modules/services/security/reaction.nix +37 −25 Original line number Diff line number Diff line Loading @@ -17,7 +17,6 @@ in { options.services.reaction = { enable = mkEnableOption "enable reaction"; package = mkPackageOption pkgs "reaction" { }; settings = mkOption { Loading Loading @@ -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"; } Loading @@ -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 = [ Loading Loading @@ -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"; Loading @@ -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; }; }; Loading
nixos/tests/reaction-firewall.nix +27 −27 Original line number Diff line number Diff line Loading @@ -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: Loading
nixos/tests/reaction.nix +11 −8 Original line number Diff line number Diff line Loading @@ -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 }; Loading @@ -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}" Loading