nixos/reaction: init module

Co-authored-by: ppom <ppom0@users.noreply.github.com>
Signed-off-by: phanirithvij <phanirithvij2000@gmail.com>
This commit is contained in:
phanirithvij 2025-12-01 10:44:08 +05:30
commit 10c4b77c91
No known key found for this signature in database
7 changed files with 390 additions and 0 deletions

View file

@ -18,6 +18,8 @@
- [ImmichFrame](https://immichframe.dev/), display your photos from Immich as a digital photo frame. Available as `services.immichframe`.
- [reaction](https://reaction.ppom.me/), a daemon that scans program outputs for repeated patterns, and takes action. A common usage is to scan ssh and webserver logs, and to ban hosts that cause multiple authentication errors. A modern alternative to fail2ban. Available as [services.reaction](#opt-services.reaction.enable).
- [LibreChat](https://www.librechat.ai/), open-source self-hostable ChatGPT clone with Agents and RAG APIs. Available as [services.librechat](#opt-services.librechat.enable).
- [udp-over-tcp](https://github.com/mullvad/udp-over-tcp), a tunnel for proxying UDP traffic over a TCP stream. Available as `services.udp-over-tcp`.

View file

@ -1498,6 +1498,7 @@
./services/security/pass-secret-service.nix
./services/security/physlock.nix
./services/security/pocket-id.nix
./services/security/reaction.nix
./services/security/shibboleth-sp.nix
./services/security/sks.nix
./services/security/sshguard.nix

View file

@ -0,0 +1,198 @@
{
lib,
pkgs,
config,
...
}:
let
settingsFormat = pkgs.formats.yaml { };
inherit (lib)
mkOption
mkEnableOption
mkPackageOption
types
;
in
{
options.services.reaction = {
enable = mkEnableOption "enable reaction";
package = mkPackageOption pkgs "reaction" { };
settings = mkOption {
description = ''
Configuration for reaction. See the [wiki](https://framagit.org/ppom/reaction-wiki).
The settings are written as a YAML file.
Can be used in combination with `settingsFiles` option, both will be present in the configuration directory.
'';
default = { };
type = types.submodule {
freeformType = settingsFormat.type;
options = { };
};
};
settingsFiles = mkOption {
description = ''
Configuration for reaction, see the [wiki](https://framagit.org/ppom/reaction-wiki).
reaction supports JSON, YAML and JSONnet. For those who prefer to take advantage of JSONnet rather than Nix.
Can be used in combination with `settings` option, both will be present in the configuration directory.
'';
default = [ ];
type = types.listOf types.path;
};
loglevel = mkOption {
description = ''
reaction's loglevel. One of DEBUG, INFO, WARN, ERROR.
'';
default = null;
type = types.nullOr (
types.enum [
"DEBUG"
"INFO"
"WARN"
"ERROR"
]
);
};
stopForFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Whether to stop reaction when reloading the firewall.
The presence of a reaction chain in the INPUT table may cause the firewall
reload to fail.
One can alternatively cherry-pick the right iptables commands to execute before and after the firewall
```nix
{
systemd.services.firewall.serviceConfig = {
ExecStopPre = [ "''${pkgs.iptables}/bin/iptables -w -D INPUT -p all -j reaction" ];
ExecStartPost = [ "''${pkgs.iptables}/bin/iptables -w -I INPUT -p all -j reaction" ];
};
}
```
'';
};
checkConfig = mkOption {
type = types.bool;
default = true;
description = "Check the syntax of the configuration files at build time";
};
runAsRoot = mkOption {
type = types.bool;
default = false;
description = ''
Whether to run reaction as root.
Defaults to false, where an unprivileged reaction user is created.
Be sure to give it sufficient permissions.
Example config permitting `iptables` and `journalctl` use
```nix
{
# allows reading journal logs of processess
users.users.reaction.extraGroups = [ "systemd-journal" ];
# allows modifying ip firewall rules
systemd.services.reaction.AmbientCapabilities = [ "CAP_NET_ADMIN" ];
# optional, if more control over ssh logs is needed
services.openssh.settings.LogLevel = lib.mkDefault "VERBOSE";
}
```
'';
};
};
config =
let
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);
in
lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.settings != { } || (builtins.length cfg.settingsFiles) != 0;
message = "You must specify settings and/or settingsFile options";
}
];
users = lib.mkIf (!cfg.runAsRoot) {
users.reaction = {
isSystemUser = true;
group = "reaction";
};
groups.reaction = { };
};
system.checks =
lib.optional (cfg.checkConfig && pkgs.stdenv.hostPlatform == pkgs.stdenv.buildPlatform)
(
pkgs.runCommand "reaction-config-validation" { } ''
${lib.getExe cfg.package} test-config -c ${settingsDir} >/dev/null
echo "reaction config ${settingsDir} is valid"
touch $out
''
);
# 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";
ExecStart = ''
${lib.getExe cfg.package} start -c ${settingsDir}${
lib.optionalString (cfg.loglevel != null) " -l ${cfg.loglevel}"
}
'';
StateDirectory = "reaction";
RuntimeDirectory = "reaction";
WorkingDirectory = "/var/lib/reaction";
CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
};
};
environment.systemPackages = [ cfg.package ];
};
meta.maintainers =
with lib.maintainers;
[
ppom
phanirithvij
]
++ lib.teams.ngi.members;
}

