diff --git a/README.md b/README.md index a276d55..988cf3c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ a program that scans program outputs, such as logs, for repeated patterns, such as failed login attempts, and takes action, such as banning ips. -(adapted from [fail2ban](http://fail2ban.org)'s presentation 😄) +(adapted from [ fail2ban ](http://fail2ban.org)'s presentation 😄) 🚧 this program hasn't received external audit. however, it already works well on my servers 🚧 @@ -22,16 +22,27 @@ and an always-running daemon should be implemented in a fast language. this configuration file is all that should be needed to prevent brute force attacks on an ssh server. -see [reaction.service](./config/reaction.service) and [reaction.yml](./app/reaction.yml) for the fully explained examples. +see [ reaction.service ](./config/reaction.service) and [reaction.yml](./app/reaction.yml) for the fully explained examples. `/etc/reaction.yml` ```yaml definitions: - - &iptablesban [ "iptables" "-w" "-I" "reaction" "1" "-s" "" "-j" "block" ] - - &iptablesunban [ "iptables" "-w" "-D" "reaction" "1" "-s" "" "-j" "block" ] + - &iptablesban [ "ip46tables" "-w" "-I" "reaction" "1" "-s" "" "-j" "block" ] + - &iptablesunban [ "ip46tables" "-w" "-D" "reaction" "1" "-s" "" "-j" "block" ] patterns: - ip: '(([0-9]{1,3}\.){3}[0-9]{1,3})|([0-9a-fA-F:]{2,90})' + ip: '(([ 0-9 ]{1,3}\.){3}[0-9]{1,3})|([0-9a-fA-F:]{2,90})' + +start: + - [ "ip46tables", "-w", "-N", "reaction" ] + - [ "ip46tables", "-w", "-A", "reaction", "-j", "ACCEPT" ] + - [ "ip46tables", "-w", "-I", "reaction", "1", "-s", "127.0.0.1", "-j", "ACCEPT" ] + - [ "ip46tables", "-w", "-I", "INPUT", "-p", "all", "-j", "reaction" ] + +stop: + - [ "ip46tables", "-w," "-D," "INPUT", "-p", "all", "-j", "reaction" ] + - [ "ip46tables", "-w," "-F," "reaction" ] + - [ "ip46tables", "-w," "-X," "reaction" ] streams: ssh: @@ -54,17 +65,29 @@ jsonnet is also supported: `/etc/reaction.jsonnet` ```jsonnet -local iptablesban = ['iptables', '-w', '-A', 'reaction', '1', '-s', '', '-j', 'DROP']; -local iptablesunban = ['iptables', '-w', '-D', 'reaction', '1', '-s', '', '-j', 'DROP']; +local iptables(args) = [ 'ip46tables', '-w' ] + args; +local iptablesban(ip) = iptables([ '-A', 'reaction', '1', '-s', ip, '-j', 'DROP' ]); +local iptablesunban(ip) = iptables([ '-D', 'reaction', '1', '-s', ip, '-j', 'DROP' ]); { patterns: { ip: { - regex: @'(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})', + regex: @'(?:(?:[ 0-9 ]{1,3}\.){3}[0-9]{1,3})|(?:[0-9a-fA-F:]{2,90})', }, }, + start: { + iptables([ "-N", "reaction" ]), + iptables([ "-A", "reaction", "-j", "ACCEPT" ]), + iptables([ "-I", "reaction", "1", "-s", "127.0.0.1", "-j", "ACCEPT" ]), + iptables([ "-I", "INPUT", "-p", "all", "-j", "reaction" ]), + }, + stop: { + iptables([ "-D,", "INPUT", "-p", "all", "-j", "reaction" ]), + iptables([ "-F,", "reaction" ]), + iptables([ "-X,", "reaction" ]), + }, streams: { ssh: { - cmd: ['journalctl', '-fu', 'sshd.service'], + cmd: [ 'journalctl', '-fu', 'sshd.service' ], filters: { failedlogin: { regex: [ @'authentication failure;.*rhost=' ], @@ -72,10 +95,10 @@ local iptablesunban = ['iptables', '-w', '-D', 'reaction', '1', '-s', '', '- retryperiod: '6h', actions: { ban: { - cmd: iptablesban, + cmd: iptablesban(''), }, unban: { - cmd: iptablesunban, + cmd: iptablesunban(''), after: '48h', onexit: true, }, @@ -91,21 +114,12 @@ note that both yaml and jsonnet are extensions of json, so json is also inherent `/etc/systemd/system/reaction.service` ```systemd -[Unit] +[ Unit ] WantedBy=multi-user.target -[Service] +[ Service ] ExecStart=/path/to/reaction -c /etc/reaction.yml -ExecStartPre=/path/to/iptables -w -N reaction -ExecStartPre=/path/to/iptables -w -A reaction -j ACCEPT -ExecStartPre=/path/to/iptables -w -I reaction 1 -s 127.0.0.1 -j ACCEPT -ExecStartPre=/path/to/iptables -w -I INPUT -p all -j reaction - -ExecStopPost=/path/to/iptables -w -D INPUT -p all -j reaction -ExecStopPost=/path/to/iptables -w -F reaction -ExecStopPost=/path/to/iptables -w -X reaction - StateDirectory=reaction RuntimeDirectory=reaction WorkingDirectory=/var/lib/reaction @@ -142,11 +156,11 @@ $ gcc ip46tables.d/ip46tables.c -o ip46tables ### nixos -in addition to the [package](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/pkgs/reaction/default.nix) -and [module](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction.nix) +in addition to the [ package ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/pkgs/reaction/default.nix) +and [ module ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction.nix) that i didn't try to upstream to nixpkgs yet (although they are ready), i use extensively reaction on my servers. if you're using nixos, -consider reading and building upon [my own building blocks](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-variables.nix), -[my own non-root reaction conf, including conf for SSH, port scanning & Nginx common attack URLS](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-custom.nix), -and the configuration for [nextcloud](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/file.ppom.me.nix#L53), -[vaultwarden](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/vaultwarden.nix#L45), -and [maddy](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/mail.nix#L74). see also an [example](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/mail.nix#L85) where it does something else than banning IPs. +consider reading and building upon [ my own building blocks ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-variables.nix), +[ my own non-root reaction conf, including conf for SSH, port scanning & Nginx common attack URLS ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/common/reaction-custom.nix), +and the configuration for [ nextcloud ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/file.ppom.me.nix#L53), +[ vaultwarden ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/vaultwarden.nix#L45), +and [ maddy ](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/mail.nix#L74). see also an [example](https://framagit.org/ppom/nixos/-/blob/cf5448b21ae3386265485308a6cd077e8068ad77/modules/musi/mail.nix#L85) where it does something else than banning IPs. diff --git a/app/daemon.go b/app/daemon.go index 4aa795d..4ff70c8 100644 --- a/app/daemon.go +++ b/app/daemon.go @@ -39,6 +39,15 @@ func cmdStdout(commandline []string) chan *string { return lines } +func runCommands(commands [][]string, moment string) { + for _, command := range commands { + cmd := exec.Command(command[0], command[1:]...) + if err := cmd.Start(); err != nil { + logger.Printf(logger.ERROR, "couldn't execute %v command: %v", moment, err) + } + } +} + func (p *Pattern) notAnIgnore(match *string) bool { for _, ignore := range p.Ignore { if ignore == *match { @@ -323,6 +332,8 @@ func Daemon(confFilename string) { actions = make(ActionsMap) matches = make(MatchesMap) + runCommands(conf.Start, "start") + go DatabaseManager(conf) go MatchesManager() go ActionsManager() @@ -348,16 +359,16 @@ func Daemon(confFilename string) { logger.Printf(logger.ERROR, "%s stream finished", finishedStream.name) nbStreamsInExecution-- if nbStreamsInExecution == 0 { - quit() + quit(conf) } case <-sigs: logger.Printf(logger.INFO, "Received SIGINT/SIGTERM, exiting") - quit() + quit(conf) } } } -func quit() { +func quit(conf *Conf) { // send stop to StreamManager·s close(stopStreams) logger.Println(logger.INFO, "Waiting for Streams to finish...") @@ -369,6 +380,8 @@ func quit() { // stop all actions logger.Println(logger.INFO, "Waiting for Actions to finish...") wgActions.Wait() + // run stop commands + runCommands(conf.Stop, "stop") // delete pipe err := os.Remove(*SocketPath) if err != nil { diff --git a/app/example.yml b/app/example.yml index aafe50a..95a8220 100644 --- a/app/example.yml +++ b/app/example.yml @@ -3,8 +3,8 @@ # using YAML anchors `&name` and pointers `*name` # definitions are not readed by reaction definitions: - - &iptablesban [ "ip46tables" "-w" "-A" "reaction" "1" "-s" "" "-j" "DROP" ] - - &iptablesunban [ "ip46tables" "-w" "-D" "reaction" "1" "-s" "" "-j" "DROP" ] + - &iptablesban [ "ip46tables", "-w", "-A", "reaction", "1", "-s", "", "-j", "DROP" ] + - &iptablesunban [ "ip46tables", "-w", "-D", "reaction", "1", "-s", "", "-j", "DROP" ] # ip46tables is a minimal C program (only POSIX dependencies) present as a subdirectory. # it permits to handle both ipv4/iptables and ipv6/ip6tables commands @@ -18,6 +18,20 @@ patterns: - 127.0.0.1 - ::1 +# Those commands will be executed in order at start, before everything else +start: + - [ "ip46tables", "-w", "-N", "reaction" ] + - [ "ip46tables", "-w", "-A", "reaction", "-j", "ACCEPT" ] + - [ "ip46tables", "-w", "-I", "reaction", "1", "-s", "127.0.0.1", "-j", "ACCEPT" ] + - [ "ip46tables", "-w", "-I", "INPUT", "-p", "all", "-j", "reaction" ] + +# Those commands will be executed in order at stop, after everything else +stop: + - [ "ip46tables", "-w,", "-D", "INPUT", "-p", "all", "-j", "reaction" ] + - [ "ip46tables", "-w", "-F", "reaction" ] + - [ "ip46tables", "-w", "-X", "reaction" ] + + # streams are commands # they're run and their ouptut is captured # *example:* `tail -f /var/log/nginx/access.log` @@ -27,7 +41,7 @@ streams: ssh: # note that if the command is not in environment's `PATH` # its full path must be given. - cmd: [ "journalctl" "-n0" "-fu" "sshd.service" ] + cmd: [ "journalctl", "-n0", "-fu", "sshd.service" ] # filters run actions when they match regexes on a stream filters: # filters have a user-defined name diff --git a/app/types.go b/app/types.go index 9a297da..cd196f3 100644 --- a/app/types.go +++ b/app/types.go @@ -10,6 +10,8 @@ import ( type Conf struct { Patterns map[string]*Pattern `json:"patterns"` Streams map[string]*Stream `json:"streams"` + Start [][]string `json:"start"` + Stop [][]string `json:"stop"` } type Pattern struct { diff --git a/config/activitywatch.jsonnet b/config/activitywatch.jsonnet index 8100052..33e4083 100644 --- a/config/activitywatch.jsonnet +++ b/config/activitywatch.jsonnet @@ -1,8 +1,6 @@ -local directory = '~/.local/share/watch'; // Those strings will be substitued in each shell() call local substitutions = [ - ['OUTFILE', directory + '/logs-$(date %+F)'], - ['TMUXFILE', directory + '/tmux'], + ['OUTFILE', '"$HOME/.local/share/watch/logs-$(date +%F)"'], ['DATE', '"$(date "+%F %T")"'], ]; @@ -18,19 +16,17 @@ local shell(prg) = [ sub(prg), ]; -{ - // Startup is currently not implemented - startup: shell(||| - mkdir -p "$(dirname OUTFILE)" - echo DATE start >> OUTFILE - # tmux set-hook -g pane-focus-in[50] new-session -d 'echo tmux >> TMUXFILE' - |||), +local log(line) = shell('echo DATE ' + std.strReplace(line, '\n', ' ') + '>> OUTFILE'); - // Stop is currently not implemented - stop: shell(||| - tmux set-hook -ug pane-focus-in[50] - echo DATE stop >> OUTFILE - |||), +{ + start: [ + shell('mkdir -p "$(dirname OUTFILE)"'), + log('start'), + ], + + stop: [ + log('stop'), + ], patterns: { all: { regex: '.*' }, @@ -47,7 +43,7 @@ local shell(prg) = [ send: { regex: ['^$'], actions: { - send: { cmd: shell('echo DATE focus >> OUTFILE') }, + send: { cmd: log('focus ') }, }, }, }, @@ -55,12 +51,13 @@ local shell(prg) = [ // Be notified when user is away swayidle: { - cmd: ['swayidle', 'timeout', '60', 'echo sleep', 'resume', 'echo resume'], + // FIXME echo stop and start instead? + cmd: ['swayidle', 'timeout', '30', 'echo sleep', 'resume', 'echo resume'], filters: { send: { regex: ['^$'], actions: { - send: { cmd: shell('echo DATE >> OUTFILE') }, + send: { cmd: log('') }, }, }, }, @@ -92,7 +89,7 @@ local shell(prg) = [ // send: { // regex: ['^tmux $'], // actions: { - // send: { cmd: shell('echo DATE tmux >> OUTFILE') }, + // send: { cmd: log('tmux ') }, // }, // }, // }, diff --git a/config/example.jsonnet b/config/example.jsonnet index b6f2b38..598fab1 100644 --- a/config/example.jsonnet +++ b/config/example.jsonnet @@ -1,11 +1,13 @@ // This file is using JSONNET, a complete configuration language based on JSON // See https://jsonnet.org // JSONNET is a superset of JSON, so one can write plain JSON files if wanted. -// Note that YAML is also supported. +// Note that YAML is also supported, see ./example.yml +// A JSONNET function +local iptables(args) = ['ip46tables', '-w'] + args; // variables defined for later use. -local iptablesban = ['ip46tables', '-w', '-A', 'reaction', '1', '-s', '', '-j', 'DROP']; -local iptablesunban = ['ip46tables', '-w', '-D', 'reaction', '1', '-s', '', '-j', 'DROP']; +local iptablesban = iptables(['-A', 'reaction', '1', '-s', '', '-j', 'drop']); +local iptablesunban = iptables(['-D', 'reaction', '1', '-s', '', '-j', 'drop']); // ip46tables is a minimal C program (only POSIX dependencies) present as a subdirectory. // it permits to handle both ipv4/iptables and ipv6/ip6tables commands @@ -21,6 +23,30 @@ local iptablesunban = ['ip46tables', '-w', '-D', 'reaction', '1', '-s', '', }, }, + // Those commands will be executed in order at start, before everything else + start: [ + // Create an iptables chain for reaction + iptables(['-N', 'reaction']), + // Set its default to ACCEPT + iptables(['-A', 'reaction', '-j', 'ACCEPT']), + // Always accept 127.0.0.1 + iptables(['-I', 'reaction', '1', '-s', '127.0.0.1', '-j', 'ACCEPT']), + // Always accept ::1 + iptables(['-I', 'reaction', '1', '-s', '::1', '-j', 'ACCEPT']), + // Insert this chain as the first item of the INPUT chain (for incoming connections) + iptables(['-I', 'INPUT', '-p', 'all', '-j', 'reaction']), + ], + + // Those commands will be executed in order at stop, after everything else + stop: [ + // Remove the chain from the INPUT chain + iptables(['-D,', 'INPUT', '-p', 'all', '-j', 'reaction']), + // Empty the chain + iptables(['-F,', 'reaction']), + // Delete the chain + iptables(['-X,', 'reaction']), + ], + // streams are commands // they're run and their ouptut is captured // *example:* `tail -f /var/log/nginx/access.log` diff --git a/config/reaction.service b/config/reaction.service index 0d00b54..5232252 100644 --- a/config/reaction.service +++ b/config/reaction.service @@ -6,24 +6,6 @@ WantedBy=multi-user.target [Service] ExecStart=/path/to/reaction -c /etc/reaction.yml -# Create an iptables chain for reaction -ExecStartPre=/path/to/ip46tables -w -N reaction -# Set its default to ACCEPT -ExecStartPre=/path/to/ip46tables -w -A reaction -j ACCEPT -# Always accept 127.0.0.1 -ExecStartPre=/path/to/ip46tables -w -I reaction 1 -s 127.0.0.1 -j ACCEPT -# Always accept ::1 -ExecStartPre=/path/to/ip46tables -w -I reaction 1 -s ::1 -j ACCEPT -# Insert this chain as the first item of the INPUT chain (for incoming connections) -ExecStartPre=/path/to/ip46tables -w -I INPUT -p all -j reaction - -# Remove the chain from the INPUT chain -ExecStopPost=/path/to/ip46tables -w -D INPUT -p all -j reaction -# Empty the chain -ExecStopPost=/path/to/ip46tables -w -F reaction -# Delete the chain -ExecStopPost=/path/to/ip46tables -w -X reaction - # Ask systemd to create /var/lib/reaction (/var/lib/ is implicit) StateDirectory=reaction # Ask systemd to create /run/reaction at runtime (/run/ is implicit) diff --git a/logger/log.go b/logger/log.go index 3f1eaeb..221d45b 100644 --- a/logger/log.go +++ b/logger/log.go @@ -69,8 +69,10 @@ func Printf(level Level, format string, args ...any) { } func Fatalln(args ...any) { - level := FATAL - log.Fatalln(level.String(), args) + newargs := make([]any, 0) + newargs = append(newargs, FATAL) + newargs = append(newargs, args...) + log.Fatalln(newargs...) } func Fatalf(format string, args ...any) {