Compare commits

...

8 Commits

80 changed files with 4017 additions and 2339 deletions
Split View
  1. +8
    -0
      .editorconfig
  2. +10
    -1
      .gitignore
  3. +18
    -0
      Cargo.toml
  4. +0
    -33
      extra/setup/setup.py
  5. +0
    -22
      pyruse/actions/action_counterRaise.py
  6. +0
    -22
      pyruse/actions/action_counterReset.py
  7. +0
    -177
      pyruse/actions/action_dailyReport.py
  8. +0
    -18
      pyruse/actions/action_dnatCapture.py
  9. +0
    -18
      pyruse/actions/action_dnatReplace.py
  10. +0
    -22
      pyruse/actions/action_email.py
  11. +0
    -37
      pyruse/actions/action_ipsetBan.py
  12. +0
    -22
      pyruse/actions/action_log.py
  13. +0
    -39
      pyruse/actions/action_nftBan.py
  14. +0
    -11
      pyruse/actions/action_noop.py
  15. +0
    -85
      pyruse/ban.py
  16. +0
    -56
      pyruse/base.py
  17. +0
    -34
      pyruse/config.py
  18. +0
    -106
      pyruse/counter.py
  19. +0
    -97
      pyruse/dnat.py
  20. +0
    -37
      pyruse/email.py
  21. +0
    -13
      pyruse/filters/filter_equals.py
  22. +0
    -13
      pyruse/filters/filter_greaterOrEquals.py
  23. +0
    -13
      pyruse/filters/filter_in.py
  24. +0
    -50
      pyruse/filters/filter_inNetworks.py
  25. +0
    -13
      pyruse/filters/filter_lowerOrEquals.py
  26. +0
    -21
      pyruse/filters/filter_pcre.py
  27. +0
    -23
      pyruse/filters/filter_pcreAny.py
  28. +0
    -17
      pyruse/filters/filter_userExists.py
  29. +0
    -28
      pyruse/log.py
  30. +0
    -53
      pyruse/main.py
  31. +0
    -41
      pyruse/module.py
  32. +0
    -73
      pyruse/workflow.py
  33. +112
    -0
      src/domain/action/counter_raise.rs
  34. +103
    -0
      src/domain/action/counter_reset.rs
  35. +320
    -0
      src/domain/action/dnat_capture.rs
  36. +222
    -0
      src/domain/action/dnat_replace.rs
  37. +39
    -0
      src/domain/action/email.rs
  38. +162
    -0
      src/domain/action/log.rs
  39. +77
    -0
      src/domain/action/mod.rs
  40. +38
    -0
      src/domain/action/noop.rs
  41. +21
    -0
      src/domain/config.rs
  42. +266
    -0
      src/domain/counter.rs
  43. +17
    -0
      src/domain/dnat.rs
  44. +452
    -0
      src/domain/email.rs
  45. +178
    -0
      src/domain/filter/equals.rs
  46. +2
    -0
      src/domain/filter/mod.rs
  47. +17
    -0
      src/domain/log.rs
  48. +73
    -0
      src/domain/mod.rs
  49. +103
    -0
      src/domain/module.rs
  50. +85
    -0
      src/domain/template.rs
  51. +109
    -0
      src/domain/test_util.rs
  52. +312
    -0
      src/domain/workflow.rs
  53. +80
    -0
      src/infra/config/file.rs
  54. +343
    -0
      src/infra/config/mod.rs
  55. +180
    -0
      src/infra/counter.rs
  56. +29
    -0
      src/infra/dnat.rs
  57. +389
    -0
      src/infra/email.rs
  58. +157
    -0
      src/infra/log.rs
  59. +5
    -0
      src/infra/mod.rs
  60. +90
    -0
      src/main.rs
  61. +0
    -0
      src/service/mod.rs
  62. +0
    -58
      tests/action_counterRaise.py
  63. +0
    -57
      tests/action_counterReset.py
  64. +0
    -159
      tests/action_dailyReport.py
  65. +0
    -93
      tests/action_dnatCapture.py
  66. +0
    -74
      tests/action_dnatReplace.py
  67. +0
    -77
      tests/action_email.py
  68. +0
    -147
      tests/action_ipsetBan.py
  69. +0
    -15
      tests/action_log.py
  70. +0
    -147
      tests/action_nftBan.py
  71. +0
    -22
      tests/filter_equals.py
  72. +0
    -26
      tests/filter_greaterOrEquals.py
  73. +0
    -22
      tests/filter_in.py
  74. +0
    -50
      tests/filter_inNetworks.py
  75. +0
    -26
      tests/filter_lowerOrEquals.py
  76. +0
    -26
      tests/filter_pcre.py
  77. +0
    -20
      tests/filter_pcreAny.py
  78. +0
    -14
      tests/filter_userExists.py
  79. +0
    -97
      tests/main.py
  80. +0
    -14
      tests/pyruse/actions/action_testLog.py

