Compare commits

...

11 Commits
1.0 ... master

39 changed files with 2074 additions and 695 deletions

12
Changelog.md Normal file
View File

@ -0,0 +1,12 @@
# Changelog
This file is not intended as a dupplicate of Git logs.
Its purpose is to warn of important changes between version, that users should be aware of.
## v2.0
After this version is installed, the following command should be run on the `action_nftBan.py.json` file:
```bash
$ sudo sed -i s/nftSet/nfSet/g action_nftBan.py.json
```

View File

@ -1,18 +1,35 @@
# Python peruser of systemd-journal
## Summary
This program is intended to be used as a lightweight replacement for both epylog and fail2ban.
Its purpose is to peruse the system log entries, warn of important situations, report daily on the latest events, and act on specific patterns (IP address bans…).
* [Functional overview](doc/intro_func.md)
* [Technical overview](doc/intro_tech.md)
The software requirements are:
The benefits of Pyruse over products of the same kind are:
* a modern systemd-based Linux operating system (eg. [Archlinux](https://archlinux.org/)- or [Fedora](https://getfedora.org/)-based distributions);
* python, at least version 3.1 (or [more, depending on the modules](doc/intro_tech.md) being used);
* [python-systemd](https://www.freedesktop.org/software/systemd/python-systemd/journal.html);
* [nftables](http://wiki.nftables.org/) _if_ IP address bans are to be managed;
* a sendmail-like program _if_ emails are wanted.
* **Optimization brought by systemd**
[systemd-journal entries](https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html) play an important role in Pyruse: instead of matching log entries against message patterns only, the whole range of systemds journal fields is available. This allows for the much faster integer comparisons (`PRIORITY`, `_UID`…), or even faster comparisons with short strings like the `SYSLOG_IDENTIFIER`, `_SYSTEMD_UNIT`, or `_HOSTNAME`, with the opportunity to test more often for equality, and less for regular expressions.
* **Optimization brought by context**
Programs that peruse the system logs usually apply a set of rules on each log entry, rule after rule, regardless of what can be deduced by the already-applied rules.
In contrast, each fact learnt by applying a rule in Pyruse can be taken into account so that rules that do not apply are not even considered.
For example, after matching the `SYSLOG_IDENTIFIER` of a journal entry to the value `sshd`, only SSH-related rules are applied, not Nginx-related rules, nor Prosody-related rules.
* **Modularity**
Each filter (ie. a matching step) or action (eg. a ban, an email, etc.) is a Python module with a very simple API. As soon as a new need arises, a module can be written for it.
For example, to my knowledge, there is no equivalent in any tool of the same scale, for the [DNAT-correcting actions](doc/dnat.md) now included with Pyruse.
## Get Pyruse
Pyruse is [packaged for Archlinux](https://aur.archlinux.org/packages/pyruse/).
For other distributions, please [read the manual installation instructions](doc/install.md).
Whenever your upgrade Pyruse, make sure to check the [Changelog](Changelog.md).
## Configuration
The `/etc/pyruse` directory is where system-specific files are looked-for:
@ -21,6 +38,8 @@ The `/etc/pyruse` directory is where system-specific files are looked-for:
Instead of using `/etc/pyruse`, an alternate directory may be specified with the `PYRUSE_EXTRA` environment variable.
## Documentation
For more in-depth documentation, please refer to these pages:
* [General structure of the `pyruse.json` file](doc/conffile.md)
@ -29,7 +48,8 @@ For more in-depth documentation, please refer to these pages:
* More information about:
- [the built-in filters](doc/builtinfilters.md)
- [the counter-based actions](doc/counters.md)
- [the DNAT-related actions](doc/dnat.md)
- [the actions that log and ban](doc/logandban.md)
- [the `action_noop` module](doc/noop.md)
- [the `action_email` module](doc/action_email.md)
- [the `action_dailyReport` module](doc/action_dailyReport.md)
- [the `action_nftBan` module](doc/action_nftBan.md)

View File

@ -1,6 +1,7 @@
# Backlog
* Switch the `dailyReport` from a static layout to a light-weight template system.
* Change `e.get("D", Details.ALL.name)` to `e["D"]` in `action_dailyReport.py` at release next+1, when backward compatibility with the running Pyruse will not be an issue any more.
* Maybe persist counters; they are currently lost on restart.
* Maybe switch from storing the daily journal in a file, to storing it in a database.
* Eventually make the code more elegant, as I learn more about Python…

View File

@ -35,9 +35,20 @@ When an `action_dailyReport` is used, there are two mandatory parameters:
- this means that any key in the current entry may be referrenced by its name between curly braces;
- and that literal curly braces must be doubled, lest they are read as the start of a template placeholder.
In the `WARN` and `INFO` sections, there is one table row by unique message, and the messages are sorted in alphabetical order.
On each row, the table cells contain first the number of times the message was added to the section, then the message itself, and finally all the dates and times of occurrence.
_Note_: As a consequence, it is useless to put the date and time of occurrence in the message.
Additionally, the `details` parameter may be used to fine-tune the rendering of the times at which events occur (see below).
In the `WARN` and `INFO` sections, there is one table row per unique message, and the messages are sorted in alphabetical order.
On each row, the table cells contain first the number of times the message was added to the section, then the message itself, and finally the dates and times of occurrence:
* If `details` is “`ALL`” or unspecified, each occurrence is mentionned.
* If `details` is “`NONE`”, no occurrence is mentionned.
* If `details` is “`FIRST`”, only the first occurrence is mentionned, prepended by “`From :`”.
* If `details` is “`LAST`”, only the last occurrence is mentionned, prepended by “`Until:`”.
* “`FIRSTLAST`” is a combination of “`FIRST`” and “`LAST`”, although it falls back to “`ALL`” when there are fewer than 2 occurrences.
_Notes_:
* As a consequence, it is useless to put the date and time of occurrence in the message.
* If the same message is added to a section with different levels of details, each level of details gets its own paragraph in the third column.
In the `OTHER` section, the messages are kept in chronological order, and prepended by their date and time of occurrence: “`date+time: message`”. It is thus useless to put the date and time of occurrence in the message.
@ -46,7 +57,7 @@ Here are examples for each of the sections:
```json
{
"action": "action_dailyReport",
"args": { "level": "WARN", "message": "Nextcloud query failed because the buffer-size was too low" }
"args": { "level": "WARN", "message": "Nextcloud query failed because the buffer-size was too low", "details": "NONE" }
}
{
@ -60,11 +71,11 @@ Here are examples for each of the sections:
}
```
I chose the `WARN` level for the first situation because, although there is no immediate security risk associated with this fact, I know that some users will experience a loss of functionality.
I chose the `WARN` level for the first situation because, although there is no immediate security risk associated with this fact, I know that some users will experience a loss of functionality. However, the exact times of occurrence are of no use; this is just a situation I need to be aware of.
I chose the `INFO` level for the second situation because all is well with my server; however, depending on who the remote `xmppServer` is, I might want to add it to a whitelist of allowed unsecured peers.
As for the last example, it is the catch-all action, that will report unexpected log lines.
_Tip_: System administrators should know that the contents of the next daily report can always be read in Pyruses [storage directory](conffile.md), in the file named `action_dailyReport.py.journal`.
In this file, `L` is the section (aka. level: `1` for `WARN`, `2` for `INFO`, and `0` for `OTHER`), `T` is the Unix timestamp, and `M` is the message.
In this file, `L` is the section (aka. level: `1` for `WARN`, `2` for `INFO`, and `0` for `OTHER`), `T` is the Unix timestamp, `M` is the message, and `D` is the level of details regarding the times of occurrence.

View File

@ -1,101 +0,0 @@
# Ban IP addresses after they misbehaved
Linux provides a number of firewall solutions: [iptables](http://www.netfilter.org/), its successor [nftables](http://wiki.nftables.org/), and many iptables frontends like [Shorewall](http://www.shorewall.net/) or RedHats [firewalld](http://www.firewalld.org/).
For Pyruse, **nftables** was chosen, because it is modern and light-weight, and provides interesting features.
Action `action_nftBan` takes three mandatory arguments:
* `nftSetIPv4` and `nftSetIPv6` are the nftables sets where IP addresses will be added. The IP address is considered to be IPv6 if it contains at least one colon (`:`) character, else it is supposed to be IPv4.
* `IP` gives the name of the entry field to read, in order to get the IP address.
Note that nftables sets are kind-of paths.
For example, if your nftables ruleset is such:
```nftables
table ip Inet4 {
set mail_ban {
type ipv4_addr
flags timeout
}
chain FilterIn {
type filter hook input priority 0
policy drop
ip saddr @mail_ban drop
}
}
table ip6 Inet6 {
set mail_ban {
type ipv6_addr
flags timeout
}
chain FilterIn {
type filter hook input priority 0
policy drop
ip6 saddr @mail_ban drop
}
}
```
Then the values for `nftSetIPv4` and `nftSetIPv6` will be respectively “`Inet4 mail_ban`” and “`Inet6 mail_ban`”.
Optionally, a number may be specified with `banSeconds` to limit the time this ban will last.
The nice thing with nftables, is that it handles the timeouts itself: no need to keep track of the active bans and remove them using a Python program; the only reason why bans are recorded in a file, is to be able to restore them on reboot.
Here are examples:
```json
{
"action": "action_nftBan",
"args": { "IP": "thatIP", "banSeconds": 86400, "nftSetIPv4": "Inet4 mail_ban", "nftSetIPv6": "Inet6 mail_ban" }
}
{
"action": "action_nftBan",
"args": { "IP": "thatIP", "nftSetIPv4": "Inet4 sshd_ban", "nftSetIPv6": "Inet6 sshd_ban" }
}
```
## List the currently banned addresses
To see what IP addresses are currently banned, here is the `nft` command:
```bash
$ sudo nft 'list set Inet4 mail_ban'
table ip Inet4 {
set mail_ban {
type ipv4_addr
flags timeout
elements = { 37.49.226.159 timeout 5d expires 3d11h11m58s,
71.6.167.142 timeout 2d20h23m57s expires 6h12m49s,
91.200.12.96 timeout 4d18h22m29s expires 2d4h11m24s,
91.200.12.156 timeout 4d22h59m19s expires 2d8h48m15s,
91.200.12.203 timeout 4d22h53m38s expires 2d8h42m33s,
91.200.12.213 timeout 4d22h54m25s expires 2d8h43m21s,
91.200.12.217 timeout 4d18h1m14s expires 2d3h50m9s,
91.200.12.230 timeout 4d22h54m27s expires 2d8h43m23s,
139.201.42.59 timeout 5d expires 3d17h29m51s,
183.129.89.243 timeout 5d expires 4d9h4m37s }
}
}
```
_Note_: The un-rounded timeouts are post-reboot restored bans.
## Un-ban an IP address
It is bound to happen some day: you will want to un-ban a banned IP address.
Since `action_nftBan` does not keep the current bans in memory, it is enough to remove the ban using the `nft` command:
```bash
$ sudo nft 'delete element Inet4 mail_ban {10.0.0.10}'
```
However, the ban may be restored when restarting Pyruse.
To avoid that, also delete the corresponding record from the `action_nftBan.py.json` file in Pyruses [storage directory](conffile.md).
To go further, you could tweak your configuration, so that your trusted IP addresses never reach `action_nftBan`.

View File

@ -4,7 +4,7 @@ Pyruse comes with a few very simple filters.
## `=`, `≤`, `≥`, `in`
The filters `filter_equals`, `filter_lowerOrEquals`, and `filter_greaterOrEquals` simply check equality on inequality between a given field, given by the parameter `field`, and a constant value, given py the parameter `value`.
The filters `filter_equals`, `filter_lowerOrEquals`, and `filter_greaterOrEquals` simply check equality or inequality between a given field, given by the parameter `field`, and a constant value, given py the parameter `value`.
Both parameters are mandatory.
Here are two examples:
@ -32,6 +32,21 @@ Here is an example:
For any of these filters, the constant values must be of the same type as the typical contents of the chosen field.
## Test if an IP address is part of given networks
Filter `filter_inNetworks` reads an IP address in a field given by the `field` parameter, and a list of networks in the `nets` parameter; each net is written as an IP address, then “`/`”, then an integer network mask.
The filter is passing if the IP address that was read is part of one of the networks configured for the filter.
Here is an example:
```json
{
"filter": "filter_inNetworks",
"args": { "field": "IP", "nets": [ "fd00::/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" ] }
}
```
## Perl-compatible regular expressions (pcre)
Filter `filter_pcre` should only be used on character strings.

View File

@ -1,33 +1,46 @@
# Configuration tips
In contrast with legacy log parsers, Pyruse works with structured [systemd-journal entries](https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html). This allows for better performance, since targeted comparisons become possible.
In contrast with legacy log parsers, Pyruse works with structured [systemd-journal entries](https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html).
This allows for better performance, since targeted comparisons become possible.
The general intent, when writing the configuration file, should be to handle the log entries that appear the most often first, in as few steps as possible. For example, I ran some stats on my server before writing my own configuration file; I got:
The general intent, when writing the configuration file, should be to handle the log entries that appear the most often first, in as few steps as possible.
For example, I ran some stats on my server on the log entries of the past week; I got:
| systemd units | number of journal entries |
| ------------------------------- | -------------------------:|
| `prosody.service` | 518019 |
| `gitea.service` | 329389 |
| `uwsgi@nextcloud.service` | 217342 |
| `session-*.scope` | 89813 |
| `nginx.service` | 80762 |
| `dovecot.service` | 61898 |
| `exim.service` | 60743 |
| `init.scope` | 43021 |
| `nextcloud-maintenance.service` | 20775 |
| `haproxy.service` | 18445 |
| `user@*.service` | 7306 |
| `minidlna.service` | 6032 |
| `loolwsd.service` | 5797 |
| `sshd.service` | 4959 |
| `spamassassin-update.service` | 2383 |
| `systemd-nspawn@*.service` | 1497 |
| `nslcd.service` | 867 |
| `nfs-mountd.service` | 723 |
| `systemd-logind.service` | 696 |
| `nfs-server.service` | 293 |
| `systemd-networkd.service` | 121 |
| misc. units with < 100 entries | |
| SYSLOG_IDENTIFIER | number of journal entries |
| ----------------------- | -------------------------:|
| `uwsgi` (for Nextcloud) | 55930 |
| `gitea` | 38923 |
| `prosody` | 25596 |
| `haproxy` | 21877 |
| `postgres` | 12990 |
| `nginx` | 12808 |
| `dovecot` | 7062 |
| `exim` | 2540 |
| `systemd` | 1997 |
| `su` | 1458 |
| `ownCloud` (Nextcloud) | 1067 |
| `sshd` | 1051 |
| `mandb` | 953 |
| `spamd` | 855 |
| `pyruse` | 615 |
| `kernel` | 420 |
| `msmtp` | 295 |
| `sa-compile` | 255 |
| `ansible-*` | 103 |
| `systemd-logind` | 102 |
| `python` | 78 |
| `rpc.mountd` | 52 |
| `ldapwhoami` | 42 |
| `prosody_auth` | 42 |
| `minidlnad` | 39 |
| `kill` | 28 |
| `sudo` | 26 |
| `loolwsd` | 17 |
| `exportfs` | 15 |
| `dehydrated` | 6 |
| `sa-update` | 5 |
| `nslcd` | 4 |
| `rpc.idmapd` | 1 |
For reference, here is the command that gives these statistics:
@ -37,6 +50,16 @@ $ bash ./extra/examples/get-systemd-stats.sh >~/systemd-units.stats.tsv
One should also remember, that numeric comparison are faster that string comparison, which in turn are faster than regular expression pattern-matching. Further more, some log entries are not worth checking for, because they are too rare: it costs more to grab them with filters (that most log entries will have to pass through), than letting them get caught by the catch-all last execution chain, which typically uses the `action_dailyReport` module.
An efficient way to organize the configuration file is by handling units from the most verbose to the least verbose, and for each unit, filter-out useless entries based on the `PRIORITY` (which is an integer number) whenever it is possible. In short, filtering on the actual message, while not entirely avoidable, is the last-resort operation.
An efficient way to organize the configuration file is by handling Syslog-identifiers from the most verbose to the least verbose, and for each one, filter-out useless entries based on the `PRIORITY` (which is an integer number) whenever it is possible.
In short, filtering on the actual message, while not entirely avoidable, is the last-resort operation.
NOTE: I used to group my log entries (and Pyruse execution chains) by `_SYSTEMD_UNIT`, which seemed logical at the time.
However, for some reason, there is some “leaking” of logs from some units to others; for example, I had Nginx logs appearing in the Exim `_SYSTEMD_UNIT`… The reason probably lies somewhere in inter-process communication, or with the launching of external commands.
Anyway, I found that grouping by `SYSLOG_IDENTIFIER` actually gives better results:
* `SYSLOG_IDENTIFIER` names are shorter than `_SYSTEMD_UNIT` names, hence probably quicker to compare `:-p`
* Several `_SYSTEMD_UNIT` names from generic units (like `unit-name@instance-name`) end up into the same `SYSLOG_IDENTIFIER`, which allows to occasionaly replace `filter_pcre` with `filter_equals`.
* A single program often does several tasks, and `SYSLOG_IDENTIFIER` reflects this diversity, which makes writing rules much easier.
For example, Pyruse sends emails using msmtp; I do not care about `msmtp`s logs, but I do about `pyruse`s. Filtering-out logs from the `msmtp` `SYSLOG_IDENTIFIER` is much easier to do than getting rid of email-related logs from the `pyruse.service` systemd unit.
An [example based on the above statistics](../extra/examples/full_pyruse.json) is available in the `extra/examples/` source directory.

175
doc/dnat.md Normal file
View File

@ -0,0 +1,175 @@
# DNAT-correcting actions
## Introduction
Pyruse provides two actions, namely `action_dnatCapture` and `action_dnatReplace`, that work together towards a single goal: giving to Pyruses filters and actions the illusion that client connections come directly to the examined services, instead of going through a firewall Network Address Translation or a reverse-proxy.
If for example you run a Prosody XMPP server (or anything that does not handle the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt)) behind an HAProxy reverse-proxy, or if all your network connections go through a NATing router in your DMZ, then the following happens: the services report your proxy as being the client, ie. usually `127.0.0.1` (same machine) or your LANs router IP.
Here is a simplified illustration of the network configuration:
```ditaa
/-------------------------------\
| +---------------------+
| Client | ClientIP:ClientPort +---\==\
| +---------------------+ | :
\-------------------------------/ | :
(1)| :(2)
/-------------------------------\ | :
| +--++-------------------+ | :
| | PublicIP:PublicPort +<--/ :
| +-----------------------+ : /---------------------------------\
| Proxy | : +-----------------------+ |
| +-----------------------+ \===>+ | Service |
| | ProxyLanIP:RandomPort +---------->+ ServiceIP:ServicePort | |
| +-----------------------+ (1) +-----------------------+ |
\-------------------------------/ \---------------------------------/
```
The circuit number `(1)` is the real one, which is why the service sees `ProxyLanIP:RandomPort` as the client.
The circuit number `(2)` is what Pyruse will fake, using the fore-mentionned actions.
First some “vocabulary”. In `action_dnatCapture` and `action_dnatReplace`:
* `ClientIP` and `ClientPort` are called `saddr` and `sport` (`s` for **s**ource)
* `ProxyLanIP` and `RandomPort` are called `addr` and `port`
* `ServiceIP` and `ServicePort` are called `daddr` and `dport` (`d` for **d**estination)
Pyruses actions work by storing the link between these 6 values in memory, and later replacing `addr` with `saddr`, and optionaly `port` with `sport`.
## Action `action_dnatCapture`
For Pyruse to be able to capture the wanted values, the proxy software must first be configured to provide them.
### HAProxy configuration
Here is an example configuration that reproduces the default `tcplog` format, simply adding the missing information between square braquets:
```haproxy
global
log /dev/log local0 info
… misc. other options …
defaults
mode tcp
log global
option log-separate-errors
log-format "%ci:%cp [%t] %ft %b[%bi:%bp]/%s %Tw/%Tc/%Tt %B %ts %ac/%fc/%bc/%sc/%rc %sq/%bq"
… misc. other options …
```
The above configuration would produce log lines like this one:
```log
12.34.56.78:54321 [dd/MM/yyyy:HH:mm:ss.…] tls~ xmpp[10.0.0.1:43210]/xmpp …/…/… … -- …/…/…/…/… …/…
```
### nftables configuration
Here is an example rule for nftables on the proxy:
```nftables
tcp dport 22 log prefix "DNAT/ssh: " dnat to 10.0.0.2
```
Having an easily recognizable log prefix helps.
The above would result in a line like this one:
```log
DNAT/ssh: IN=… OUT=… MAC=… SRC=12.34.56.78 DST=10.0.0.1 LEN=… … PROTO=… SPT=43210 DPT=22 WINDOW=… …
```
Besides, Netfilter logs (part of the kernel logs) must be enabled for those log lines to actually appear in the logs.
For example, it [may be required](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=2851940ffee313e0ff12540a8e11a8c54dea9c65) to run this:
```bash
sysctl net.netfilter.nf_log_all_netns=1
```
### Pyruse configuration
Action `action_dnatCapture` must tell Pyruse what fields in the current log entry match the 6 parameters described in the introduction.
If some values are constant and known (or marker values are wished for), but these values are in no available field in the log entry, then those values may be given in the parameters `addrValue`, `portValue`, `daddrValue`, and `dportValue`.
When an information is given by both a field reference and a plain value, the field reference is used first, and the plain value is used as a default value if the referenced field is not found.
The `saddr` parameter is mandatory (else there is no point), and either `addr` or `addrValue` (or both) must be given as well.
In addition to all these parameters, a `keepSeconds` parameter may be given to indicate how many seconds the detected correspondance should be kept in memory (default value is 63).
NOTE: For performance reasons, the `keepSeconds` value is rounded up to the _next_ power of 2 —eg. values 4 to 7 are rounded up to 8—, and the retention countdown only begins at the next occurrence of that power of two in the current time, expressed as a Unix timestamp.
As a consequence, the actual length of time a correspondance is kept in memory, varies between 1× and 4× the length of time given by the parameter, depending on the chosen value, and depending on the current time-of-the-day when the correspondance is found.
Here is an example configuration that would work fine with log lines as produced by nftables (see above):
```json
{ "filter": "filter_pcre",
"args": {
"field": "MESSAGE",
"re": "^DNAT/ssh:.* SRC=([^ ]+) DST=([^ ]+) .* SPT=([^ ]+) DPT=([^ ]+) ",
"save": [ "dnatSaddr", "dnatAddr", "dnatPort", "dnatDport" ]
}
},
{ "action": "action_dnatCapture",
"args": {
"saddr": "dnatSaddr",
"addr": "dnatAddr", "addrValue": "127.0.0.1",
"port": "dnatPort",
"dport": "dnatDport", "dportValue": "22"
}
}
```
## Action `action_dnatReplace`
Action `action_dnatReplace` should be inserted whenever there is a chance that the values stored in a log entrys fields for a client IP address (and possibly the port as well) are those of a proxy instead.
Properties `addr`, `port`, `daddr`, and `dport` are used to match against a correspondance currently held in memory; at least one of these properties must be given.
Each property corresponds to the name of a log entry field in which to read the corresponding value.
Properties `saddrInto` and `sportInto` indicate the log entry fields in which to store the corrected source IP address or port; at least one of those properties must be given.
For example, consider the following (simplified) log entries:
```json
{ '_HOSTNAME': 'dmz',
'SYSLOG_IDENTIFIER': 'kernel',
'MESSAGE': 'DNAT/ssh: … SRC=12.34.56.78 DST=10.0.0.1 … SPT=43210 …'
}
{ '_HOSTNAME': 'sshserv',
'SYSLOG_IDENTIFIER': 'sshd',
'_SYSTEMD_UNIT': 'sshd.service',
'MESSAGE': 'Failed password for ME from 10.0.0.1 port 43210 ssh2'
}
{ '_HOSTNAME': 'dmz',
'SYSLOG_IDENTIFIER': 'sshd',
'_SYSTEMD_UNIT': 'sshd.service',
'MESSAGE': 'Failed password for KeyUser from 87.65.43.21 port 24680 ssh2'
}
```
Assuming the first log entry is correctly handled by `action_dnatCapture`, a good configuration to handle SSH failed logins could be:
```json
{ "filter": "filter_equals",
"args": { "field": "SYSLOG_IDENTIFIER", "value": "sshd" }
},
{ "filter": "filter_pcre",
"args": {
"field": "MESSAGE",
"re": "^Failed password for (.*) from ([^ ]+) port ([^ ]+) ssh2$",
"save": [ "sshUser", "clientIP", "clientPort" ]
},
{ "action": "action_dnatReplace",
"args": { "addr": "clientIP", "port": "clientPort", "saddrInto": "clientIP" }
},
{ "action": "action_email",
"args": { "message": "SSH attack from {clientIP} on {sshUser}@{_HOSTNAME}." }
}
```
As a result, two emails would be sent, with these messages:
```
SSH attack from 12.34.56.78 on ME@sshserv.
SSH attack from 87.65.43.21 on KeyUser@dmz.
```

50
doc/install.md Normal file
View File

@ -0,0 +1,50 @@
# Installation
The software requirements are:
* a modern systemd-based Linux operating system (eg. [Archlinux](https://archlinux.org/)- or [Fedora](https://getfedora.org/)-based distributions);
* python, at least version 3.4 (or [more, depending on the modules](intro_tech.md) being used);
* [python-systemd](https://www.freedesktop.org/software/systemd/python-systemd/journal.html);
* [nftables](http://wiki.nftables.org/) or [ipset](http://ipset.netfilter.org/) _if_ IP address bans are to be managed;
* a sendmail-like program _if_ emails are wanted.
Besides, getting the software requires [Git](http://git-scm.com/), and packaging it requires [python-setuptools](http://pypi.python.org/pypi/setuptools).
## Get and run Pyruse
Getting the software is just a matter of cloning the repository with Git.
It can be run without being installed:
1. Create a [configuration file](conffile.md) in the root directory of the repository (where `doc`, `extra`, `pyruse`, `tests`… reside).
2. Run Pyruse like this at the root directory of the repository:
```bash
$ sudo python3 -c 'from pyruse import main; main.main()'
```
## Run the tests
To run the tests, enter the `tests` subdirectory, and run `python3 main.py` there.
## Install and run Pyruse
To install Pyruse on the system, run these commands as root, in the root directory of the repository:
```bash
# curl -o PKGBUILD 'https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=pyruse'
# . PKGBUILD
# export srcdir="$PWD/.."
# export pkgdir=
# package
# rm -rf build PKGBUILD
# systemctl daemon-reload
```
The `package` line is the one that actually alters the system. Until Pyruse is packaged for your operating system, you may want to change this line to `checkinstall package`. [Checkinstall](https://en.wikipedia.org/wiki/CheckInstall) should be able to turn your Pyruse installation into a native Linux package.
Then, to run Pyruse, start (and enable) `pyruse.service`.
If you use nftables bans, you should also start (and enable) `pyruse-boot@action_nftBan.service`.
Likewise, if you use ipset bans, you should start (and enable) `pyruse-boot@action_ipsetBan.service`.

View File

@ -2,7 +2,7 @@
Everyone knows [Fail2ban](http://www.fail2ban.org/).
This program is excellent at what it does, which is matching patterns in a number of log files, and keeping track of counters in order to launch actions (ban an IP, send an email…) when thresholds are crossed.
[Fail2ban is extremely customizable](http://yalis.fr/cms/index.php/post/2014/11/02/Migrate-from-DenyHosts-to-Fail2ban)… to a point; and to my knowledge it cannot benefit from all the features of modern-day logging with systemd.
[Fail2ban is extremely customizable](http://yalis.fr/cms/index.php/post/2014/11/02/Migrate-from-DenyHosts-to-Fail2ban)… to a point; to my knowledge it cannot benefit from all the features of modern-day logging with systemd.
Then, there is the less-known and aging [Epylog](http://freshmeat.sourceforge.net/projects/epylog/); several programs exist with the same features.
Just like Fail2ban, this program continuously scans the logs to do pattern matching.
@ -38,6 +38,7 @@ The most interesting [filtering or informational entries](https://www.freedeskto
* `PRIORITY`: see [Syslog at Wikipedia](https://en.wikipedia.org/wiki/Syslog#Severity_level) for the definitions
* `SYSLOG_FACILITY`: see [Syslog at Wikipedia](https://en.wikipedia.org/wiki/Syslog#Facility) for the definitions
* `SYSLOG_IDENTIFIER`: short name for the program that produced the log entry (better accuracy than `_SYSTEMD_UNIT`)
* `_HOSTNAME`: short hostname of the machine where the log entry occurred
* `_UID`: user ID of the systemd service that produced the log entry
* `_GID`: group ID of the systemd service that produced the log entry

View File

@ -15,8 +15,9 @@ It should be noted, that modern Python API are used. Thus:
* Python version ≥ 3.1 is required for managing modules (`importlib`);
* Python version ≥ 3.1 is required for loading the configuration (jsons `object_pairs_hook`);
* Python version ≥ 3.2 is required for the daily report and emails (strings `format_map`);
* Python version ≥ 3.5 is required for IP address bans and emails (subprocess `run`);
* Python version ≥ 3.6 is required for sending emails (`headerregistry`, `EmailMessage`).
* Python version ≥ 3.4 is required for the daily report and logging, thus also the log action (`enum`);
* Python version ≥ 3.5 is required for IP address bans and emails, thus also the daily report (subprocess `run`);
* Python version ≥ 3.6 is required for emails, thus also the daily report (`headerregistry`, `EmailMessage`).
In order to be fast, this program avoids dynamic decisions while running.
To this end, a static workflow of filters and actions is built upon start, based on the configuration file.
@ -30,8 +31,10 @@ Pyruse is split into several Python files:
* `workflow.py`: This is where the configuration file get translated into actual execution chains, linked together into a single static workflow.
* `module.py`: Whenever the workflow needs to add a filter or an action to an execution chain, this module finds it in the filesystem.
* `base.py`: All actions and filters inherit from the `Action` and `Filter` classes defined in there; they act as an abstraction layer between the workflow and the modules.
* `counter.py`: This utility class is parent to modules that manage a counter (Python supports multiple-inheritance).
* `email.py`: This utility class is parent to modules that send emails (Python supports multiple-inheritance).
* `ban.py`: This utility class is parent to modules that ban IP addresses using [Netfilter](https://netfilter.org/) (Python supports multiple-inheritance).
* `counter.py`: This utility class is parent to modules that manage a counter.
* `dnat.py`: This file contains utility parent classes for actions that try and restore the actual client IP addresses.
* `email.py`: This utility class is parent to modules that send emails.
All else is actions and filters…
Some are delivered with Pyruse itself; [more can be added](customize.md).

250
doc/logandban.md Normal file
View File

@ -0,0 +1,250 @@
# Log entries creation, and ban of IP addresses
## Log entries
The main purpose of creating new log entries, is to detect recidives in bad behaviour: after an IP address misbehaves, it gets banned, and we generate a log line for that; such log lines get counted, and eventually trigger a harsher, recidive, ban of the same IP address. Several levels of bans can thus be stacked, up to an unlimited ban, if such is wanted.
Action `action_log` takes a mandatory `message` argument, which is a template for the message to be sent.
Optionally, the log level can be changed from the default (which is “INFO”) by setting the `level` parameter; valid values are “EMERG”, “ALERT”, “CRIT”, “ERR”, “WARNING”, “NOTICE”, “INFO”, and “DEBUG” (see [Syslog severity levels](https://en.wikipedia.org/wiki/Syslog#Severity_level) for the definitions).
The `message` parameter is a Python [string format](https://docs.python.org/3/library/string.html#formatstrings).
This means that any key in the current entry may be referrenced by its name between curly braces.
This also means that literal curly braces must be doubled, lest they are read as the start of a template placeholder.
Here are some examples:
```json
{
"action": "action_log", "args": { "message": "Ban from SSH for {thatIP}." }
}
{
"action": "action_log",
"args": {
"level": "NOTICE",
"message": "Recidive ban from SSH for {thatIP}."
}
}
```
## Ban IP addresses after they misbehaved
Linux provides a number of firewall solutions: [iptables](http://www.netfilter.org/), its successor [nftables](http://wiki.nftables.org/), and many iptables frontends like [Shorewall](http://www.shorewall.net/) or RedHats [firewalld](http://www.firewalld.org/).
For Pyruse, **nftables** was initially chosen, because it is modern and light-weight, and provides interesting features. Besides, only nftables is actually tested in real use.
Now, an **ipset**-based [alternative is also available](#iptables-with-ipset).
### nftables
Action `action_nftBan` requires that nftables is installed.
In addition, the binary to run (and parameters if needed) should be set in the configuration file; here is the default value:
```json
"nftBan": {
"nft": [ "/usr/bin/nft" ]
}
```
This action takes three mandatory arguments:
* `nftSetIPv4` and `nftSetIPv6` are the nftables sets where IP addresses will be added. The IP address is considered to be IPv6 if it contains at least one colon (`:`) character, else it is supposed to be IPv4.
* `IP` gives the name of the entry field to read, in order to get the IP address.
Note that nftables sets are kind-of paths.
For example, if your nftables ruleset is such:
```nftables
table ip Inet4 {
set mail_ban {
type ipv4_addr
flags timeout
}
chain FilterIn {
type filter hook input priority 0
policy drop
ip saddr @mail_ban drop
}
}
table ip6 Inet6 {
set mail_ban {
type ipv6_addr
flags timeout
}
chain FilterIn {
type filter hook input priority 0
policy drop
ip6 saddr @mail_ban drop
}
}
```
Then the values for `nftSetIPv4` and `nftSetIPv6` will be respectively “`ip Inet4 mail_ban`” and “`ip6 Inet6 mail_ban`”.
Optionally, a number may be specified with `banSeconds` to limit the time this ban will last.
The nice thing with nftables, is that it handles the timeouts itself: no need to keep track of the active bans and remove them using a Python program; the only reason why bans are recorded in a file, is to be able to restore them on reboot.
Here are examples:
```json
{
"action": "action_nftBan",
"args": { "IP": "thatIP", "banSeconds": 86400, "nftSetIPv4": "ip Inet4 mail_ban", "nftSetIPv6": "ip6 Inet6 mail_ban" }
}
{
"action": "action_nftBan",
"args": { "IP": "thatIP", "nftSetIPv4": "ip Inet4 sshd_ban", "nftSetIPv6": "ip6 Inet6 sshd_ban" }
}
```
#### List the currently banned addresses
To see what IP addresses are currently banned, here is the `nft` command:
```bash
$ sudo nft 'list set ip Inet4 mail_ban'
table ip Inet4 {
set mail_ban {
type ipv4_addr
flags timeout
elements = { 37.49.226.159 timeout 5d expires 3d11h11m58s,
71.6.167.142 timeout 2d20h23m57s expires 6h12m49s,
91.200.12.96 timeout 4d18h22m29s expires 2d4h11m24s,
91.200.12.156 timeout 4d22h59m19s expires 2d8h48m15s,
91.200.12.203 timeout 4d22h53m38s expires 2d8h42m33s,
91.200.12.213 timeout 4d22h54m25s expires 2d8h43m21s,
91.200.12.217 timeout 4d18h1m14s expires 2d3h50m9s,
91.200.12.230 timeout 4d22h54m27s expires 2d8h43m23s,
139.201.42.59 timeout 5d expires 3d17h29m51s,
183.129.89.243 timeout 5d expires 4d9h4m37s }
}
}
```
_Note_: The un-rounded timeouts are post-reboot restored bans.
#### Un-ban an IP address
It is bound to happen some day: you will want to un-ban a banned IP address.
Since `action_nftBan` does not keep the current bans in memory, it is enough to remove the ban using the `nft` command:
```bash
$ sudo nft 'delete element ip Inet4 mail_ban {10.0.0.10}'
```
However, the ban may be restored when restarting Pyruse.
To avoid that, also delete the corresponding record from the `action_nftBan.py.json` file in Pyruses [storage directory](conffile.md).
To go further, you could tweak your configuration, so that your trusted IP addresses never reach `action_nftBan`.
#### Manual ban of an IP address
To add a ban yourself, run a command like this:
```bash
$ sudo nft 'add element ip Inet4 ssh_ban {192.168.1.1 timeout 5d}
```
The `timeout …` part can be omitted to add a permanent ban. The timeout can be any combination of days (`d`), hours (`h`), minutes (`m`), and seconds (`s`), eg. “`3d31m16s`”.
In order to make the ban persistent across reboots, a corresponding record should also be appended to the `action_nftBan.py.json` file in Pyruses [storage directory](conffile.md) (the IP address, Nftables Set, days, hours, minutes, seconds, and actual path to the file should be adapted to your needs):
* either a time-limited ban:
```bash
$ sudo sed -i "\$s/.\$/$(date +', {"IP": "192.168.1.1", "nfSet": "ip Inet4 ssh_ban", "timestamp": %s.000000}' -d 'now +3day +31minute +16second')]/" /var/lib/pyruse/action_nftBan.py.json
```
* or an unlimited ban:
```bash
$ sudo sed -i '$s/.$/, {"IP": "192.168.1.1", "nfSet": "ip Inet4 ssh_ban", "timestamp": 0}]/' /var/lib/pyruse/action_nftBan.py.json
```
### iptables with ipset
Action `action_ipsetBan` requires that ipset and iptables are installed.
In addition, the ipset binary to run (and parameters if needed) should be set in the configuration file; here is the default value:
```json
"ipsetBan": {
"ipset": [ "/usr/bin/ipset", "-exist", "-quiet" ]
}
```
This action works exactly [like `action_nftBan`](#nftables), except parameters `nftSetIPv4` and `nftSetIPv6` are named `ipSetIPv4` and `ipSetIPv6` instead.
The name of the set in the `ipSetIPv4` parameter must have been created before running Pyruse, with:
```bash
$ sudo ipset create SET_NAME hash:ip family inet hashsize 1024 maxelem 65535
```
Likewise, the set given by `ipSetIPv6` must have been created before running Pyruse, with:
```bash
$ sudo ipset create SET_NAME hash:ip family inet6 hashsize 1024 maxelem 65535
```
Here are examples of usage for this action:
```json
{
"action": "action_ipsetBan",
"args": { "IP": "thatIP", "banSeconds": 86400, "ipSetIPv4": "mail_ban4", "ipSetIPv6": "mail_ban6" }
}
{
"action": "action_ipsetBan",
"args": { "IP": "thatIP", "ipSetIPv4": "sshd_ban4", "ipSetIPv6": "sshd_ban6" }
}
```
#### List the currently banned addresses
To see what IP addresses are currently banned, here is the `ipset` command:
```bash
$ sudo ipset list mail_ban4'
```
#### Un-ban an IP address
To remove an IP address from a set, here is the `ipset` command:
```bash
$ sudo ipset del mail_ban4 10.0.0.10'
```
To make the change persistent across reboots, also delete the corresponding record from the `action_ipsetBan.py.json` file in Pyruses [storage directory](conffile.md).
To go further, you could tweak your configuration, so that your trusted IP addresses never reach `action_ipsetBan`.
#### Manual ban of an IP address
To add a ban yourself, run a command like this:
```bash
$ sudo ipset add ssh_ban4 192.168.1.1 timeout 261076
```
The `timeout …` part can be omitted to add a permanent ban; otherwise it is a number of seconds.
In order to make the ban persistent across reboots, a corresponding record should also be appended to the `action_ipsetBan.py.json` file in Pyruses [storage directory](conffile.md) (the IP address, Nftables Set, days, hours, minutes, seconds, and actual path to the file should be adapted to your needs):
* either a time-limited ban:
```bash
$ sudo sed -i "\$s/.\$/$(date +', {"IP": "192.168.1.1", "nfSet": "ssh_ban4", "timestamp": %s.000000}' -d 'now +3day +31minute +16second')]/" /var/lib/pyruse/action_ipsetBan.py.json
```
* or an unlimited ban:
```bash
$ sudo sed -i '$s/.$/, {"IP": "192.168.1.1", "nfSet": "ssh_ban4", "timestamp": 0}]/' /var/lib/pyruse/action_ipsetBan.py.json
```

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,15 @@
#!/bin/bash
# $1 (optional): systemd-nspawn machine name
# $1 (optional): grouping criteria: SYSLOG_IDENTIFIER (default) or _SYSTEMD_UNIT
# $+ (optional): journalctl options (-M machine, -S date…)
CRIT=${1:-SYSLOG_IDENTIFIER}
shift
{
printf 'Units\tTotal\tP7\tP6\tP5\tP4\tP3\tP2\tP1\tP0\n'
sudo journalctl ${1:+-M "$1"} -o json-pretty --output-fields=_SYSTEMD_UNIT,PRIORITY \
printf '%s\tTotal\tP7\tP6\tP5\tP4\tP3\tP2\tP1\tP0\n' "$CRIT"
sudo journalctl "$@" -o json-pretty --output-fields="${CRIT}",PRIORITY \
| tr -d $'"\t, ' \
| awk -F: -vOFS=: '
| awk -F: -vOFS=: -vCRIT="$CRIT" '
/^\{/ {
u = ""
p = -1
@ -13,7 +17,7 @@
$1 == "PRIORITY" {
p = $2
}
$1 == "_SYSTEMD_UNIT" {
$1 == CRIT {
u = gensub(\
"@.*(\\.[^.]*)$",\
"@*\\1",\

View File

@ -0,0 +1,3 @@
[Unit]
Requires=iptables.service
After=iptables.service

View File

@ -5,7 +5,6 @@ Description=Initialization of pyruse module %I
Type=oneshot
ExecStart=/usr/bin/pyruse-boot "%I"
WorkingDirectory=/etc/pyruse
CapabilityBoundingSet=CAP_SYS_CHROOT
NoNewPrivileges=true
PrivateDevices=yes
PrivateTmp=yes

View File

@ -4,7 +4,6 @@ Description=Route systemd-journal logs to filters and actions (ban, report…)
[Service]
ExecStart=/usr/bin/pyruse
WorkingDirectory=/etc/pyruse
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_CHROOT
NoNewPrivileges=true
PrivateDevices=yes
PrivateTmp=yes

View File

@ -6,8 +6,24 @@ import os
import string
from collections import OrderedDict
from datetime import datetime
from enum import Enum, unique
from pyruse import base, config, email
@unique
class Details(Enum):
NONE = [ lambda l: [] ]
FIRST = [ lambda l: ["From : " + str(t) for t in l[:1]] ]
LAST = [ lambda l: ["Until: " + str(t) for t in l[-1:]] ]
FIRSTLAST = [ lambda l: ["From : " + str(l[0]), "Until: " + str(l[-1])] if len(l) > 1 else [str(t) for t in l] ]
ALL = [ lambda l: [str(t) for t in l] ]
def __init__(self, wrapper):
self.fn = wrapper[0]
def toAdoc(self, times):
return " +\n ".join(str(t) for t in self.fn(times))
def toHtml(self, times):
return "<br/>".join(str(t) for t in self.fn(times))
class Action(base.Action):
_storage = config.Config().asMap().get("storage", "/var/lib/pyruse") \
+ "/" + os.path.basename(__file__) + ".journal"
@ -47,6 +63,7 @@ class Action(base.Action):
def __init__(self, args):
super().__init__()
l = args["level"]
if l == "WARN":
self.level = 1
@ -54,6 +71,7 @@ class Action(base.Action):
self.level = 2
else:
self.level = 0
self.template = args["message"]
values = {}
for (_void, name, _void, _void) in string.Formatter().parse(self.template):
@ -61,12 +79,20 @@ class Action(base.Action):
values[name] = None
self.values = values
ts = args.get("details", Details.ALL.name)
for e in Details:
if ts == e.name:
self.details = e
break
else:
self.details = Details.ALL
def act(self, entry):
for (name, _void) in self.values.items():
self.values[name] = entry.get(name, None)
msg = self.template.format_map(self.values)
json.dump(
OrderedDict(L = self.level, T = entry["__REALTIME_TIMESTAMP"].timestamp(), M = msg),
OrderedDict(L = self.level, T = entry["__REALTIME_TIMESTAMP"].timestamp(), M = msg, D = self.details.name),
Action._out
)
Action._out.write(",\n")
@ -81,27 +107,34 @@ class Action(base.Action):
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _toAdoc(self, msg, times):
return "\n|{count:^5d}|{text}\n |{times}\n".format_map(
{"count": len(times), "text": msg, "times": " +\n ".join(str(t) for t in times)}
)
return "\n|{count:^5d}|{text}\n |{times}\n".format_map({
"count": sum(len(t) for (_void, t) in times.items()),
"text": msg,
"times": "\n +\n ".join(d.toAdoc(t) for (d,t) in times.items())
})
def _toHtml(self, msg, times):
return "<tr><td>{count}</td><td>{text}</td><td>{times}</td></tr>\n".format_map(
{"count": len(times), "text": self._encode(msg), "times": "<br/>".join(str(t) for t in times)}
)
return "<tr><td>{count}</td><td>{text}</td><td>{times}</td></tr>\n".format_map({
"count": sum(len(t) for (_void, t) in times.items()),
"text": self._encode(msg),
"times": "<br/><br/>".join(d.toHtml(t) for (d,t) in times.items())
})
def _sendReport(self):
messages = [[], {}, {}]
with open(Action._storage) as journal:
for e in json.load(journal):
if e != {}:
(L, T, M) = (e["L"], datetime.fromtimestamp(e["T"]), e["M"])
(L, T, M, D) = (e["L"], datetime.fromtimestamp(e["T"]), e["M"], e.get("D", Details.ALL.name))
if L == 0:
messages[0].append((T, M))
elif M in messages[L]:
messages[L][M].append(T)
else:
messages[L][M] = [T]
dd = Details[D]
if M not in messages[L]:
messages[L][M] = {}
if dd not in messages[L][M]:
messages[L][M][dd] = []
messages[L][M][dd].append(T)
os.remove(Action._storage)
html = Action._htmDocStart + Action._htmHeadWarn

View File

@ -0,0 +1,18 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from pyruse import base, dnat
class Action(base.Action, dnat.Mapper):
def __init__(self, args):
base.Action.__init__(self)
sa = (args ["saddr"], None )
sp = (args.get("sport", None), None )
a = (args.get("addr", None), args.get("addrValue", None))
p = (args.get("port", None), args.get("portValue", None))
da = (args.get("daddr", None), args.get("daddrValue", None))
dp = (args.get("dport", None), args.get("dportValue", None))
dnat.Mapper.__init__(self, sa, sp, a, p, da, dp, args.get("keepSeconds", 63))
def act(self, entry):
self.map(entry)

View File

@ -0,0 +1,18 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from pyruse import base, dnat
class Action(base.Action, dnat.Matcher):
def __init__(self, args):
base.Action.__init__(self)
sa = args ["saddrInto"]
sp = args.get("sportInto", None)
a = args.get("addr", None)
p = args.get("port", None)
da = args.get("daddr", None)
dp = args.get("dport", None)
dnat.Matcher.__init__(self, a, p, da, dp, sa, sp)
def act(self, entry):
self.replace(entry)

View File

@ -0,0 +1,37 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import os
import subprocess
from pyruse import ban, base, config
class Action(base.Action, ban.NetfilterBan):
_storage = config.Config().asMap().get("storage", "/var/lib/pyruse") \
+ "/" + os.path.basename(__file__) + ".json"
_ipset = config.Config().asMap().get("ipsetBan", {}).get("ipset", ["/usr/bin/ipset", "-exist", "-quiet"])
def __init__(self, args):
base.Action.__init__(self)
ban.NetfilterBan.__init__(self, Action._storage)
if args is None:
return # on-boot configuration
ipv4Set = args["ipSetIPv4"]
ipv6Set = args["ipSetIPv6"]
field = args["IP"]
banSeconds = args.get("banSeconds", None)
self.initSelf(ipv4Set, ipv6Set, field, banSeconds)
def act(self, entry):
ban.NetfilterBan.act(self, entry)
def setBan(self, nfSet, ip, seconds):
cmd = list(Action._ipset)
cmd.extend(["add", nfSet, ip])
if seconds > 0:
cmd.extend(["timeout", str(seconds)])
subprocess.run(cmd)
def cancelBan(self, nfSet, ip):
cmd = list(Action._ipset)
cmd.extend(["del", nfSet, ip])
subprocess.run(cmd)

View File

@ -0,0 +1,22 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import string
from pyruse import base, log
class Action(base.Action):
def __init__(self, args):
super().__init__()
self.level = log.Level[args.get("level", log.Level.INFO.name)]
self.template = args["message"]
values = {}
for (_void, name, _void, _void) in string.Formatter().parse(self.template):
if name:
values[name] = None
self.values = values
def act(self, entry):
for (name, _void) in self.values.items():
self.values[name] = entry.get(name, None)
msg = self.template.format_map(self.values)
log.log(self.level, msg)

View File

@ -1,97 +1,39 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import datetime
import json
import os
import subprocess
from pyruse import base, config
from pyruse import ban, base, config
class Action(base.Action):
class Action(base.Action, ban.NetfilterBan):
_storage = config.Config().asMap().get("storage", "/var/lib/pyruse") \
+ "/" + os.path.basename(__file__) + ".json"
_nft = config.Config().asMap().get("nftBan", {}).get("nft", ["/usr/bin/nft"])
def __init__(self, args):
super().__init__()
base.Action.__init__(self)
ban.NetfilterBan.__init__(self, Action._storage)
if args is None:
return # on-boot configuration
self.ipv4Set = args["nftSetIPv4"]
self.ipv6Set = args["nftSetIPv6"]
self.field = args["IP"]
self.banSeconds = args.get("banSeconds", None)
ipv4Set = args["nftSetIPv4"]
ipv6Set = args["nftSetIPv6"]
field = args["IP"]
banSeconds = args.get("banSeconds", None)
self.initSelf(ipv4Set, ipv6Set, field, banSeconds)
def act(self, entry):
ip = entry[self.field]
nftSet = self.ipv6Set if ":" in ip else self.ipv4Set
newBan = {"IP": ip, "nftSet": nftSet}
ban.NetfilterBan.act(self, entry)
now = datetime.datetime.utcnow()
bans = []
previousTS = None
try:
with open(Action._storage) as dataFile:
for ban in json.load(dataFile):
if ban["timestamp"] > 0 and ban["timestamp"] <= now.timestamp():
continue
elif {k: ban[k] for k in newBan.keys()} == newBan:
# should not happen, since the IP is banned…
previousTS = ban["timestamp"]
else:
bans.append(ban)
except IOError:
pass # new file
if previousTS is not None:
try:
cmd = list(Action._nft)
cmd.append("delete element %s {%s}" % (nftSet, ip))
subprocess.run(cmd)
except Exception:
pass # too late: not a problem
if self.banSeconds:
until = now + datetime.timedelta(seconds = self.banSeconds)
newBan["timestamp"] = until.timestamp()
timeout = self.banSeconds
else:
newBan["timestamp"] = 0
timeout = 0
self._doBan(timeout, ip, nftSet)
bans.append(newBan)
with open(Action._storage, "w") as dataFile:
json.dump(bans, dataFile)
def boot(self):
now = datetime.datetime.utcnow()
bans = []
try:
with open(Action._storage) as dataFile:
for ban in json.load(dataFile):
if ban["timestamp"] == 0:
self._doBan(0, ban["IP"], ban["nftSet"])
bans.append(ban)
elif ban["timestamp"] <= now.timestamp():
continue
else:
until = datetime.datetime.utcfromtimestamp(ban["timestamp"])
timeout = (until - now).total_seconds()
self._doBan(int(timeout), ban["IP"], ban["nftSet"])
bans.append(ban)
except IOError:
pass # no file
with open(Action._storage, "w") as dataFile:
json.dump(bans, dataFile)
def _doBan(self, seconds, ip, nftSet):
if seconds < 0:
return # can happen when the threshold is crossed while computing the duration
def setBan(self, nfSet, ip, seconds):
if seconds == 0:
timeout = ""
else:
timeout = " timeout %ss" % seconds
cmd = list(Action._nft)
cmd.append("add element %s {%s%s}" % (nftSet, ip, timeout))
cmd.append("add element %s {%s%s}" % (nfSet, ip, timeout))
subprocess.run(cmd)
def cancelBan(self, nfSet, ip):
cmd = list(Action._nft)
cmd.append("delete element %s {%s}" % (nfSet, ip))
subprocess.run(cmd)

85
pyruse/ban.py Normal file
View File

@ -0,0 +1,85 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import abc
import datetime
import json
class NetfilterBan(abc.ABC):
def __init__(self, storage):
self.storage = storage
def initSelf(self, ipv4Set, ipv6Set, ipField, banSeconds):
self.ipv4Set = ipv4Set
self.ipv6Set = ipv6Set
self.field = ipField
self.banSeconds = banSeconds
@abc.abstractmethod
def setBan(self, nfSet, ip, seconds):
pass
@abc.abstractmethod
def cancelBan(self, nfSet, ip):
pass
def act(self, entry):
ip = entry[self.field]
nfSet = self.ipv6Set if ":" in ip else self.ipv4Set
newBan = {"IP": ip, "nfSet": nfSet}
now = datetime.datetime.utcnow()
bans = []
previousTS = None
try:
with open(self.storage) as dataFile:
for ban in json.load(dataFile):
if ban["timestamp"] > 0 and ban["timestamp"] <= now.timestamp():
continue
elif {k: ban[k] for k in newBan.keys()} == newBan:
# should not happen, since the IP is banned…
previousTS = ban["timestamp"]
else:
bans.append(ban)
except IOError:
pass # new file
if previousTS is not None:
try:
self.cancelBan(nfSet, ip)
except Exception:
pass # too late: not a problem
if self.banSeconds:
until = now + datetime.timedelta(seconds = self.banSeconds)
newBan["timestamp"] = until.timestamp()
timeout = self.banSeconds
else:
newBan["timestamp"] = 0
timeout = 0
self.setBan(nfSet, ip, timeout)
bans.append(newBan)
with open(self.storage, "w") as dataFile:
json.dump(bans, dataFile)
def boot(self):
now = int(datetime.datetime.utcnow().timestamp())
bans = []
try:
with open(self.storage) as dataFile:
for ban in json.load(dataFile):
if ban["timestamp"] == 0:
self.setBan(ban["nfSet"], ban["IP"], 0)
bans.append(ban)
elif int(ban["timestamp"]) <= now:
continue
else:
timeout = int(ban["timestamp"]) - now
self.setBan(ban["nfSet"], ban["IP"], timeout)
bans.append(ban)
except IOError:
pass # no file
with open(self.storage, "w") as dataFile:
json.dump(bans, dataFile)

View File

@ -35,7 +35,7 @@ class Filter(Step):
nextStep = self.nextStep if self.filter(entry) else self.altStep
except Exception as e:
nextStep = self.altStep
log.error("Error while executing %s: %s." % (type(self), str(e)))
log.error("Error while executing %s (%s): %s." % (type(self), self.stepName, str(e)))
return nextStep
class Action(Step):
@ -52,5 +52,5 @@ class Action(Step):
nextStep = self.nextStep
except Exception as e:
nextStep = None
log.error("Error while executing %s: %s." % (type(self), str(e)))
log.error("Error while executing %s (%s): %s." % (type(self), self.stepName, str(e)))
return nextStep

97
pyruse/dnat.py Normal file
View File

@ -0,0 +1,97 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from datetime import datetime
_mappings = []
def _cleanMappings():
global _mappings
now = int(datetime.today().timestamp())
_mappings = [m for m in _mappings if (now >> m["bits"]) <= m["time"]]
def putMapping(mapping):
global _mappings
_cleanMappings()
_mappings.append(mapping)
def getMappings():
global _mappings
_cleanMappings()
return _mappings
def periodBits(keepSeconds):
seconds, bits = keepSeconds, 0
while seconds:
bits += 1
seconds = seconds >> 1
return bits # number of significant bits in keepSeconds
def valueFor(spec, entry):
return spec[1] if spec[0] is None else entry.get(spec[0], spec[1])
class Mapper():
def __init__(self, saddr, sport, addr, port, daddr, dport, keepSeconds):
for spec in [saddr, addr]:
if spec[0] is None and spec[1] is None:
raise ValueError("Neither field nor value was specified for address")
self.saddr = saddr
self.sport = sport
self.addr = addr
self.port = port
self.daddr = daddr
self.dport = dport
self.keepBits = periodBits(keepSeconds)
def map(self, entry):
saddr = valueFor(self.saddr, entry)
addr = valueFor(self.addr, entry)
if saddr is None or addr is None:
return
sport = valueFor(self.sport, entry)
port = valueFor(self.port, entry)
daddr = valueFor(self.daddr, entry)
dport = valueFor(self.dport, entry)
putMapping(dict(
bits = self.keepBits,
time = 1 + (int(entry["__REALTIME_TIMESTAMP"].timestamp()) >> self.keepBits),
saddr = saddr, sport = sport,
addr = addr, port = port,
daddr = daddr, dport = dport
))
class Matcher():
def __init__(self, addr, port, daddr, dport, saddr, sport):
if addr is None and port is None and daddr is None and dport is None:
raise ValueError("No field was provided on which to do the matching")
if saddr is None and sport is None:
raise ValueError("No field was provided in which to store the translated values")
matchers = []
updaters = []
if addr is not None:
matchers.append((addr, "addr"))
if port is not None:
matchers.append((port, "port"))
if daddr is not None:
matchers.append((daddr, "daddr"))
if dport is not None:
matchers.append((dport, "dport"))
if saddr is not None:
updaters.append((saddr, "saddr"))
if sport is not None:
updaters.append((sport, "sport"))
self.matchers = matchers
self.updaters = updaters
def replace(self, entry):
for field, _void in self.matchers:
if field not in entry:
return
for mapping in getMappings():
for field, mapEntry in self.matchers:
if entry[field] != mapping[mapEntry]:
break
else:
for field, mapEntry in self.updaters:
entry[field] = mapping[mapEntry]
return

View File

@ -0,0 +1,50 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import socket
from functools import reduce
from pyruse import base
class Filter(base.Filter):
ipReducer = lambda bits, byte: bits<<8 | byte
def __init__(self, args):
super().__init__()
self.field = args["field"]
ip4Nets = []
ip6Nets = []
for net in args["nets"]:
if ":" in net:
ip6Nets.append(self._toNetAndMask(socket.AF_INET6, 128, net))
else:
ip4Nets.append(self._toNetAndMask(socket.AF_INET, 32, net))
self.ip4Nets = ip4Nets
self.ip6Nets = ip6Nets
def filter(self, entry):
if self.field not in entry:
return False
ip = entry[self.field]
if ":" in ip:
return self._filter(socket.AF_INET6, ip, self.ip6Nets)
else:
return self._filter(socket.AF_INET, ip, self.ip4Nets)
def _filter(self, family, ip, nets):
for (net, mask) in nets:
numericIP = self._numericIP(family, ip)
if numericIP & mask == net:
return True
return False
def _toNetAndMask(self, family, bits, net):
if "/" in net:
ip, mask = net.split("/")
else:
ip, mask = net, bits
numericMask = ((1<<int(mask))-1)<<(bits-int(mask))
numericIP = self._numericIP(family, ip)
return numericIP & numericMask, numericMask
def _numericIP(self, family, ipString):
return reduce(Filter.ipReducer, socket.inet_pton(family, ipString))

View File

@ -1,28 +1,28 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from enum import Enum, unique
from systemd import journal
EMERG = 0 # System is unusable.
ALERT = 1 # Action must be taken immediately.
CRIT = 2 # Critical conditions, such as hard device errors.
ERR = 3 # Error conditions.
WARNING = 4 # Warning conditions.
NOTICE = 5 # Normal but significant conditions.
INFO = 6 # Informational messages.
DEBUG = 7
@unique
class Level(Enum):
EMERG = 0 # System is unusable.
ALERT = 1 # Action must be taken immediately.
CRIT = 2 # Critical conditions, such as hard device errors.
ERR = 3 # Error conditions.
WARNING = 4 # Warning conditions.
NOTICE = 5 # Normal but significant conditions.
INFO = 6 # Informational messages.
DEBUG = 7
def log(level, string):
journal.send(string, PRIORITY = level)
journal.send(string, PRIORITY = level.value)
def debug(string):
global DEBUG
log(DEBUG, string)
log(Level.DEBUG, string)
def notice(string):
global NOTICE
log(NOTICE, string)
log(Level.NOTICE, string)
def error(string):
global ERR
log(ERR, string)
log(Level.ERR, string)

View File

@ -11,7 +11,7 @@ class Workflow:
firstStep = None
for label in actions:
if not label in seen:
(entryPoint, seen, newDangling) = self._initChain(actions, label, seen)
(entryPoint, seen, newDangling) = self._initChain(actions, label, seen, (label,))
if firstStep is None:
firstStep = entryPoint
elif len(dangling) > 0:
@ -19,11 +19,8 @@ class Workflow:
setter(entryPoint)
dangling = newDangling
self.firstStep = firstStep
loop = self._checkForLoops()
if loop:
raise RecursionError("Loop found in actions: %s\n" % loop)
def _initChain(self, actions, label, seen):
def _initChain(self, actions, label, seen, wholeChain):
dangling = []
previousSetter = None
firstStep = None
@ -38,12 +35,16 @@ class Workflow:
obj.setStepName(label + '[' + str(stepNum) + ']')
if mod.thenRun:
(seen, dangling) = \
self._branchToChain(obj.setNextStep, mod.thenRun, actions, seen, dangling)
self._branchToChain(
obj.setNextStep, mod.thenRun, wholeChain,
actions, seen, dangling)
isThenCalled = True
if mod.isFilter:
if mod.elseRun:
(seen, dangling) = \
self._branchToChain(obj.setAltStep, mod.elseRun, actions, seen, dangling)
self._branchToChain(
obj.setAltStep, mod.elseRun, wholeChain,
actions, seen, dangling)
else:
dangling.append(obj.setAltStep)
isPreviousDangling = mod.isFilter and not isThenCalled
@ -57,35 +58,16 @@ class Workflow:
seen[label] = firstStep if len(dangling) == 0 else None
return (firstStep, seen, dangling)
def _branchToChain(self, parentSetter, branchName, actions, seen, dangling):
if branchName in seen and seen[branchName]:
def _branchToChain(self, parentSetter, branchName, wholeChain, actions, seen, dangling):
if branchName in wholeChain:
raise RecursionError("Loop found in actions: %s\n" % str(wholeChain + (branchName,)))
elif branchName in seen and seen[branchName] is not None:
parentSetter(seen[branchName])
elif branchName in actions:
(entryPoint, seen, newDangling) = \
self._initChain(actions, branchName, seen)
self._initChain(actions, branchName, seen, wholeChain + (branchName,))
parentSetter(entryPoint)
dangling.extend(newDangling)
else:
raise ValueError("Action chain not found: %s\n" % branchName)
return (seen, dangling)
def _checkForLoops(self):
if self.firstStep is None:
return None
branches = [(self.firstStep, [], [])]
while True:
node, branchIds, branch = branches.pop()
idNode = id(node)
if idNode in branchIds:
return branch if self._withDebug else True
branchIds.append(idNode)
if self._withDebug:
branch.append(node.stepName)
if isinstance(node, base.Filter) and node.altStep:
altBranch = list(branch)
altBranchIds = list(branchIds)
branches.append((node.altStep, altBranchIds, altBranch))
if node.nextStep:
branches.append((node.nextStep, branchIds, branch))
if len(branches) == 0:
return None

View File

@ -11,6 +11,10 @@ mail_filename = "email.dump"
wAction = Action({"level": "WARN", "message": "WarnMsg {m}"})
iAction = Action({"level": "INFO", "message": "InfoMsg {m}"})
oAction = Action({"level": "OTHER", "message": "MiscMsg {m}"})
wActFirst = Action({"level": "WARN", "message": "WarnMsg {m}", "details": "FIRST"})
wActLast = Action({"level": "WARN", "message": "WarnMsg {m}", "details": "LAST"})
wActFL = Action({"level": "WARN", "message": "WarnMsg {m}", "details": "FIRSTLAST"})
wActNone = Action({"level": "WARN", "message": "WarnMsg {m}", "details": "NONE"})
def newEntry(m):
return {"__REALTIME_TIMESTAMP": datetime.utcnow(), "m": m}
@ -18,7 +22,6 @@ def newEntry(m):
def whenNewDayThenReport():
if os.path.exists(mail_filename):
os.remove(mail_filename)
Action._hour = 0
oAction.act(newEntry("message1"))
assert not os.path.exists(mail_filename)
Action._hour = 25
@ -26,10 +29,9 @@ def whenNewDayThenReport():
assert os.path.exists(mail_filename)
os.remove(mail_filename)
def whenEmailThenCheckContents():
def whenEmailThenCheck3Sections():
if os.path.exists(mail_filename):
os.remove(mail_filename)
Action._hour = 0
wAction.act(newEntry("messageW"))
iAction.act(newEntry("messageI"))
Action._hour = 25
@ -70,6 +72,73 @@ def whenEmailThenCheckContents():
assert nbMisc == 2
os.remove(mail_filename)
def _compareEmailWithExpected(expected):
assert os.path.exists(mail_filename)
reTime = re.compile(r"\d{4}(?:[- :.]\d{2}){6}\d{4}")
warnSeen = False
nbTimes = 0
nbFirst = 0
nbLast = 0
line = ""
with open(mail_filename, 'rt') as m:
for l in m:
if l != "" and l[-1:] == "=":
line += l[:-1]
continue
elif l == "" and warnSeen:
break
line += l
if "WarnMsg" in line:
warnSeen = True
elif not warnSeen:
line = ""
continue
nbTimes += len(reTime.findall(line))
if "From=C2=A0:" in line:
nbFirst += 1
if "Until:" in line:
nbLast += 1
if "</tr>" in line:
break
line = ""
seen = dict(warn = warnSeen, times = nbTimes, first = nbFirst, last = nbLast)
assert seen == expected, "Expected=" + str(expected) + " ≠ Seen=" + str(seen)
os.remove(mail_filename)
def whenEmailThenCheckTimes(warnAction, expected):
if os.path.exists(mail_filename):
os.remove(mail_filename)
warnAction.act(newEntry("messageW"))
warnAction.act(newEntry("messageW"))
Action._hour = 25
warnAction.act(newEntry("messageW"))
_compareEmailWithExpected(expected)
def whenSeveralDetailsModesThenOnlyOneWarn():
if os.path.exists(mail_filename):
os.remove(mail_filename)
wAction.act(newEntry("messageW"))
wAction.act(newEntry("messageW"))
wAction.act(newEntry("messageW"))
wAction.act(newEntry("messageW"))
wAction.act(newEntry("messageW"))
wActFirst.act(newEntry("messageW"))
wActFirst.act(newEntry("messageW"))
wActFirst.act(newEntry("messageW"))
wActLast.act(newEntry("messageW"))
wActLast.act(newEntry("messageW"))
wActLast.act(newEntry("messageW"))
wActFL.act(newEntry("messageW"))
wActFL.act(newEntry("messageW"))
wActFL.act(newEntry("messageW"))
wActFL.act(newEntry("messageW"))
wActNone.act(newEntry("messageW"))
wActNone.act(newEntry("messageW"))
wActNone.act(newEntry("messageW"))
Action._hour = 25
wActNone.act(newEntry("messageW"))
_compareEmailWithExpected(dict(warn = True, times = 9, first = 2, last = 2))
def whenReportThenNewSetOfMessages():
if os.path.exists(mail_filename):
os.remove(mail_filename)
@ -77,9 +146,14 @@ def whenReportThenNewSetOfMessages():
oAction.act(newEntry("message3"))
assert os.path.exists(mail_filename)
os.remove(mail_filename)
whenEmailThenCheckContents()
whenEmailThenCheck3Sections()
def unitTests():
whenNewDayThenReport()
whenEmailThenCheckContents()
whenEmailThenCheck3Sections()
whenEmailThenCheckTimes(wActFirst, dict(warn = True, times = 1, first = 1, last = 0))
whenEmailThenCheckTimes(wActLast, dict(warn = True, times = 1, first = 0, last = 1))
whenEmailThenCheckTimes(wActFL, dict(warn = True, times = 2, first = 1, last = 1))
whenEmailThenCheckTimes(wActNone, dict(warn = True, times = 0, first = 0, last = 0))
whenSeveralDetailsModesThenOnlyOneWarn()
whenReportThenNewSetOfMessages()

View File

@ -0,0 +1,93 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from datetime import datetime
from pyruse import dnat
from pyruse.actions.action_dnatCapture import Action
def whenNoSaddrThenError():
try:
Action(dict(addr=1))
except Exception:
return
assert False, "An exception should be raised when saddr is absent"
def whenNoAddrNorAddrvalueThenError():
try:
Action(dict(saddr=1))
except Exception:
return
assert False, "An exception should be raised when addr and addrValue are absent"
def whenNoAddrButAddrvalueThenNoError():
Action(dict(saddr=1, addrValue=1))
def whenNoAddrvalueButAddrThenNoError():
Action(dict(saddr=1, addr=1))
def whenNoKeepsecondsThen6bits():
a = Action(dict(saddr=1, addr=1))
assert a.keepBits == 6, "Default keepSeconds (63) should be on 6 bits, not " + str(a.keepBits)
def whenKeepsecondsIs150Then8bits():
a = Action(dict(saddr=1, addr=1, keepSeconds=150))
assert a.keepBits == 8, "150 for keepSeconds should be on 8 bits, not " + str(a.keepBits)
def whenInsufficientEntryThenNoMapping():
dnat._mappings = []
Action({"saddr": "sa", "addrValue": "x"}).act({"__REALTIME_TIMESTAMP": datetime(2018,1,1)})
assert dnat._mappings == [], "Got:\n" + str(dnat._mappings) + "\ninstead of []"
def whenFieldAndOrValueThenCheckMapping(spec, entryWithAddr, entryWithDAddr, expect):
dnat._mappings = []
# specify the Action
spec.update({"saddr": "sa"})
# prepare the entry
entry = {
"__REALTIME_TIMESTAMP": datetime(2018,1,1),
"sa": "vsa", "sp": "vsp"}
if entryWithAddr:
entry.update({"a": "va", "p": "vp"})
if entryWithDAddr:
entry.update({"da": "vda", "dp": "vdp"})
# run
Action(spec).act(entry)
# check the result
expect.update({"bits": 6, "time": 23668144, "saddr": "vsa"})
assert dnat._mappings == [expect], "Got:\n" + str(dnat._mappings) + "\ninstead of:\n" + str([expect])
def unitTests():
whenNoSaddrThenError()
whenNoAddrNorAddrvalueThenError()
whenNoAddrButAddrvalueThenNoError()
whenNoAddrvalueButAddrThenNoError()
whenNoKeepsecondsThen6bits()
whenKeepsecondsIs150Then8bits()
whenInsufficientEntryThenNoMapping()
whenFieldAndOrValueThenCheckMapping({"addr": "a"}, True, True,
{"sport": None, "addr": "va", "port": None, "daddr": None, "dport": None})
whenFieldAndOrValueThenCheckMapping({"addrValue": "x"}, True, True,
{"sport": None, "addr": "x", "port": None, "daddr": None, "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "addrValue": "x"}, True, True,
{"sport": None, "addr": "va", "port": None, "daddr": None, "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "addrValue": "x"}, False, True,
{"sport": None, "addr": "x", "port": None, "daddr": None, "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "daddr": "da"}, True, True,
{"sport": None, "addr": "va", "port": None, "daddr": "vda", "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "daddrValue": "x"}, True, True,
{"sport": None, "addr": "va", "port": None, "daddr": "x", "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "daddr": "da", "daddrValue": "x"}, True, True,
{"sport": None, "addr": "va", "port": None, "daddr": "vda", "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "daddr": "da", "daddrValue": "x"}, True, False,
{"sport": None, "addr": "va", "port": None, "daddr": "x", "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "port": "p"}, True, True,
{"sport": None, "addr": "va", "port": "vp", "daddr": None, "dport": None})
whenFieldAndOrValueThenCheckMapping({"addr": "a", "dport": "dp"}, True, True,
{"sport": None, "addr": "va", "port": None, "daddr": None, "dport": "vdp"})

View File

@ -0,0 +1,74 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from datetime import datetime
from pyruse import dnat
from pyruse.actions.action_dnatReplace import Action
def whenNoSaddrintoThenError():
try:
Action(dict(addr=1))
except Exception:
return
assert False, "An exception should be raised when saddrInto is absent"
def whenNoMatchFieldThenError():
try:
Action(dict(saddrInto=1))
except Exception:
return
assert False, "An exception should be raised when no match-field is present"
def whenSaddrintoAndAtLeastOneMatchFieldThenNoError():
a = Action(dict(saddrInto=1, dport=1))
assert a.matchers == [(1, "dport")], "Got:\n" + str(a.matchers) + "\ninstead of:\n" + str([(1, "dport")])
assert a.updaters == [(1, "saddr")], "Got:\n" + str(a.updaters) + "\ninstead of:\n" + str([(1, "saddr")])
def whenNoMatchingEntryThenNoChange():
dnat._mappings = [{
"bits": 7, "time": 1183407200,
"saddr": "bad", "sport": None,
"addr": "prox", "port": 12345,
"daddr": "serv", "dport": None}]
a = Action(dict(saddrInto="sa", port="sp"))
entryIn = dict(sa = "prox", da = "serv")
entryOut = entryIn.copy()
a.act(entryOut)
assert entryIn == entryOut, "Got:\n" + str(entryOut) + "\ninstead of:\n" + str(entryIn)
def whenNoMatchingValueThenNoChange():
dnat._mappings = [{
"bits": 7, "time": 1183407200,
"saddr": "bad", "sport": None,
"addr": "prox", "port": 12345,
"daddr": "serv", "dport": None}]
a = Action(dict(saddrInto="sa", port="sp"))
entryIn = dict(sa = "prox", sp = 1234, da = "serv")
entryOut = entryIn.copy()
a.act(entryOut)
assert entryIn == entryOut, "Got:\n" + str(entryOut) + "\ninstead of:\n" + str(entryIn)
def whenMatchingEntryThenChange():
dnat._mappings = [{
"bits": 7, "time": 1183407200,
"saddr": "bad", "sport": None,
"addr": "prox", "port": 12345,
"daddr": "serv", "dport": None}]
a = Action(dict(saddrInto="sa", port="sp"))
entryIn = dict(sa = "prox", sp = 12345, da = "serv")
expect = entryIn.copy()
expect.update({"sa": "bad"})
entryOut = entryIn.copy()
a.act(entryOut)
assert expect == entryOut, "Got:\n" + str(entryOut) + "\ninstead of:\n" + str(expect)
def unitTests():
whenNoSaddrintoThenError()
whenNoMatchFieldThenError()
whenSaddrintoAndAtLeastOneMatchFieldThenNoError()
whenNoMatchingEntryThenNoChange()
whenNoMatchingValueThenNoChange()
whenMatchingEntryThenChange()

147
tests/action_ipsetBan.py Normal file
View File

@ -0,0 +1,147 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import json
import os
import time
from pyruse.actions.action_ipsetBan import Action
ipBanCmd = "ipsetBan.cmd"
ipBanState = "action_ipsetBan.py.json"
def _clean():
if os.path.exists(ipBanCmd):
os.remove(ipBanCmd)
if os.path.exists(ipBanState):
os.remove(ipBanState)
def whenBanIPv4ThenAddToIPv4Set():
_clean()
Action({"IP": "ip", "ipSetIPv4": "I4ban", "ipSetIPv6": "I6ban"}).act({"ip": "10.0.0.1"})
assert os.path.exists(ipBanCmd)
assert os.path.exists(ipBanState)
nbLines = 0
with open(ipBanCmd, "rt") as c:
for line in c:
assert line == "add I4ban 10.0.0.1\n", line
nbLines += 1
assert nbLines == 1, nbLines
nbBans = 0
with open(ipBanState) as s:
for ban in json.load(s):
assert ban["IP"] == "10.0.0.1" and ban["nfSet"] == "I4ban", str(ban)
nbBans += 1
assert nbBans == 1, nbBans
_clean()
def whenBanIPv6ThenAddToIPv6Set():
_clean()
Action({"IP": "ip", "ipSetIPv4": "I4ban", "ipSetIPv6": "I6ban"}).act({"ip": "::1"})
assert os.path.exists(ipBanCmd)
assert os.path.exists(ipBanState)
nbLines = 0
with open(ipBanCmd, "rt") as c:
for line in c:
assert line == "add I6ban ::1\n", line
nbLines += 1
assert nbLines == 1, nbLines
nbBans = 0
with open(ipBanState) as s:
for ban in json.load(s):
assert ban["IP"] == "::1" and ban["nfSet"] == "I6ban", str(ban)
nbBans += 1
assert nbBans == 1, nbBans
_clean()
def whenBanTwoIPThenTwoLinesInState():
_clean()
action = Action({"IP": "ip", "ipSetIPv4": "I4ban", "ipSetIPv6": "I6ban"})
action.act({"ip": "10.0.0.1"})
action.act({"ip": "::1"})
action.act({"ip": "10.0.0.1"})
assert os.path.exists(ipBanState)
nbBans = 0
with open(ipBanState) as s:
for ban in json.load(s):
if ban["IP"] == "10.0.0.1":
assert ban["nfSet"] == "I4ban", str(ban)
elif ban["IP"] == "::1":
assert ban["nfSet"] == "I6ban", str(ban)
else:
assert false, str(ban)
nbBans += 1
assert nbBans == 2, nbBans
_clean()
def whenBanAnewThenNoDuplicate():
_clean()
action = Action({"IP": "ip", "ipSetIPv4": "I4ban", "ipSetIPv6": "I6ban"})
action.act({"ip": "10.0.0.1"})
action.act({"ip": "10.0.0.1"})
assert os.path.exists(ipBanCmd)
assert os.path.exists(ipBanState)
lineCount = 0
with open(ipBanCmd, "rt") as c:
for line in c:
lineCount += 1
if lineCount == 1:
assert line == "add I4ban 10.0.0.1\n", line
elif lineCount == 2:
assert line == "del I4ban 10.0.0.1\n", line
elif lineCount == 3:
assert line == "add I4ban 10.0.0.1\n", line
assert lineCount == 3, lineCount
nbBans = 0
with open(ipBanState) as s:
for ban in json.load(s):
if ban["IP"] == "10.0.0.1":
assert ban["nfSet"] == "I4ban", str(ban)
nbBans += 1
assert nbBans == 1, nbBans
_clean()
def whenFinishedBanThenAsIfNotThere():
_clean()
action = Action({"IP": "ip", "ipSetIPv4": "I4ban", "ipSetIPv6": "I6ban", "banSeconds": 1})
action.act({"ip": "10.0.0.1"})
time.sleep(1)
action.act({"ip": "10.0.0.1"})
assert os.path.exists(ipBanCmd)
lineCount = 0
with open(ipBanCmd, "rt") as c:
for line in c:
lineCount += 1
if lineCount == 1:
assert line == "add I4ban 10.0.0.1 timeout 1\n", line
elif lineCount == 2:
assert line == "add I4ban 10.0.0.1 timeout 1\n", line
assert lineCount == 2, lineCount
_clean()
def whenUnfinishedBanThenTimeoutReset():
_clean()
action = Action({"IP": "ip", "ipSetIPv4": "I4ban", "ipSetIPv6": "I6ban", "banSeconds": 2})
action.act({"ip": "10.0.0.1"})
time.sleep(1)
action.act({"ip": "10.0.0.1"})
assert os.path.exists(ipBanCmd)
lineCount = 0
with open(ipBanCmd, "rt") as c:
for line in c:
lineCount += 1
if lineCount == 1:
assert line == "add I4ban 10.0.0.1 timeout 2\n", line
elif lineCount == 2:
assert line == "del I4ban 10.0.0.1\n", line
elif lineCount == 3:
assert line == "add I4ban 10.0.0.1 timeout 2\n", line
assert lineCount == 3, lineCount
_clean()
def unitTests():
whenBanIPv4ThenAddToIPv4Set()
whenBanIPv6ThenAddToIPv6Set()
whenBanTwoIPThenTwoLinesInState()
whenBanAnewThenNoDuplicate()
whenFinishedBanThenAsIfNotThere()
whenUnfinishedBanThenTimeoutReset()

15
tests/action_log.py Normal file
View File

@ -0,0 +1,15 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from unittest.mock import patch
from pyruse import log
from pyruse.actions.action_log import Action
@patch('pyruse.actions.action_log.log.log')
def whenLogThenRightSystemdCall(mockLog):
for level in log.Level:
Action({"level": level.name, "message": "Test: {text}"}).act({"text": "test message"})
mockLog.assert_called_with(level, "Test: test message")
def unitTests():
whenLogThenRightSystemdCall()

View File

@ -17,45 +17,45 @@ def _clean():
def whenBanIPv4ThenAddToIPv4Set():
_clean()
Action({"IP": "ip", "nftSetIPv4": "I4 ban", "nftSetIPv6": "I6 ban"}).act({"ip": "10.0.0.1"})
Action({"IP": "ip", "nftSetIPv4": "ip I4 ban", "nftSetIPv6": "ip6 I6 ban"}).act({"ip": "10.0.0.1"})
assert os.path.exists(nftBanCmd)
assert os.path.exists(nftBanState)
nbLines = 0
with open(nftBanCmd, "rt") as c:
for line in c:
assert line == "add element I4 ban {10.0.0.1}\n", line
assert line == "add element ip I4 ban {10.0.0.1}\n", line
nbLines += 1
assert nbLines == 1, nbLines
nbBans = 0
with open(nftBanState) as s:
for ban in json.load(s):
assert ban["IP"] == "10.0.0.1" and ban["nftSet"] == "I4 ban", str(ban)
assert ban["IP"] == "10.0.0.1" and ban["nfSet"] == "ip I4 ban", str(ban)
nbBans += 1
assert nbBans == 1, nbBans
_clean()
def whenBanIPv6ThenAddToIPv6Set():
_clean()
Action({"IP": "ip", "nftSetIPv4": "I4 ban", "nftSetIPv6": "I6 ban"}).act({"ip": "::1"})
Action({"IP": "ip", "nftSetIPv4": "ip I4 ban", "nftSetIPv6": "ip6 I6 ban"}).act({"ip": "::1"})
assert os.path.exists(nftBanCmd)
assert os.path.exists(nftBanState)
nbLines = 0
with open(nftBanCmd, "rt") as c:
for line in c:
assert line == "add element I6 ban {::1}\n", line
assert line == "add element ip6 I6 ban {::1}\n", line
nbLines += 1
assert nbLines == 1, nbLines
nbBans = 0
with open(nftBanState) as s:
for ban in json.load(s):
assert ban["IP"] == "::1" and ban["nftSet"] == "I6 ban", str(ban)
assert ban["IP"] == "::1" and ban["nfSet"] == "ip6 I6 ban", str(ban)
nbBans += 1
assert nbBans == 1, nbBans
_clean()
def whenBanTwoIPThenTwoLinesInState():
_clean()
action = Action({"IP": "ip", "nftSetIPv4": "I4 ban", "nftSetIPv6": "I6 ban"})
action = Action({"IP": "ip", "nftSetIPv4": "ip I4 ban", "nftSetIPv6": "ip6 I6 ban"})
action.act({"ip": "10.0.0.1"})
action.act({"ip": "::1"})
action.act({"ip": "10.0.0.1"})
@ -64,9 +64,9 @@ def whenBanTwoIPThenTwoLinesInState():
with open(nftBanState) as s:
for ban in json.load(s):
if ban["IP"] == "10.0.0.1":
assert ban["nftSet"] == "I4 ban", str(ban)
assert ban["nfSet"] == "ip I4 ban", str(ban)
elif ban["IP"] == "::1":
assert ban["nftSet"] == "I6 ban", str(ban)
assert ban["nfSet"] == "ip6 I6 ban", str(ban)
else:
assert false, str(ban)
nbBans += 1
@ -75,7 +75,7 @@ def whenBanTwoIPThenTwoLinesInState():
def whenBanAnewThenNoDuplicate():
_clean()
action = Action({"IP": "ip", "nftSetIPv4": "I4 ban", "nftSetIPv6": "I6 ban"})
action = Action({"IP": "ip", "nftSetIPv4": "ip I4 ban", "nftSetIPv6": "ip6 I6 ban"})
action.act({"ip": "10.0.0.1"})
action.act({"ip": "10.0.0.1"})
assert os.path.exists(nftBanCmd)
@ -85,24 +85,24 @@ def whenBanAnewThenNoDuplicate():
for line in c:
lineCount += 1
if lineCount == 1:
assert line == "add element I4 ban {10.0.0.1}\n", line
assert line == "add element ip I4 ban {10.0.0.1}\n", line
elif lineCount == 2:
assert line == "delete element I4 ban {10.0.0.1}\n", line
assert line == "delete element ip I4 ban {10.0.0.1}\n", line
elif lineCount == 3:
assert line == "add element I4 ban {10.0.0.1}\n", line
assert line == "add element ip I4 ban {10.0.0.1}\n", line
assert lineCount == 3, lineCount
nbBans = 0
with open(nftBanState) as s:
for ban in json.load(s):
if ban["IP"] == "10.0.0.1":
assert ban["nftSet"] == "I4 ban", str(ban)
assert ban["nfSet"] == "ip I4 ban", str(ban)
nbBans += 1
assert nbBans == 1, nbBans
_clean()
def whenFinishedBanThenAsIfNotThere():
_clean()
action = Action({"IP": "ip", "nftSetIPv4": "I4 ban", "nftSetIPv6": "I6 ban", "banSeconds": 1})
action = Action({"IP": "ip", "nftSetIPv4": "ip I4 ban", "nftSetIPv6": "ip6 I6 ban", "banSeconds": 1})
action.act({"ip": "10.0.0.1"})
time.sleep(1)
action.act({"ip": "10.0.0.1"})
@ -112,15 +112,15 @@ def whenFinishedBanThenAsIfNotThere():
for line in c:
lineCount += 1
if lineCount == 1:
assert line == "add element I4 ban {10.0.0.1 timeout 1s}\n", line
assert line == "add element ip I4 ban {10.0.0.1 timeout 1s}\n", line
elif lineCount == 2:
assert line == "add element I4 ban {10.0.0.1 timeout 1s}\n", line
assert line == "add element ip I4 ban {10.0.0.1 timeout 1s}\n", line
assert lineCount == 2, lineCount
_clean()
def whenUnfinishedBanThenTimeoutReset():
_clean()
action = Action({"IP": "ip", "nftSetIPv4": "I4 ban", "nftSetIPv6": "I6 ban", "banSeconds": 2})
action = Action({"IP": "ip", "nftSetIPv4": "ip I4 ban", "nftSetIPv6": "ip6 I6 ban", "banSeconds": 2})
action.act({"ip": "10.0.0.1"})
time.sleep(1)
action.act({"ip": "10.0.0.1"})
@ -130,11 +130,11 @@ def whenUnfinishedBanThenTimeoutReset():
for line in c:
lineCount += 1
if lineCount == 1:
assert line == "add element I4 ban {10.0.0.1 timeout 2s}\n", line
assert line == "add element ip I4 ban {10.0.0.1 timeout 2s}\n", line
elif lineCount == 2:
assert line == "delete element I4 ban {10.0.0.1}\n", line
assert line == "delete element ip I4 ban {10.0.0.1}\n", line
elif lineCount == 3:
assert line == "add element I4 ban {10.0.0.1 timeout 2s}\n", line
assert line == "add element ip I4 ban {10.0.0.1 timeout 2s}\n", line
assert lineCount == 3, lineCount
_clean()

View File

@ -0,0 +1,50 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 20172018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
from pyruse.filters.filter_inNetworks import Filter
def whenIp4InNet4ThenTrue():
assert Filter({"field": "ip", "nets": ["34.56.78.90/12"]}).filter({"ip": "34.48.0.1"})
def whenIp4NotInNet4ThenFalse():
assert not Filter({"field": "ip", "nets": ["34.56.78.90/12"]}).filter({"ip": "34.47.255.254"})
def whenIp4ItselfThenTrue():
assert Filter({"field": "ip", "nets": ["12.34.56.78"]}).filter({"ip": "12.34.56.78"})
def whenIp6InNet6ThenTrue():
assert Filter({"field": "ip", "nets": ["2001:db8:1:1a0::/59"]}).filter({"ip": "2001:db8:1:1a0::1"})
def whenIp6NotInNet6ThenFalse():
assert not Filter({"field": "ip", "nets": ["2001:db8:1:1a0::/59"]}).filter({"ip": "2001:db8:1:19f:ffff:ffff:ffff:fffe"})
def whenIp6ItselfThenTrue():
assert Filter({"field": "ip", "nets": ["2001:db8:1:1a0::"]}).filter({"ip": "2001:db8:1:1a0::"})
def whenNumericIp6InNet4ThenFalse():
assert not Filter({"field": "ip", "nets": ["34.56.78.90/12"]}).filter({"ip": "::2230:1"})
def whenNumericIp4InNet6ThenFalse():
assert not Filter({"field": "ip", "nets": ["::2230:1/108"]}).filter({"ip": "34.48.0.1"})
def whenIpInOneNetworkThenTrue():
assert Filter({"field": "ip", "nets": ["::2230:1/108", "10.0.0.0/8", "34.56.78.90/12", "2001:db8:1:1a0::/59"]}).filter({"ip": "34.48.0.1"})
def whenNoIpThenFalse():
assert not Filter({"field": "ip", "nets": ["::2230:1/108", "10.0.0.0/8"]}).filter({"no_ip": "Hi!"})
def whenNoNetworkThenFalse():
assert not Filter({"field": "ip", "nets": []}).filter({"ip": "34.48.0.1"})
def unitTests():
whenIp4InNet4ThenTrue()
whenIp4NotInNet4ThenFalse()
whenIp4ItselfThenTrue()
whenIp6InNet6ThenTrue()
whenIp6NotInNet6ThenFalse()
whenIp6ItselfThenTrue()
whenNumericIp6InNet4ThenFalse()
whenNumericIp4InNet6ThenFalse()
whenIpInOneNetworkThenTrue()
whenNoIpThenFalse()
whenNoNetworkThenFalse()

View File

@ -19,12 +19,13 @@ def main():
conf = config.Config(os.curdir)
# Unit tests
import filter_equals, filter_greaterOrEquals, filter_in, filter_lowerOrEquals, filter_pcre, filter_pcreAny, filter_userExists
import action_counterRaise, action_counterReset, action_dailyReport, action_email, action_nftBan
import filter_equals, filter_greaterOrEquals, filter_in, filter_inNetworks, filter_lowerOrEquals, filter_pcre, filter_pcreAny, filter_userExists
import action_counterRaise, action_counterReset, action_dailyReport, action_dnatCapture, action_dnatReplace, action_email, action_ipsetBan, action_log, action_nftBan
filter_equals.unitTests()
filter_greaterOrEquals.unitTests()
filter_in.unitTests()
filter_inNetworks.unitTests()
filter_lowerOrEquals.unitTests()
filter_pcre.unitTests()
filter_pcreAny.unitTests()
@ -32,7 +33,11 @@ def main():
action_counterRaise.unitTests()
action_counterReset.unitTests()
action_dailyReport.unitTests()
action_dnatCapture.unitTests()
action_dnatReplace.unitTests()
action_email.unitTests()
action_ipsetBan.unitTests()
action_log.unitTests()
action_nftBan.unitTests()
# Integration test

View File

@ -1 +1 @@
add element I4 bans {1.2.3.4 timeout 100s}
add element ip I4 bans {1.2.3.4 timeout 100s}

View File

@ -58,7 +58,7 @@
},
{
"action": "action_nftBan",
"args": { "IP": "ip", "banSeconds": 100, "nftSetIPv4": "I4 bans", "nftSetIPv6": "I6 bans" },
"args": { "IP": "ip", "banSeconds": 100, "nftSetIPv4": "ip I4 bans", "nftSetIPv6": "ip6 I6 bans" },
"then": "… finalize after last action"
}
],
@ -87,5 +87,8 @@
"nftBan": {
"nft": [ "/bin/sh", "-c", "echo \"$0\" >>\"nftBan.cmd\"" ]
},
"ipsetBan": {
"ipset": [ "/bin/sh", "-c", "echo \"$0 $*\" >>\"ipsetBan.cmd\"" ]
},
"storage": "."
}