Guard: watch logs, ban misbehaving addresses in Housecarl

Files and the journal, patterns with one <IP>, bounties within a window,
bans in a Housecarl ban list with repeat offenders' bans growing; presets
for sshd, jarl, housecarl, steward, bind-xfr, postfix-sasl, dovecot,
nginx-auth and housecarl-drops; guard run/check/test/status/bans/ban/unban;
a Debian package with a sandboxed unit; an end-to-end test with Housecarl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-22 16:29:37 +01:00
commit d06bf8d531
47 files changed
+4614

No files matched your search

+442
View File
@@ -0,0 +1,442 @@
// Package config reads Guard's configuration: one YAML file,
// /etc/guard/guard.yaml (docs/configuration.md). Unknown settings are an
// error; everything not given has a default.
package config
import (
"bytes"
"errors"
"fmt"
"io"
"net/netip"
"os"
"regexp"
"strconv"
"strings"
"time"
"go.yaml.in/yaml/v3"
"guard/internal/match"
)
// DefaultPath is where the configuration is read from.
const DefaultPath = "/etc/guard/guard.yaml"
// Config is guard.yaml.
type Config struct {
Housecarl Housecarl `yaml:"housecarl"`
// NeverBan are addresses, prefixes and host names never banned.
NeverBan []string `yaml:"never_ban"`
Defaults Defaults `yaml:"defaults"`
Watch []*Watch `yaml:"watch"`
// State is where Guard keeps what must outlive a restart: repeat
// offenders, recent bans, bans waiting for Housecarl.
State string `yaml:"state"`
// Socket is the local socket 'guard status' asks.
Socket string `yaml:"socket"`
Log Log `yaml:"log"`
}
// Housecarl is how Guard reaches Housecarl.
type Housecarl struct {
// URL is Housecarl's API ("https://router.lan:8070"), or Socket its
// local socket on the firewall itself.
URL string `yaml:"url"`
Socket string `yaml:"socket"`
// TokenFile (or Token) is the API token: one for the ban lists Guard
// bans into (bans:write:LIST).
TokenFile string `yaml:"token_file"`
Token string `yaml:"token"`
// CACert trusts a private CA for Housecarl's certificate; Insecure
// skips checking it.
CACert string `yaml:"ca_cert"`
Insecure bool `yaml:"insecure"`
// List is the ban list a watch bans into unless it names another.
List string `yaml:"list"`
}
// Defaults are the watches' settings unless they say otherwise.
type Defaults struct {
// Hits matches of an address within Within ban it.
Hits int `yaml:"hits"`
Within Duration `yaml:"within"`
// Ban is how long a first ban lasts.
Ban Duration `yaml:"ban"`
Repeat Repeat `yaml:"repeat"`
}
// Repeat makes repeat offenders' bans longer: each earlier ban within
// Forget multiplies the time by Factor, up to Max.
type Repeat struct {
Factor float64 `yaml:"factor"`
Max Duration `yaml:"max"`
Forget Duration `yaml:"forget"`
}
// Log is Guard's own logging.
type Log struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
}
// Watch is one log to watch: where its lines come from, what in them is
// a misdeed, and what a ban is.
type Watch struct {
Name string `yaml:"name"`
// Preset names built-in patterns and their usual source
// (docs/presets.md); the watch's own match lines are added.
Preset string `yaml:"preset"`
// One source: a file (or glob), the journal, or Housecarl's log of
// dropped packets (source: housecarl-drops).
File string `yaml:"file"`
Journal *Journal `yaml:"journal"`
Source string `yaml:"source"`
// Match are regular expressions with one <IP>; Ignore lines are
// skipped even when they match.
Match []string `yaml:"match"`
Ignore []string `yaml:"ignore"`
// Protocol limits housecarl-drops to a protocol (default tcp: UDP
// sources can be faked); Shadow counts shadow mode's would-be drops.
Protocol string `yaml:"protocol"`
Shadow bool `yaml:"shadow"`
// Hits and Within override the defaults.
Hits int `yaml:"hits"`
Within Duration `yaml:"within"`
Ban Ban `yaml:"ban"`
// Disabled watches are skipped.
Disabled bool `yaml:"disabled"`
}
// Journal selects journal entries: by systemd unit and/or syslog
// identifier (each a name or a list; entries of any).
type Journal struct {
Unit Strings `yaml:"unit"`
Identifier Strings `yaml:"identifier"`
}
// Ban is what a watch's ban is: into which list, of which ports, for how
// long.
type Ban struct {
List string `yaml:"list"`
// Protocol and Port limit the ban (port numbers or service names);
// without them, every port.
Protocol string `yaml:"protocol"`
Port Strings `yaml:"port"`
Time Duration `yaml:"time"`
}
// Strings is a YAML scalar or list of scalars.
type Strings []string
// UnmarshalYAML reads "x", 22, or [x, 22].
func (s *Strings) UnmarshalYAML(n *yaml.Node) error {
switch n.Kind {
case yaml.ScalarNode:
*s = Strings{n.Value}
return nil
case yaml.SequenceNode:
var out []string
for _, c := range n.Content {
if c.Kind != yaml.ScalarNode {
return fmt.Errorf("line %d: give a name, or a list of names", c.Line)
}
out = append(out, c.Value)
}
*s = out
return nil
}
return fmt.Errorf("line %d: give a name, or a list of names", n.Line)
}
// Duration is a length of time: 30s, 10m, 1h, 1d, 1w, or combined
// (1d12h).
type Duration time.Duration
var durRe = regexp.MustCompile(`^(\d+[wdhms])+$`)
// ParseDuration reads a Duration.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if !durRe.MatchString(s) {
return 0, fmt.Errorf("%q isn't a length of time: write it as 30s, 10m, 1h, 1d or 1w", s)
}
var d time.Duration
unit := map[byte]time.Duration{'w': 7 * 24 * time.Hour, 'd': 24 * time.Hour, 'h': time.Hour, 'm': time.Minute, 's': time.Second}
for _, m := range regexp.MustCompile(`(\d+)([wdhms])`).FindAllStringSubmatch(s, -1) {
n, err := strconv.Atoi(m[1])
if err != nil {
return 0, err
}
d += time.Duration(n) * unit[m[2][0]]
}
return d, nil
}
// UnmarshalYAML reads a Duration.
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
v, err := ParseDuration(n.Value)
if err != nil {
return fmt.Errorf("line %d: %v", n.Line, err)
}
*d = Duration(v)
return nil
}
// D is the time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
// String writes a Duration the short way: 1h30m, 1w.
func (d Duration) String() string { return FormatDuration(time.Duration(d)) }
// FormatDuration writes a length of time the short way.
func FormatDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
var b strings.Builder
for _, u := range []struct {
d time.Duration
s string
}{{7 * 24 * time.Hour, "w"}, {24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
if d >= u.d {
fmt.Fprintf(&b, "%d%s", d/u.d, u.s)
d %= u.d
}
}
return b.String()
}
// Load reads and checks the configuration.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(data)
}
// Parse reads and checks a configuration.
func Parse(data []byte) (*Config, error) {
c := &Config{}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("guard.yaml: %w", err)
}
c.defaults()
if err := c.check(); err != nil {
return nil, err
}
return c, nil
}
func (c *Config) defaults() {
d := &c.Defaults
if d.Hits == 0 {
d.Hits = 5
}
if d.Within == 0 {
d.Within = Duration(10 * time.Minute)
}
if d.Ban == 0 {
d.Ban = Duration(time.Hour)
}
if d.Repeat.Factor == 0 {
d.Repeat.Factor = 2
}
if d.Repeat.Max == 0 {
d.Repeat.Max = Duration(7 * 24 * time.Hour)
}
if d.Repeat.Forget == 0 {
d.Repeat.Forget = Duration(7 * 24 * time.Hour)
}
if c.State == "" {
c.State = "/var/lib/guard/state.json"
}
if c.Socket == "" {
c.Socket = "/run/guard/guard.sock"
}
if c.Log.Level == "" {
c.Log.Level = "info"
}
if c.Log.Format == "" {
c.Log.Format = "text"
}
for _, w := range c.Watch {
if w.Hits == 0 {
w.Hits = d.Hits
}
if w.Within == 0 {
w.Within = d.Within
}
if w.Ban.Time == 0 {
w.Ban.Time = d.Ban
}
if w.Ban.List == "" {
w.Ban.List = c.Housecarl.List
}
}
}
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,47}$`)
// Source names besides files and the journal.
const SourceHousecarlDrops = "housecarl-drops"
func (c *Config) check() error {
var errs []string
add := func(format string, args ...any) { errs = append(errs, fmt.Sprintf(format, args...)) }
h := c.Housecarl
switch {
case h.URL == "" && h.Socket == "":
add("housecarl: give url (https://router.lan:8070) or socket (/run/housecarl/housecarl.sock)")
case h.URL != "" && h.Socket != "":
add("housecarl: give url or socket, not both")
case h.URL != "" && !strings.HasPrefix(h.URL, "http://") && !strings.HasPrefix(h.URL, "https://"):
add("housecarl.url %q: start it with https:// (or http://)", h.URL)
}
if h.Token != "" && h.TokenFile != "" {
add("housecarl: give token_file or token, not both")
}
for _, n := range c.NeverBan {
if _, err := netip.ParsePrefix(n); err == nil {
continue
}
if _, err := netip.ParseAddr(n); err == nil {
continue
}
if !hostRe.MatchString(n) {
add("never_ban: %q isn't an address, prefix or host name", n)
}
}
d := c.Defaults
if d.Hits < 1 {
add("defaults.hits is %d; use 1 or more", d.Hits)
}
if d.Repeat.Factor < 1 {
add("defaults.repeat.factor is %g; use 1 (bans don't grow) or more", d.Repeat.Factor)
}
if lv := strings.ToLower(c.Log.Level); lv != "debug" && lv != "info" && lv != "warn" && lv != "error" {
add("log.level %q: use debug, info, warn or error", c.Log.Level)
}
if c.Log.Format != "text" && c.Log.Format != "json" {
add("log.format %q: use text or json", c.Log.Format)
}
if len(c.Watch) == 0 {
add("watch: nothing to watch; add a watch (guard config --example shows some)")
}
seen := map[string]bool{}
for i, w := range c.Watch {
what := fmt.Sprintf("watch %d", i+1)
if w.Name != "" {
what = "watch " + w.Name
}
if !nameRe.MatchString(w.Name) {
add("%s: give it a name (letters, digits, - _ .; starting with a letter)", what)
} else if seen[strings.ToLower(w.Name)] {
add("%s: the name is used twice", what)
}
seen[strings.ToLower(w.Name)] = true
if err := w.resolve(); err != nil {
add("%s: %v", what, err)
continue
}
if w.Hits < 1 {
add("%s: hits is %d; use 1 or more", what, w.Hits)
}
if w.Ban.List == "" {
add("%s: which ban list? Set housecarl.list, or ban.list", what)
}
switch strings.ToLower(w.Ban.Protocol) {
case "", "tcp", "udp":
default:
add("%s: ban.protocol %q: use tcp, udp, or leave it out", what, w.Ban.Protocol)
}
if w.Ban.Protocol != "" && len(w.Ban.Port) == 0 {
add("%s: ban.protocol without ban.port: give the port, or leave both out to ban every port", what)
}
if _, err := match.Compile(w.Match, w.Ignore); err != nil {
add("%s: %v", what, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("guard.yaml:\n %s", strings.Join(errs, "\n "))
}
return nil
}
var hostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$`)
// Kind is a watch's kind of source: "file", "journal" or
// "housecarl-drops" (after resolve).
func (w *Watch) Kind() string {
switch {
case w.Source != "":
return w.Source
case w.Journal != nil:
return "journal"
}
return "file"
}
// resolve fills in a watch's preset: its source unless the watch gives
// one, its patterns before the watch's own.
func (w *Watch) resolve() error {
n := 0
for _, set := range []bool{w.File != "", w.Journal != nil, w.Source != ""} {
if set {
n++
}
}
if n > 1 {
return fmt.Errorf("give one source: file, journal or source")
}
if w.Source != "" && w.Source != SourceHousecarlDrops {
return fmt.Errorf("source %q: the only one besides file and journal is %s", w.Source, SourceHousecarlDrops)
}
if w.Preset != "" {
p, ok := Presets()[w.Preset]
if !ok {
return fmt.Errorf("there is no preset %q (guard presets lists them)", w.Preset)
}
if n == 0 {
switch {
case p.Source != "":
w.Source = p.Source
case p.Journal != nil:
j := *p.Journal
w.Journal = &j
default:
w.File = p.File
}
}
w.Match = append(append([]string{}, p.Match...), w.Match...)
w.Ignore = append(append([]string{}, p.Ignore...), w.Ignore...)
} else if n == 0 {
return fmt.Errorf("give a source: file, journal, source, or a preset")
}
if w.Journal != nil && len(w.Journal.Unit) == 0 && len(w.Journal.Identifier) == 0 {
return fmt.Errorf("journal: give a unit or an identifier (or both)")
}
if w.Source == SourceHousecarlDrops {
if len(w.Match) > 0 {
return fmt.Errorf("housecarl-drops has no patterns: every dropped packet counts")
}
switch strings.ToLower(w.Protocol) {
case "":
w.Protocol = "tcp"
case "tcp", "udp", "any":
default:
return fmt.Errorf("protocol %q: use tcp, udp or any", w.Protocol)
}
} else {
if w.Protocol != "" || w.Shadow {
return fmt.Errorf("protocol and shadow are for source housecarl-drops")
}
if len(w.Match) == 0 {
return fmt.Errorf("no match patterns: give some, or a preset")
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
package config
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
"time"
"guard/internal/match"
)
// TestPresets runs each preset over its sample lines in testdata/presets:
// what it must find, and what it must leave alone.
func TestPresets(t *testing.T) {
for name, p := range Presets() {
if p.Source != "" {
continue
}
m, err := match.Compile(p.Match, p.Ignore)
if err != nil {
t.Fatalf("preset %s: %v", name, err)
}
f, err := os.Open(filepath.Join("..", "..", "testdata", "presets", name+".txt"))
if err != nil {
t.Errorf("preset %s has no sample lines: %v", name, err)
continue
}
sc := bufio.NewScanner(f)
n := 0
for sc.Scan() {
want, line, ok := strings.Cut(sc.Text(), "\t")
if !ok || strings.HasPrefix(want, "#") {
continue
}
n++
a, matched := m.Match(line)
switch {
case want == "-" && matched:
t.Errorf("%s: matched %s in %q", name, a, line)
case want != "-" && !matched:
t.Errorf("%s: no match in %q", name, line)
case want != "-" && a.String() != want:
t.Errorf("%s: %s, want %s, in %q", name, a, want, line)
}
}
f.Close()
if n == 0 {
t.Errorf("preset %s: no sample lines", name)
}
}
}
func TestExampleParses(t *testing.T) {
c, err := Parse([]byte(Example()))
if err != nil {
t.Fatal(err)
}
w := c.Watch[0]
if w.Kind() != "journal" || w.Ban.List != "web" || w.Hits != 5 || w.Within.D() != 10*time.Minute || len(w.Match) == 0 {
t.Errorf("the ssh watch: %+v", w)
}
if c.Defaults.Repeat.Max.D() != 7*24*time.Hour {
t.Errorf("repeat %+v", c.Defaults.Repeat)
}
}
func TestErrors(t *testing.T) {
base := "housecarl: {url: https://fw:8070, list: web}\n"
cases := []struct{ yaml, want string }{
{"", "give url"},
{base + "watch: [{name: a, preset: nope}]", `no preset "nope"`},
{base + "watch: [{name: a, file: /x, match: ['no address']}]", "exactly one"},
{base + "watch: [{name: a, file: /x, journal: {unit: x}, match: ['<IP>']}]", "one source"},
{base + "watch: [{name: a, file: /x}]", "no match patterns"},
{base + "watch: [{name: a, source: housecarl-drops, match: ['<IP>']}]", "no patterns"},
{base + "watch: [{name: a, preset: sshd, ban: {protocol: tcp}}]", "without ban.port"},
{base + "watch: [{name: a, preset: sshd, within: 10 minutes}]", "isn't a length of time"},
{base + "watch: [{name: a, preset: sshd}, {name: A, preset: sshd}]", "used twice"},
{base + "never_ban: ['not an address!']\nwatch: [{name: a, preset: sshd}]", "never_ban"},
{base + "bogus: 1\nwatch: [{name: a, preset: sshd}]", "bogus"},
{"housecarl: {url: https://fw:8070}\nwatch: [{name: a, preset: sshd}]", "which ban list"},
}
for _, c := range cases {
_, err := Parse([]byte(c.yaml))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%q: %v, want %q", c.yaml, err, c.want)
}
}
}
func TestPresetSourceAndOwnPatterns(t *testing.T) {
c, err := Parse([]byte(`housecarl: {socket: /run/housecarl/housecarl.sock, list: router}
watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log, match: ['custom <IP> line']}
- {name: scans, preset: housecarl-drops}
`))
if err != nil {
t.Fatal(err)
}
ssh := c.Watch[0]
if ssh.Kind() != "file" || ssh.File != "/var/log/auth.log" || ssh.Match[len(ssh.Match)-1] != "custom <IP> line" || len(ssh.Match) < 2 {
t.Errorf("own file and pattern beside the preset's: %+v", ssh)
}
if s := c.Watch[1]; s.Kind() != SourceHousecarlDrops || s.Protocol != "tcp" {
t.Errorf("housecarl-drops: %+v", s)
}
}
func TestDuration(t *testing.T) {
for in, want := range map[string]time.Duration{"30s": 30 * time.Second, "1d12h": 36 * time.Hour, "1w": 7 * 24 * time.Hour} {
if d, err := ParseDuration(in); err != nil || d != want {
t.Errorf("%s: %v %v", in, d, err)
}
if got := FormatDuration(want); got != in {
t.Errorf("format %v: %s, want %s", want, got, in)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
# Guard's configuration (docs/configuration.md). Guard reads it when it
# starts: systemctl restart guard after a change, and 'guard check' to
# check it first.
# Housecarl, which does the banning. Give Guard a token for its ban list
# alone; on the firewall:
# hc banlist add web --scope hosts --host 10.0.10.57
# hc user add guard-web --no-password --grant bans:write:web
# hc token create web --user guard-web # as root
housecarl:
url: https://router.lan:8070
# On the firewall itself, the local socket instead of url:
# socket: /run/housecarl/housecarl.sock
token_file: /etc/guard/token
# ca_cert: /etc/guard/housecarl-ca.pem # a private CA
# The ban list a watch bans into, unless it says another.
list: web
# Never banned, whatever the logs say: addresses, prefixes, host names
# (looked up every ten minutes). Housecarl itself never bans the inside
# networks, the firewall's own addresses, or this machine.
never_ban:
# - 198.51.100.7
# - 2001:db8:ffff::/48
# - vps.example.org
# What every watch does unless it says otherwise: this many matches of an
# address within this time ban it for this long. Each earlier ban of the
# address within "forget" doubles the time ("factor"), up to "max".
defaults:
hits: 5
within: 10m
ban: 1h
repeat: {factor: 2, max: 1w, forget: 1w}
watch:
# OpenSSH, from the journal (the preset knows where).
- name: ssh
preset: sshd
ban: {protocol: tcp, port: 22}
# A log file, with your own pattern: <IP> is where the address is.
# - name: web-login
# file: /var/log/nginx/access.log
# match:
# - '^<IP> \S+ \S+ \[[^\]]+\] "POST /login[^"]*" 401 '
# ignore:
# - '/healthz'
# hits: 10
# ban: {time: 6h}
# Jarl's, Housecarl's or Steward's failed logins (presets jarl,
# housecarl, steward), banned from every port of the list's scope.
# - name: jarl
# preset: jarl
# On the firewall: sources whose packets Housecarl's logged rules keep
# dropping (TCP only: UDP sources can be faked). The token also needs
# log:read.
# - name: scans
# preset: housecarl-drops
# hits: 20
# within: 1m
# ban: {list: edge, time: 1d}
# Guard's memory of its bans (repeat offenders), and the socket 'guard
# status' asks.
state: /var/lib/guard/state.json
socket: /run/guard/guard.sock
log: {level: info, format: text}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
_ "embed"
"sync"
"go.yaml.in/yaml/v3"
)
//go:embed presets.yaml
var presetsYAML []byte
// Preset is a built-in watch: patterns, and where the lines usually are.
type Preset struct {
Description string `yaml:"description"`
File string `yaml:"file"`
Journal *Journal `yaml:"journal"`
Source string `yaml:"source"`
Match []string `yaml:"match"`
Ignore []string `yaml:"ignore"`
}
// Presets are the built-in presets by name.
var Presets = sync.OnceValue(func() map[string]Preset {
out := map[string]Preset{}
if err := yaml.Unmarshal(presetsYAML, &out); err != nil {
panic("presets.yaml: " + err.Error())
}
return out
})
//go:embed example.yaml
var exampleYAML []byte
// Example is a commented configuration (guard config --example).
func Example() string { return string(exampleYAML) }
+70
View File
@@ -0,0 +1,70 @@
# Guard's presets (docs/presets.md): patterns for common programs, and
# where their lines usually are. A watch's own file or journal replaces
# the preset's source; its match and ignore lines are added to these.
#
# Patterns are anchored where the program's format allows, and put the
# address where the program writes it, after anything a client chooses
# (a user name), so a chosen name can't plant another address. Lines
# from a syslog file carry a "host prog[pid]: " head that journal
# messages don't, hence "(?:^|: )" before a message's first word.
sshd:
description: OpenSSH's failed logins, unknown users and broken handshakes
# OpenSSH 9.8 and later (Debian 13) logs from sshd-session.
journal: {identifier: [sshd, sshd-session]}
match:
- '(?:^|: )Failed (?:password|publickey|keyboard-interactive/pam|none) for .* from <IP> port \d+(?: ssh2)?$'
- '(?:^|: )Invalid user .* from <IP> port \d+$'
- '(?:^|: )(?:Connection (?:closed|reset)|Disconnected) (?:by|from) (?:invalid|authenticating) user .* <IP> port \d+ \[preauth\]$'
- '(?:^|: )error: maximum authentication attempts exceeded for .* from <IP> port \d+ ssh2 \[preauth\]$'
- '(?:^|: )Unable to negotiate with <IP> port \d+: no matching (?:key exchange method|host key type|cipher|MAC) found'
- '(?:^|: )banner exchange: Connection from <IP> port \d+: invalid format$'
jarl:
description: failed logins and refused API tokens of Jarl (the BIND manager)
journal: {unit: jarl.service}
match:
- 'msg="(?:login failed|token refused)" ip=<IP> '
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
housecarl:
description: failed logins and refused API tokens of Housecarl (the firewall manager)
journal: {unit: housecarl.service}
match:
- 'msg="(?:login failed|token refused)" ip=<IP> '
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
steward:
description: failed logins and refused API tokens of Steward (the DHCP server)
journal: {unit: steward.service}
match:
- 'msg="(?:login failed|token refused)" ip=<IP> '
- '"msg":"(?:login failed|token refused)","ip":"<IP>"'
bind-xfr:
description: zone transfers BIND refused (over TCP, so the address is real)
journal: {identifier: named}
match:
- 'client @0x[0-9a-f]+ <IP>#\d+ .*: zone transfer .* denied$'
postfix-sasl:
description: Postfix's failed SMTP logins (SASL)
journal: {identifier: [postfix/smtpd, postfix/submission/smtpd, postfix/smtps/smtpd]}
match:
- 'warning: [-._0-9A-Za-z]+\[<IP>\]: SASL [-A-Z0-9]+ authentication failed'
dovecot:
description: Dovecot's failed IMAP and POP3 logins
journal: {identifier: dovecot}
match:
- '(?:imap|pop3|submission|managesieve)-login: (?:Disconnected|Aborted login).*\(auth failed, \d+ attempts? in \d+ secs\): user=<.*>, .*rip=<IP>, lip='
nginx-auth:
description: nginx's failed HTTP basic authentication (error.log)
file: /var/log/nginx/error.log
match:
- '\buser "[^"]*"(?: was not found in "[^"]*"|: password mismatch), client: <IP>, server: '
housecarl-drops:
description: packets Housecarl's rules dropped and logged (on the firewall; TCP only by default)
source: housecarl-drops