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

+89
View File
@@ -0,0 +1,89 @@
// Package match finds the address in a log line: patterns are regular
// expressions (Go's, RE2) with one <IP>, which matches an IPv4 or IPv6
// address (bracketed, IPv4-mapped, with a zone). The address is checked
// with netip, so what only looks like one (a time, 12:34:56) never is.
package match
import (
"fmt"
"net/netip"
"regexp"
"strings"
)
// Placeholder is where a pattern's address is.
const Placeholder = "<IP>"
// ipRe matches an address: IPv4, or IPv6 (which may end in IPv4,
// ::ffff:203.0.113.9), bracketed or not, with an optional zone.
const ipRe = `\[?(?P<ip>[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){1,7}(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3})?(?:%[0-9A-Za-z_.-]+)?)\]?`
// Matcher holds a watch's patterns.
type Matcher struct {
match []*regexp.Regexp
ignore []*regexp.Regexp
}
// Compile makes a Matcher. Each match pattern must hold <IP> exactly
// once; ignore patterns are plain regular expressions.
func Compile(match, ignore []string) (*Matcher, error) {
m := &Matcher{}
for _, p := range match {
if n := strings.Count(p, Placeholder); n != 1 {
return nil, fmt.Errorf("pattern %q has %d %s; it needs exactly one, where the address is", p, n, Placeholder)
}
re, err := regexp.Compile(strings.Replace(p, Placeholder, ipRe, 1))
if err != nil {
return nil, fmt.Errorf("pattern %q: %v", p, err)
}
m.match = append(m.match, re)
}
for _, p := range ignore {
re, err := regexp.Compile(p)
if err != nil {
return nil, fmt.Errorf("ignore pattern %q: %v", p, err)
}
m.ignore = append(m.ignore, re)
}
return m, nil
}
// Match returns the address of the first pattern a line matches, unless
// an ignore pattern matches it too.
func (m *Matcher) Match(line string) (netip.Addr, bool) {
for _, re := range m.match {
sub := re.FindStringSubmatch(line)
if sub == nil {
continue
}
text := sub[re.SubexpIndex("ip")]
if i := strings.IndexByte(text, '%'); i >= 0 {
text = text[:i]
}
a, err := netip.ParseAddr(text)
if err != nil {
continue
}
for _, ig := range m.ignore {
if ig.MatchString(line) {
return netip.Addr{}, false
}
}
return a.Unmap(), true
}
return netip.Addr{}, false
}
// Group is the prefix an address is counted and banned by: an IPv4
// address alone, an IPv6 address by its /v6 (64: what one customer
// usually has).
func Group(a netip.Addr, v6 int) netip.Prefix {
if a.Is4() || v6 <= 0 || v6 > 128 {
if a.Is4() {
return netip.PrefixFrom(a, 32)
}
v6 = 128
}
p, _ := a.Prefix(v6)
return p
}
+50
View File
@@ -0,0 +1,50 @@
package match
import (
"net/netip"
"testing"
)
func TestMatch(t *testing.T) {
m, err := Compile([]string{`from <IP> port`, `client: <IP>,`, `^conn <IP>$`}, []string{`trusted-scanner`})
if err != nil {
t.Fatal(err)
}
cases := map[string]string{
"from 203.0.113.9 port 22": "203.0.113.9",
"from 2001:db8::9 port 22": "2001:db8::9",
"from ::ffff:203.0.113.9 port 22": "203.0.113.9",
"from [2001:db8::9] port 22": "2001:db8::9",
"from fe80::1%eth0 port 22": "fe80::1",
"client: 203.0.113.9, server": "203.0.113.9",
"conn 2001:db8:1:2:3:4:5:6": "2001:db8:1:2:3:4:5:6",
"from 12:34:56 port 22": "", // a time, not an address
"from 999.1.1.1 port 22": "",
"from 203.0.113.9 port 22 trusted-scanner": "", // ignored
"nothing here": "",
}
for line, want := range cases {
a, ok := m.Match(line)
if got := map[bool]string{true: a.String(), false: ""}[ok]; got != want {
t.Errorf("%q: %q, want %q", line, got, want)
}
}
if _, err := Compile([]string{"<IP> and <IP>"}, nil); err == nil {
t.Error("two <IP> accepted")
}
if _, err := Compile([]string{"<IP> (unclosed"}, nil); err == nil {
t.Error("a broken pattern accepted")
}
}
func TestGroup(t *testing.T) {
if g := Group(netip.MustParseAddr("2001:db8:1:2:aa::1"), 64); g.String() != "2001:db8:1:2::/64" {
t.Errorf("/64: %s", g)
}
if g := Group(netip.MustParseAddr("203.0.113.9"), 64); g.String() != "203.0.113.9/32" {
t.Errorf("IPv4: %s", g)
}
if g := Group(netip.MustParseAddr("2001:db8::1"), 128); g.String() != "2001:db8::1/128" {
t.Errorf("/128: %s", g)
}
}