mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-07-06 17:13:24 -05:00
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:
parent
2500d0b83c
commit
10c4b77c91
7 changed files with 390 additions and 0 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
198
nixos/modules/services/security/reaction.nix
Normal file
198
nixos/modules/services/security/reaction.nix
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
79
nixos/tests/reaction-firewall.nix
Normal file
79
nixos/tests/reaction-firewall.nix
Normal 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
107
nixos/tests/reaction.nix
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue