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:
commit
d06bf8d531
47 files changed
+4614
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user