nixos/reaction: systemd harden, improve nixostest (#471567)

This commit is contained in:
Ivan Mincik
2025-12-17 15:06:57 +00:00
committed by GitHub
3 changed files with 75 additions and 60 deletions
+37 -25
View File
@@ -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
View File
@@ -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:
+11 -8
View File
@@ -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}"