+ 8
- 0
.editorconfig View File

@ -0,0 +1,8 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true

+ 10
- 1
.gitignore View File

@ -1 +1,10 @@
**/__pycache__/
# Generated by Cargo
# will have compiled files and executables
/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk

+ 18
- 0
Cargo.toml View File

@ -0,0 +1,18 @@
[package]
name = "pyruse"
version = "2.1.0"
authors = ["Y. <theYinYeti@yalis.fr>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = "0.4"
indexmap = { version = "1.3", features = ["serde-1"] }
lettre_email = "0.9"
quoted_printable = "0.4"
regex = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.8"
systemd = "0.8"

+ 0
- 33
extra/setup/setup.py View File

@ -1,33 +0,0 @@
from distutils.core import setup
setup(
name='pyruse',
version='1.0',
license='GPL-3',
description='Route systemd-journal logs to filters and actions (ban, report…)',
long_description='''
================
Python peruser of systemd-journal
================
This program is intended to be used as a lightweight replacement for both epylog and fail2ban.
The wanted features are these:
* Peruse all log entries from systemds journal, and only those (ie: no log files).
* Passively wait on new entries; no active polling.
* Filter-out uninteresting log lines according to the settings.
* Act on matches in the journal, with some pre-defined actions.
* Create a daily report with 2 parts:
- events of interest (according to the settings),
- and other non-filtered-out log entries.
* Send an immediate email when something important happens (according to the settings).
''',
author='Yves G.',
author_email='theYinYeti@yalis.fr',
maintainer='Yves G.',
maintainer_email='theYinYeti@yalis.fr',
url='https://yalis.fr/git/yves/pyruse',
download_url='https://yalis.fr/git/yves/pyruse',
packages=['pyruse', 'pyruse.actions', 'pyruse.filters'],
)

+ 0
- 22
pyruse/actions/action_counterRaise.py View File

@ -1,22 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import datetime
from pyruse import base, counter
class Action(base.Action, counter.Counter):
def __init__(self, args):
base.Action.__init__(self)
counter.Counter.__init__(self, args["counter"])
self.keyName = args["for"]
self.save = args.get("save", None)
keepSeconds = args.get("keepSeconds", None)
if keepSeconds:
self.keepSeconds = datetime.timedelta(seconds = keepSeconds)
else:
self.keepSeconds = None
def act(self, entry):
count = self.augment(entry[self.keyName], self.keepSeconds)
if self.save:
entry[self.save] = count

+ 0
- 22
pyruse/actions/action_counterReset.py View File

@ -1,22 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import datetime
from pyruse import base, counter
class Action(base.Action, counter.Counter):
def __init__(self, args):
base.Action.__init__(self)
counter.Counter.__init__(self, args["counter"])
self.keyName = args["for"]
self.save = args.get("save", None)
graceSeconds = args.get("graceSeconds", None)
if graceSeconds:
self.graceSeconds = datetime.timedelta(seconds = graceSeconds)
else:
self.graceSeconds = None
def act(self, entry):
self.reset(entry[self.keyName], self.graceSeconds)
if self.save:
entry[self.save] = 0

+ 0
- 177
pyruse/actions/action_dailyReport.py View File

@ -1,177 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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 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"
_out = None
_hour = 0
_txtDocStart = '= Pyruse Report\n\n'
_txtHeadWarn = '== WARNING Messages\n\n'
_txtHeadInfo = '\n== Information Messages\n\n'
_txtHeadOther = '\n== Other log events\n\n'
_txtTableDelim = '|===============================================================================\n'
_txtTableHeader = '|Count|Message |Date+time for each occurrence\n'
_txtPreDelim = '----------\n'
_htmDocStart = '<html>\n<head><meta charset="utf-8"/><style type="text/css">td{vertical-align: top}</style></head>\n<body>\n<h1>Pyruse Report</h1>\n'
_htmDocStop = '</body></html>'
_htmHeadWarn = '<h2>WARNING Messages</h2>\n'
_htmHeadInfo = '<h2>Information Messages</h2>\n'
_htmHeadOther = '<h2>Other log events</h2>\n'
_htmTableStart = '<table border="1">\n<tr><th>Count</th><th>Message</th><th>Date+time for each occurrence</th></tr>\n'
_htmTableStop = '</table>\n'
_htmPreStart = '<pre>'
_htmPreStop = '</pre>\n'
def _closeJournal():
Action._out.write("{}]")
Action._out.close()
Action._out = None
def _openJournal():
if Action._out is None:
if os.path.exists(Action._storage):
Action._out = open(Action._storage, "a", 1)
else:
Action._out = open(Action._storage, "w", 1)
Action._out.write("[\n")
def __init__(self, args):
super().__init__()
l = args["level"]
if l == "WARN":
self.level = 1
elif l == "INFO":
self.level = 2
else:
self.level = 0
self.template = args["message"]
values = {}
for (_void, name, _void, _void) in string.Formatter().parse(self.template):
if name:
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, D = self.details.name),
Action._out
)
Action._out.write(",\n")
thisHour = datetime.today().hour
if thisHour < Action._hour:
Action._closeJournal()
self._sendReport()
Action._openJournal()
Action._hour = thisHour
def _encode(self, text):
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _toAdoc(self, msg, 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": 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, D) = (e["L"], datetime.fromtimestamp(e["T"]), e["M"], e.get("D", Details.ALL.name))
if L == 0:
messages[0].append((T, M))
else:
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
text = Action._txtDocStart + Action._txtHeadWarn
text += Action._txtTableDelim + Action._txtTableHeader
html += Action._htmTableStart
for (msg, times) in sorted(messages[1].items(), key = lambda i: i[0]):
text += self._toAdoc(msg, times)
html += self._toHtml(msg, times)
text += Action._txtTableDelim
html += Action._htmTableStop
text += Action._txtHeadInfo
html += Action._htmHeadInfo
text += Action._txtTableDelim + Action._txtTableHeader
html += Action._htmTableStart
for (msg, times) in sorted(messages[2].items(), key = lambda i: i[0]):
text += self._toAdoc(msg, times)
html += self._toHtml(msg, times)
text += Action._txtTableDelim
html += Action._htmTableStop
text += Action._txtHeadOther
html += Action._htmHeadOther
text += Action._txtPreDelim
html += Action._htmPreStart
for (time, msg) in messages[0]:
m = '%s: %s\n' % (time, msg)
text += m
html += self._encode(m)
text += Action._txtPreDelim
html += Action._htmPreStop
html += Action._htmDocStop
email.Mail(text, html).send()
Action._openJournal()

+ 0
- 18
pyruse/actions/action_dnatCapture.py View File

@ -1,18 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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)