View file

@ -1322,6 +1322,8 @@ in
rasdaemon = runTest ./rasdaemon.nix;
rathole = runTest ./rathole.nix;
rauc = runTest ./rauc.nix;
reaction = runTest ./reaction.nix;
reaction-firewall = runTest ./reaction-firewall.nix;
readarr = runTest ./readarr.nix;
readeck = runTest ./readeck.nix;
realm = runTest ./realm.nix;

View file

@ -0,0 +1,79 @@
{
lib,
pkgs,
...
}:
{
name = "reaction-firewall-interaction";
nodes.machine =
{ config, pkgs, ... }:
{
services.reaction = {
enable = true;
stopForFirewall = true; # with this enabled restarting firewall will restart reaction
settingsFiles = [
"${pkgs.reaction}/share/examples/example.jsonnet"
# "${pkgs.reaction}/share/examples/example.yml" # can't specify both because conflicts
];
runAsRoot = true;
};
networking.firewall.enable = true;
};
testScript = # py
''
start_all()
machine.wait_for_unit("multi-user.target")
# Verify both services start successfully
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"
# 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"
# Verify reaction chain still exists after reload
output = machine.succeed("iptables -w -L -n")
assert "reaction" in output, "reaction chain missing after firewall reload"
# 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"
output = machine.succeed("iptables -w -L -n")
assert "reaction" in output, "reaction chain missing after firewall restart"
# 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"
# 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"
'';
# Debug interactively with:
# - nix run .#nixosTests.reaction-firewall.driverInteractive -L
# - run_tests()
interactive.sshBackdoor.enable = true; # ssh -o User=root vsock%3
meta.maintainers =
with lib.maintainers;
[
ppom
phanirithvij
]
++ lib.teams.ngi.members;
}

107
nixos/tests/reaction.nix Normal file
View file

@ -0,0 +1,107 @@
{
lib,
pkgs,
...
}:
{
name = "reaction";
nodes.server = _: {
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
];
runAsRoot = false;
};
services.openssh.enable = true;
# required to access journal of sshd.service as runAsRoot = false
users.users.reaction.extraGroups = [ "systemd-journal" ];
# required for allowing reaction to modifiy firewall rules
systemd.services.reaction.serviceConfig.AmbientCapabilities = [ "CAP_NET_ADMIN" ];
users.users.nixos.isNormalUser = true; # neeeded to establish a ssh connection, by default root login is succeeding without any password
};
nodes.client = _: {
environment.systemPackages = [
pkgs.sshpass
pkgs.libressl.nc
];
};
testScript =
{ nodes, ... }: # py
''
start_all()
# Wait for everything to be ready.
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}"
# Verify there is not ban and the port is reachable from the client.
server.succeed(f"reaction show | grep -q {client_addr} || test $? -eq 1")
client.succeed(f"nc -w3 -z {server_addr} 22")
# Cause authentication failure log entries.
for _ in range(2):
client.fail(f"""
sshpass -p 'wrongpassword' \
ssh -o StrictHostKeyChecking=no \
-o User=nixos \
-o ServerAliveInterval=1 \
-o ServerAliveCountMax=2 \
{server_addr}
""")
# Verify there is a ban and the port is unreachable from the client.
server.sleep(2)
output = server.succeed("reaction show")
print(output)
assert client_addr in output, f"client did not get banned, {client_addr}"
client.fail(f"nc -w3 -z {server_addr} 22")
# Check that unbanning works
output = server.succeed("reaction flush")
print(output)
client.succeed(f"nc -w3 -z {server_addr} 22")
'';
# Debug interactively with:
# - nix run .#nixosTests.reaction.driverInteractive -L
# - run_tests()
# ssh -o User=root vsock%3 (can also do vsock/3, but % works with scp etc.)
# ssh -o User=root vsock%4
interactive.sshBackdoor.enable = true;
interactive.nodes.server =
{ config, ... }:
{
# not needed, only for manual interactive debugging
virtualisation.memorySize = 4096;
environment.systemPackages = with pkgs; [
btop
sysz
sshpass
libressl.nc
];
};
meta.maintainers =
with lib.maintainers;
[
ppom
phanirithvij
]
++ lib.teams.ngi.members;
}

View file

@ -54,6 +54,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
license = lib.licenses.agpl3Plus;
mainProgram = "reaction";
maintainers = with lib.maintainers; [ ppom ];
teams = [ lib.teams.ngi ];
platforms = lib.platforms.unix;
};
})