Files
abrown38andClaude Opus 5.5 d06bf8d531 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>
2026-10-22 16:29:37 +01:00

90 lines
2.5 KiB
Go

// 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
}