+ 0
- 18
pyruse/actions/action_dnatReplace.py View File

@ -1,18 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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)

+ 0
- 22
pyruse/actions/action_email.py View File

@ -1,22 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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, email
class Action(base.Action):
def __init__(self, args):
super().__init__()
self.subject = args.get("subject", "Pyruse Notification")
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)
email.Mail(msg).setSubject(self.subject).send()

+ 0
- 37
pyruse/actions/action_ipsetBan.py View File

@ -1,37 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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)

+ 0
- 22
pyruse/actions/action_log.py View File

@ -1,22 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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)

+ 0
- 39
pyruse/actions/action_nftBan.py View File

@ -1,39 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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"
_nft = config.Config().asMap().get("nftBan", {}).get("nft", ["/usr/bin/nft"])
def __init__(self, args):
base.Action.__init__(self)
ban.NetfilterBan.__init__(self, Action._storage)
if args is None:
return # on-boot configuration
ipv4Set = args["nftSetIPv4"]
ipv6Set = args["nftSetIPv6"]
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):
if seconds == 0:
timeout = ""
else:
timeout = " timeout %ss" % seconds
cmd = list(Action._nft)
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)

+ 0
- 11
pyruse/actions/action_noop.py View File

@ -1,11 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
class Action(base.Action):
def __init__(self, args):
super().__init__()
def act(self, entry):
pass

