nixos/reaction: systemd harden, improve nixostest (#471567)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user