+ 0
- 85
pyruse/ban.py View File

@ -1,85 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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)

+ 0
- 56
pyruse/base.py View File

@ -1,56 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import abc
from pyruse import log
class Step(abc.ABC):
def __init__(self):
self.nextStep = None
@abc.abstractmethod
def run(self, entry):
pass
def setNextStep(self, obj):
self.nextStep = obj
def setStepName(self, name):
self.stepName = name
class Filter(Step):
def __init__(self):
super().__init__()
self.altStep = None
def setAltStep(self, obj):
self.altStep = obj
@abc.abstractmethod
def filter(self, entry):
pass
def run(self, entry):
try:
nextStep = self.nextStep if self.filter(entry) else self.altStep
except Exception as e:
nextStep = self.altStep
log.error("Error while executing %s (%s): %s." % (type(self), self.stepName, str(e)))
return nextStep
class Action(Step):
def __init__(self):
super().__init__()
@abc.abstractmethod
def act(self, entry):
pass
def run(self, entry):
try:
self.act(entry)
nextStep = self.nextStep
except Exception as e:
nextStep = None
log.error("Error while executing %s (%s): %s." % (type(self), self.stepName, str(e)))
return nextStep

+ 0
- 34
pyruse/config.py View File

@ -1,34 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
from collections import OrderedDict
from pyruse import log
class Config:
CONF_NAME = "pyruse.json"
_paths = None
# __main__ must be the first to create a Config object, then paths are remembered
def __init__(self, paths = None):
if paths is None:
paths = Config._paths
Config._paths = paths
for p in paths:
confpath = os.path.join(p, Config.CONF_NAME)
try:
with open(confpath) as conffile:
conf = json.load(conffile, object_pairs_hook = OrderedDict)
self.conf = conf
break
except IOError:
log.debug("IOError while opening %s\n" % confpath)
except json.JSONDecodeError:
log.debug("JSONDecodeError while opening %s\n" % confpath)
else:
raise FileNotFoundError("File `%s` not found in either of %s." \
% (Config.CONF_NAME, str(paths)))
def asMap(self):
return self.conf

+ 0
- 106
pyruse/counter.py View File

@ -1,106 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import datetime
class _GraceAndTicks():
def __init__(self):
self.grace = None
self.ticks = []
class _CounterData():
def __init__(self):
self._keyVals = {}
def clean(self, refDT):
for k in list(self._keyVals.keys()):
v = self._keyVals[k]
if v.grace and v.grace <= refDT:
v.grace = None
if v.grace is None:
# None values (∞) are at the end of the list
for i in range(0, len(v.ticks)):
if v.ticks[i] and v.ticks[i] <= refDT:
continue
v.ticks = v.ticks[i:]
break
else:
del self._keyVals[k]
def graceActive(self, counterKey, refDT):
self.clean(refDT)
return counterKey in self._keyVals and self._keyVals[counterKey].grace
def augment(self, counterKey, refDT, until):
self.clean(refDT)
if counterKey in self._keyVals:
v = self._keyVals[counterKey]
if v.grace:
return 0
else:
v = _GraceAndTicks()
self._keyVals[counterKey] = v
l = len(v.ticks)
# chances are that until is greater than the last item
for i in range(l, 0, -1):
if until is None or (v.ticks[i - 1] and v.ticks[i - 1] < until):
v.ticks.insert(i, until)
break
else:
v.ticks.insert(0, until)
return l + 1
def lower(self, counterKey, refDT):
self.clean(refDT)
v = self._keyVals.get(counterKey, None)
if v is None or v.grace:
return 0
l = len(v.ticks)
if l == 1:
del self._keyVals[counterKey]
return 0
v.ticks.pop()
return l - 1
def reset(self, counterKey, refDT, graceUntil):
self.clean(refDT)
if graceUntil:
v = _GraceAndTicks()
v.grace = graceUntil
self._keyVals[counterKey] = v
elif counterKey in self._keyVals:
del self._keyVals[counterKey]
class Counter():
_counters = {}
def __init__(self, counter):
if counter not in Counter._counters:
Counter._counters[counter] = _CounterData()
self.counter = Counter._counters[counter]
def augment(self, counterKey, duration = None):
now = datetime.datetime.utcnow()
if self.counter.graceActive(counterKey, now):
return 0
else:
return self.counter.augment(counterKey, now, now + duration if duration else None)
def lower(self, counterKey):
now = datetime.datetime.utcnow()
if self.counter.graceActive(counterKey, now):
return 0
else:
return self.counter.lower(counterKey, now)
def reset(self, counterKey, graceDuration = None):
now = datetime.datetime.utcnow()
self.counter.reset(
counterKey, now,
now + graceDuration if graceDuration else None
)

+ 0
- 97
pyruse/dnat.py View File

@ -1,97 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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

+ 0
- 37
pyruse/email.py View File

@ -1,37 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import subprocess
from email.headerregistry import Address
from email.message import EmailMessage
from pyruse import config
class Mail:
_mailConf = config.Config().asMap().get("email", {})
def __init__(self, text, html = None):
self.text = text
self.html = html
self.mailSubject = Mail._mailConf.get("subject", "Pyruse Report")
self.mailFrom = Mail._mailConf.get("from", "pyruse")
self.mailTo = Mail._mailConf.get("to", ["hostmaster"])
def setSubject(self, subject):
if subject:
self.mailSubject = subject
return self
def send(self):
message = EmailMessage()
message["Subject"] = self.mailSubject
message["From"] = Address(addr_spec = self.mailFrom)
message["To"] = (Address(addr_spec = a) for a in self.mailTo)
message.set_content(self.text, cte = "quoted-printable")
if self.html:
message.add_alternative(self.html, subtype = "html", cte = "quoted-printable")
subprocess.run(
Mail._mailConf.get("sendmail", ["/usr/bin/sendmail", "-t"]),
input = message.as_bytes()
)

+ 0
- 13
pyruse/filters/filter_equals.py View File

@ -1,13 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
self.value = args["value"]
def filter(self, entry):
return entry[self.field] == self.value if self.field in entry else False

+ 0
- 13
pyruse/filters/filter_greaterOrEquals.py View File

@ -1,13 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
self.value = args["value"]
def filter(self, entry):
return entry[self.field] >= self.value if self.field in entry else False

+ 0
- 13
pyruse/filters/filter_in.py View File

@ -1,13 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
self.values = args["values"]
def filter(self, entry):
return entry.get(self.field, None) in self.values

+ 0
- 50
pyruse/filters/filter_inNetworks.py View File

@ -1,50 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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))

+ 0
- 13
pyruse/filters/filter_lowerOrEquals.py View File

@ -1,13 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
self.value = args["value"]
def filter(self, entry):
return entry[self.field] <= self.value if self.field in entry else False

+ 0
- 21
pyruse/filters/filter_pcre.py View File

@ -1,21 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import re
from pyruse import base
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
self.re = re.compile(args["re"])
self.save = args.get("save", [])
def filter(self, entry):
match = self.re.search(entry.get(self.field, ""))
if match:
for group, name in enumerate(self.save, start = 1):
entry[name] = match.group(group)
for name, value in match.groupdict().items():
entry[name] = value
return match

+ 0
- 23
pyruse/filters/filter_pcreAny.py View File

@ -1,23 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import re
from pyruse import base
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
reList = []
for item in args["re"]:
reList.append(re.compile(item))
self.reList = reList
def filter(self, entry):
for item in self.reList:
match = item.search(entry.get(self.field, ""))
if match:
for name, value in match.groupdict().items():
entry[name] = value
return True
return False

+ 0
- 17
pyruse/filters/filter_userExists.py View File

@ -1,17 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import pwd
from pyruse import base
class Filter(base.Filter):
def __init__(self, args):
super().__init__()
self.field = args["field"]
def filter(self, entry):
try:
pwd.getpwnam(entry.get(self.field, ""))
return True
except KeyError:
return False

+ 0
- 28
pyruse/log.py View File

@ -1,28 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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
@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.value)
def debug(string):
log(Level.DEBUG, string)
def notice(string):
log(Level.NOTICE, string)
def error(string):
log(Level.ERR, string)

+ 0
- 53
pyruse/main.py View File

@ -1,53 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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 sys
from systemd import journal
from pyruse import config, module, workflow
PYRUSE_ENVVAR = "PYRUSE_EXTRA"
PYRUSE_PATHS = []
def _setPyrusePaths():
global PYRUSE_ENVVAR, PYRUSE_PATHS
for p in "/etc/pyruse", os.environ.get(PYRUSE_ENVVAR):
if p and os.path.isdir(p):
PYRUSE_PATHS.insert(0, p)
sys.path.insert(1, p)
PYRUSE_PATHS.insert(0, os.curdir)
def _doForEachJournalEntry(workflow):
enc8b = config.Config().asMap().get("8bit-message-encoding", "iso-8859-1")
j = journal.Reader(journal.SYSTEM_ONLY)
j.seek_tail()
j.get_previous()
while True:
event = j.wait(None)
if event == journal.APPEND:
for entry in j:
m = entry['MESSAGE']
if not isinstance(m, str):
entry['MESSAGE'] = m.decode(enc8b)
step = workflow.firstStep
while step is not None:
step = step.run(entry)
def boot(modName):
_setPyrusePaths()
conf = config.Config(PYRUSE_PATHS)
if "action_" in modName:
module.get({"action": modName, "args": None}).module.boot()
elif "filter_" in modName:
module.get({"filter": modName, "args": None}).module.boot()
else:
raise ValueError("Neither “action_” nor “filter_” found in the module name; the `boot` feature cannot work for %s\n" % modName)
def main():
_setPyrusePaths()
conf = config.Config(PYRUSE_PATHS).asMap().get("actions", {})
wf = workflow.Workflow(conf)
_doForEachJournalEntry(wf)
if __name__ == '__main__':
main()

+ 0
- 41
pyruse/module.py View File

@ -1,41 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 Y. Gablin
# Full licensing information in the LICENSE file, or gnu.org/licences/gpl-3.0.txt if the file is missing.
import importlib
from pyruse import log
_modules = {}
class Module:
def __init__(self, isAction, module, thenRun, elseRun):
self.isAction = isAction
self.isFilter = not isAction
self.module = module
self.thenRun = thenRun
self.elseRun = elseRun
def get(moduleDesc):
if "filter" in moduleDesc:
isAction = False
mod = _getModule("pyruse.filters." + moduleDesc["filter"])
obj = mod.Filter(moduleDesc.get("args", {}))
elseRun = moduleDesc["else"] if "else" in moduleDesc else None
elif "action" in moduleDesc:
isAction = True
mod = _getModule("pyruse.actions." + moduleDesc["action"])
obj = mod.Action(moduleDesc.get("args", {}))
elseRun = None
else:
raise ValueError("Step is neither “filter” nor “action”: %s\n" % str(moduleDesc))
thenRun = moduleDesc["then"] if "then" in moduleDesc else None
return Module(isAction, obj, thenRun, elseRun)
def _getModule(modName):
if modName not in _modules:
try:
module = importlib.import_module(modName)
except ImportError as e:
log.error("Module %s not found.\n" % modName)
raise e
_modules[modName] = module
return _modules[modName]

+ 0
- 73
pyruse/workflow.py View File

@ -1,73 +0,0 @@
# pyruse is intended as a replacement to both fail2ban and epylog
# Copyright © 2017–2018 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, config, log, module
class Workflow:
def __init__(self, actions):
self._withDebug = config.Config().asMap().get("debug", False)
seen = {}
dangling = []
firstStep = None
for label in actions:
if not label in seen:
(entryPoint, seen, newDangling) = self._initChain(actions, label, seen, (label,))
if firstStep is None:
firstStep = entryPoint
elif len(dangling) > 0:
for setter in dangling:
setter(entryPoint)
dangling = newDangling
self.firstStep = firstStep
def _initChain(self, actions, label, seen, wholeChain):
dangling = []
previousSetter = None
firstStep = None
isPreviousDangling = False
isThenCalled = False
for stepNum, step in enumerate(actions[label]):
if isThenCalled:
break
mod = module.get(step)
obj = mod.module
if self._withDebug:
obj.setStepName(label + '[' + str(stepNum) + ']')
if mod.thenRun:
(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, wholeChain,
actions, seen, dangling)
else:
dangling.append(obj.setAltStep)
isPreviousDangling = mod.isFilter and not isThenCalled
if previousSetter:
previousSetter(obj)
else:
firstStep = obj
previousSetter = obj.setNextStep
if isPreviousDangling:
dangling.append(previousSetter)
seen[label] = firstStep if len(dangling) == 0 else None
return (firstStep, seen, dangling)
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, wholeChain + (branchName,))
parentSetter(entryPoint)
dangling.extend(newDangling)
else:
raise ValueError("Action chain not found: %s\n" % branchName)
return (seen, dangling)

+ 112
- 0
src/domain/action/counter_raise.rs View File

@ -0,0 +1,112 @@
use super::CounterAction;
use crate::domain::{Action, Counters, CountersPort, ModuleArgs, Record, Singleton, Value};
use crate::singleton_borrow;
use chrono::Utc;
pub struct CounterRaise<C: CountersPort> {
act: CounterAction<C>,
}
impl<C: CountersPort> CounterRaise<C> {
pub fn from_args<X: CountersPort>(
args: ModuleArgs,
counters: Singleton<Counters<X>>,
) -> CounterRaise<X> {
CounterRaise {
act: CounterAction::<X>::from_args(args, counters, "CounterRaise", "keepSeconds"),
}
}
}
impl<C: CountersPort> Action for CounterRaise<C> {
fn act(&mut self, record: &mut Record) -> Result<(), ()> {
match record.get(&self.act.counter_key) {
None => Err(()),
Some(v) => {
let count = singleton_borrow!(self.act.counters).augment(
(self.act.counter_name.as_ref(), v),
(1, self.act.duration.map(|d| Utc::now() + d)),
);
if let Some(s) = &self.act.save_into {
record.insert(s.clone(), Value::Int(count as isize));
};
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use crate::domain::action::CounterRaise;
use crate::domain::test_util::FakeCountersAdapter;
use crate::domain::{Action, CounterData, Counters, Singleton, Value};
use crate::{singleton_borrow, singleton_new, singleton_share};
use chrono::{Duration, Utc};
use std::collections::HashMap;
use std::{thread, time};
#[test]
fn when_non_existing_then_raise_to_1() {
let (_, mut action) = get_counters_action();
let mut record = HashMap::with_capacity(1);
record.insert("k".to_string(), Value::Str("raise#1".to_string()));
action.act(&mut record).unwrap();
assert_eq!(Some(&Value::Int(1)), record.get("raise"));
}
#[test]
fn when_different_key_then_different_counter() {
let (_, mut action) = get_counters_action();
let mut record1 = HashMap::with_capacity(1);
record1.insert("k".to_string(), Value::Str("raise#3".to_string()));
let mut record2 = HashMap::with_capacity(1);
record2.insert("k".to_string(), Value::Str("raise#4".to_string()));
action.act(&mut record1).unwrap();
assert_eq!(Some(&Value::Int(1)), record1.get("raise"));
action.act(&mut record2).unwrap();
assert_eq!(Some(&Value::Int(1)), record2.get("raise"));
action.act(&mut record2).unwrap();
assert_eq!(Some(&Value::Int(2)), record2.get("raise"));
action.act(&mut record2).unwrap();
assert_eq!(Some(&Value::Int(3)), record2.get("raise"));
action.act(&mut record1).unwrap();
assert_eq!(Some(&Value::Int(2)), record1.get("raise"));
}
#[test]
fn when_grace_time_then_count_is_0() {
let (counters, mut action) = get_counters_action();
let mut record = HashMap::with_capacity(1);
record.insert("k".to_string(), Value::Str("raise#5".to_string()));
singleton_borrow!(counters).insert(
("test".to_string(), Value::Str("raise#5".to_string())),
(0, Some(Utc::now() + Duration::seconds(1))),
);
action.act(&mut record).unwrap();
assert_eq!(Some(&Value::Int(0)), record.get("raise"));
thread::sleep(time::Duration::from_secs(1));
action.act(&mut record).unwrap();
assert_eq!(Some(&Value::Int(1)), record.get("raise"));
}
fn get_counters_action() -> (
Singleton<HashMap<(String, Value), CounterData>>,
CounterRaise<FakeCountersAdapter>,
) {
let counters = singleton_new!(HashMap::new());
let counters_backend =
singleton_new!(Counters::<FakeCountersAdapter>::new(FakeCountersAdapter {
counters: singleton_share!(counters)
}));
let mut args = HashMap::with_capacity(3);
args.insert("counter".to_string(), Value::Str("test".to_string()));
args.insert("for".to_string(), Value::Str("k".to_string()));
args.insert("save".to_string(), Value::Str("raise".to_string()));
let action = CounterRaise::<FakeCountersAdapter>::from_args(args, counters_backend);
(counters, action)
}
}

+ 103
- 0
src/domain/action/counter_reset.rs View File

@ -0,0 +1,103 @@
use super::CounterAction;
use crate::domain::{Action, Counters, CountersPort, ModuleArgs, Record, Singleton, Value};
use crate::singleton_borrow;
use chrono::Utc;
pub struct CounterReset<C: CountersPort> {
act: CounterAction<C>,
}
impl<C: CountersPort> CounterReset<C> {
pub fn from_args<X: CountersPort>(
args: ModuleArgs,
counters: Singleton<Counters<X>>,
) -> CounterReset<X> {
CounterReset {
act: CounterAction::<X>::from_args(args, counters, "CounterReset", "graceSeconds"),
}
}
}
impl<C: CountersPort> Action for CounterReset<C> {
fn act(&mut self, record: &mut Record) -> Result<(), ()> {
match record.get(&self.act.counter_key) {