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

+120
View File
@@ -0,0 +1,120 @@
// Package bounty keeps each address's bounty in a watch: its matches
// within the watch's window. An address whose bounty reaches the watch's
// hits is banned, and starts again from nothing.
package bounty
import (
"net/netip"
"sort"
"sync"
"time"
)
// MaxTracked is the most addresses a watch keeps a bounty for; past it,
// the ones seen longest ago are forgotten.
const MaxTracked = 100000
// Tracker counts one watch's matches per address (or IPv6 prefix).
type Tracker struct {
Hits int
Within time.Duration
mu sync.Mutex
seen map[netip.Prefix][]time.Time
}
// New makes a Tracker.
func New(hits int, within time.Duration) *Tracker {
return &Tracker{Hits: hits, Within: within, seen: map[netip.Prefix][]time.Time{}}
}
// Add counts a match of p at a time. It returns p's bounty, and whether
// it reached the hits (p's bounty is then cleared).
func (t *Tracker) Add(p netip.Prefix, at time.Time) (int, bool) {
t.mu.Lock()
defer t.mu.Unlock()
ts := keep(t.seen[p], at.Add(-t.Within))
ts = append(ts, at)
if len(ts) >= t.Hits {
delete(t.seen, p)
return len(ts), true
}
t.seen[p] = ts
if len(t.seen) > MaxTracked {
t.prune(at)
}
return len(ts), false
}
// keep drops the times before from (times are in order).
func keep(ts []time.Time, from time.Time) []time.Time {
i := 0
for i < len(ts) && ts[i].Before(from) {
i++
}
return ts[i:]
}
// prune forgets what is out of the window, then, if still too many, the
// addresses seen longest ago.
func (t *Tracker) prune(now time.Time) {
for p, ts := range t.seen {
if ts = keep(ts, now.Add(-t.Within)); len(ts) == 0 {
delete(t.seen, p)
} else {
t.seen[p] = ts
}
}
if len(t.seen) <= MaxTracked {
return
}
type last struct {
p netip.Prefix
t time.Time
}
all := make([]last, 0, len(t.seen))
for p, ts := range t.seen {
all = append(all, last{p, ts[len(ts)-1]})
}
sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) })
for _, x := range all[:len(all)-MaxTracked*9/10] {
delete(t.seen, x.p)
}
}
// Bounty is an address's bounty.
type Bounty struct {
Address string `json:"address"`
Bounty int `json:"bounty"`
Last time.Time `json:"last"`
}
// Top returns the n highest bounties now (within the window), highest
// first.
func (t *Tracker) Top(n int, now time.Time) []Bounty {
t.mu.Lock()
defer t.mu.Unlock()
var out []Bounty
for p, ts := range t.seen {
if ts = keep(ts, now.Add(-t.Within)); len(ts) > 0 {
out = append(out, Bounty{Address: address(p), Bounty: len(ts), Last: ts[len(ts)-1]})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Bounty != out[j].Bounty {
return out[i].Bounty > out[j].Bounty
}
return out[i].Last.After(out[j].Last)
})
if len(out) > n {
out = out[:n]
}
return out
}
func address(p netip.Prefix) string {
if p.IsSingleIP() {
return p.Addr().String()
}
return p.String()
}
+30
View File
@@ -0,0 +1,30 @@
package bounty
import (
"net/netip"
"testing"
"time"
)
func TestTracker(t *testing.T) {
tr := New(3, time.Minute)
p := netip.MustParsePrefix("203.0.113.9/32")
t0 := time.Now()
if n, r := tr.Add(p, t0); n != 1 || r {
t.Errorf("first: %d %v", n, r)
}
// Two minutes later the first is out of the window.
tr.Add(p, t0.Add(2*time.Minute))
if n, r := tr.Add(p, t0.Add(2*time.Minute+time.Second)); n != 2 || r {
t.Errorf("window: %d %v", n, r)
}
if top := tr.Top(5, t0.Add(2*time.Minute+time.Second)); len(top) != 1 || top[0].Bounty != 2 || top[0].Address != "203.0.113.9" {
t.Errorf("top %+v", top)
}
if n, r := tr.Add(p, t0.Add(2*time.Minute+2*time.Second)); n != 3 || !r {
t.Errorf("reached: %d %v", n, r)
}
if top := tr.Top(5, t0.Add(3*time.Minute)); len(top) != 0 {
t.Errorf("after the ban the bounty starts again: %+v", top)
}
}
+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
+541
View File
@@ -0,0 +1,541 @@
// Package daemon is Guard at work: each watch's source feeds its lines
// to its patterns; an address that matches often enough is banned in
// Housecarl; bans Housecarl can't take now are sent again later.
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"guard/internal/bounty"
"guard/internal/config"
"guard/internal/housecarl"
"guard/internal/match"
"guard/internal/source"
"guard/internal/state"
)
// Banner is what bans go to: Housecarl (a fake in tests).
type Banner interface {
Ban(ctx context.Context, list string, in housecarl.BanInput) (*housecarl.BanResult, error)
BanList(ctx context.Context, list string) (*housecarl.BanList, error)
}
// Daemon is a running Guard.
type Daemon struct {
cfg *config.Config
log *slog.Logger
hc Banner
state *state.State
never *NeverBan
watches []*Watch
started time.Time
mu sync.Mutex
groups map[string]int // ban list → IPv6 grouping
// pending are the bans Housecarl hasn't taken yet, by list and group.
pending map[string]state.Ban
wake chan struct{}
// refused remembers addresses Housecarl refused, so each is logged
// once an hour.
refused map[string]time.Time
// Now and RetryEvery can be changed by tests.
Now func() time.Time
RetryEvery time.Duration
}
// Watch is one watch at work.
type Watch struct {
Cfg *config.Watch
Matcher *match.Matcher
Source source.Source
Tracker *bounty.Tracker
lines, matches, bans atomic.Uint64
}
// New makes a Daemon. hc may be a fake; sources come from the watches'
// configuration unless a test sets them (Watch.Source).
func New(cfg *config.Config, hc Banner, logs source.LogReader, st *state.State, log *slog.Logger) (*Daemon, error) {
d := &Daemon{cfg: cfg, log: log, hc: hc, state: st, never: NewNeverBan(cfg.NeverBan, log), groups: map[string]int{},
pending: map[string]state.Ban{}, wake: make(chan struct{}, 1), refused: map[string]time.Time{},
Now: time.Now, RetryEvery: 30 * time.Second}
for _, b := range st.Pending {
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
}
for _, w := range cfg.Watch {
if w.Disabled {
continue
}
m, err := match.Compile(w.Match, w.Ignore)
if err != nil {
return nil, fmt.Errorf("watch %s: %v", w.Name, err)
}
wt := &Watch{Cfg: w, Matcher: m, Tracker: bounty.New(w.Hits, w.Within.D())}
switch w.Kind() {
case "file":
wt.Source = &source.File{Pattern: w.File, Log: log.With("watch", w.Name)}
case "journal":
wt.Source = &source.Journal{Units: w.Journal.Unit, Identifiers: w.Journal.Identifier, Log: log.With("watch", w.Name)}
case config.SourceHousecarlDrops:
wt.Source = &source.HousecarlDrops{Client: logs, Protocol: w.Protocol, Shadow: w.Shadow, Log: log.With("watch", w.Name)}
}
d.watches = append(d.watches, wt)
}
if len(d.watches) == 0 {
return nil, errors.New("every watch is disabled: nothing to do")
}
return d, nil
}
// Watches are the watches at work (tests set their sources).
func (d *Daemon) Watches() []*Watch { return d.watches }
// Run watches until ctx ends.
func (d *Daemon) Run(ctx context.Context) error {
d.started = d.Now()
d.loadGroups(ctx)
go d.never.Run(ctx)
var wg sync.WaitGroup
for _, w := range d.watches {
lines := make(chan source.Line, 256)
wg.Add(2)
go func() {
defer wg.Done()
defer close(lines)
for {
err := w.Source.Run(ctx, lines)
if ctx.Err() != nil {
return
}
d.log.Error("the watch's source stopped; starting it again in 10 seconds", "watch", w.Cfg.Name, "err", err)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
}
}()
go func() {
defer wg.Done()
for l := range lines {
d.Line(ctx, w, l)
}
}()
d.log.Info("watching", "watch", w.Cfg.Name, "source", Describe(w.Cfg), "patterns", len(w.Cfg.Match), "hits", w.Cfg.Hits, "within", w.Cfg.Within.String(), "list", w.Cfg.Ban.List)
}
wg.Add(1)
go func() {
defer wg.Done()
d.sender(ctx)
}()
if d.cfg.Socket != "" {
if err := d.serveStatus(ctx); err != nil {
d.log.Warn("no status socket (guard status won't work)", "socket", d.cfg.Socket, "err", err)
}
}
<-ctx.Done()
wg.Wait()
d.savePending()
return nil
}
// Describe says where a watch's lines come from.
func Describe(w *config.Watch) string {
switch w.Kind() {
case "journal":
var parts []string
if len(w.Journal.Unit) > 0 {
parts = append(parts, "unit "+strings.Join(w.Journal.Unit, ", "))
}
if len(w.Journal.Identifier) > 0 {
parts = append(parts, "identifier "+strings.Join(w.Journal.Identifier, ", "))
}
return "journal (" + strings.Join(parts, "; ") + ")"
case config.SourceHousecarlDrops:
return "Housecarl's dropped packets (" + w.Protocol + ")"
}
return "file " + w.File
}
// loadGroups asks Housecarl how each ban list groups IPv6 addresses, so
// bounties are counted as the list bans. A list that can't be read now
// counts by /64, its default.
func (d *Daemon) loadGroups(ctx context.Context) {
for _, w := range d.watches {
list := w.Cfg.Ban.List
d.mu.Lock()
_, done := d.groups[list]
d.mu.Unlock()
if done {
continue
}
c, cancel := context.WithTimeout(ctx, 10*time.Second)
bl, err := d.hc.BanList(c, list)
cancel()
group := 64
switch {
case err != nil:
d.log.Warn("can't read the ban list from Housecarl (yet); counting IPv6 addresses by /64", "list", list, "err", err)
case bl.Disabled:
d.log.Warn("the ban list is disabled in Housecarl: its bans won't be in force", "list", list)
group = bl.V6Group()
default:
group = bl.V6Group()
}
d.mu.Lock()
d.groups[list] = group
d.mu.Unlock()
}
}
func (d *Daemon) group(list string) int {
d.mu.Lock()
defer d.mu.Unlock()
if g, ok := d.groups[list]; ok {
return g
}
return 64
}
// Line handles one line of a watch.
func (d *Daemon) Line(ctx context.Context, w *Watch, l source.Line) {
w.lines.Add(1)
addr := l.Addr
if !addr.IsValid() {
var ok bool
if addr, ok = w.Matcher.Match(l.Text); !ok {
return
}
}
w.matches.Add(1)
if why := d.never.Why(addr); why != "" {
d.log.Debug("match, but never banned", "watch", w.Cfg.Name, "address", addr, "why", why)
return
}
g := match.Group(addr, d.group(w.Cfg.Ban.List))
n, reached := w.Tracker.Add(g, l.Time)
d.log.Debug("match", "watch", w.Cfg.Name, "address", addr, "bounty", n, "hits", w.Cfg.Hits, "line", clip(l.Text, 300))
if !reached {
return
}
w.bans.Add(1)
d.ban(w, addr, g, l.Text)
}
// BanTime is how long a group's next ban lasts: the watch's time,
// times the repeat factor for each earlier ban within forget, up to the
// most.
func (d *Daemon) BanTime(w *config.Watch, group string) time.Duration {
r := d.cfg.Defaults.Repeat
base := w.Ban.Time.D()
n := d.state.Repeats(group, d.Now().Add(-r.Forget.D()))
t := time.Duration(float64(base) * math.Pow(r.Factor, float64(n)))
if t > r.Max.D() || t < 0 {
t = r.Max.D()
}
if t < base {
t = base
}
return t
}
func (d *Daemon) ban(w *Watch, addr netip.Addr, g netip.Prefix, line string) {
group := g.String()
t := d.BanTime(w.Cfg, group)
b := state.Ban{Time: d.Now(), Watch: w.Cfg.Name, List: w.Cfg.Ban.List, Address: addr.String(), Group: group,
Protocol: strings.ToLower(w.Cfg.Ban.Protocol), Ports: strings.Join(w.Cfg.Ban.Port, ","), Seconds: int(t / time.Second), Reason: clip(line, 300)}
d.log.Info("bounty reached; banning", "watch", w.Cfg.Name, "address", addr, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(t))
d.mu.Lock()
d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports] = b
d.mu.Unlock()
select {
case d.wake <- struct{}{}:
default:
}
}
func portsText(b state.Ban) string {
if b.Ports == "" {
return "all"
}
if b.Protocol == "" {
return b.Ports
}
return b.Protocol + "/" + b.Ports
}
// sender sends the pending bans to Housecarl: at once, and again every
// RetryEvery while Housecarl can't be reached.
func (d *Daemon) sender(ctx context.Context) {
t := time.NewTicker(d.RetryEvery)
defer t.Stop()
failing := false
for {
select {
case <-ctx.Done():
return
case <-d.wake:
case <-t.C:
}
d.mu.Lock()
var todo []state.Ban
for _, b := range d.pending {
todo = append(todo, b)
}
d.mu.Unlock()
sort.Slice(todo, func(i, j int) bool { return todo[i].Time.Before(todo[j].Time) })
for _, b := range todo {
err := d.send(ctx, b)
if err != nil && !housecarl.Refused(err) {
if !failing {
d.log.Warn("can't reach Housecarl; the bans wait and are sent again", "err", err, "waiting", len(todo))
failing = true
}
break
}
if failing {
d.log.Info("Housecarl is back; sending the bans that waited")
failing = false
}
d.mu.Lock()
if cur, ok := d.pending[b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports]; ok && cur.Time.Equal(b.Time) {
delete(d.pending, b.List+" "+b.Group+" "+b.Protocol+" "+b.Ports)
}
d.mu.Unlock()
}
d.savePending()
}
}
// send makes one ban. A ban Housecarl refuses (never banned, no
// permission) isn't sent again.
func (d *Daemon) send(ctx context.Context, b state.Ban) error {
c, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
// What waited is sent for the time it has left.
secs := b.Seconds - int(d.Now().Sub(b.Time)/time.Second)
if secs <= 0 {
return nil
}
res, err := d.hc.Ban(c, b.List, housecarl.BanInput{Address: b.Address, Protocol: b.Protocol, Ports: b.Ports, Time: secs,
Source: "guard/" + b.Watch, Reason: b.Reason})
if err != nil {
if housecarl.Refused(err) {
b.Result = err.Error()
d.state.Failed(b)
d.mu.Lock()
last, seen := d.refused[b.Group]
d.refused[b.Group] = d.Now()
d.mu.Unlock()
if !seen || d.Now().Sub(last) > time.Hour {
d.log.Warn("Housecarl refused the ban", "watch", b.Watch, "address", b.Address, "list", b.List, "err", err)
}
}
return err
}
b.Result = "banned"
for _, x := range res.Bans {
if x.Existing {
b.Result = "renewed"
}
}
d.state.Banned(b)
d.log.Info("banned", "watch", b.Watch, "address", b.Address, "list", b.List, "ports", portsText(b), "for", config.FormatDuration(time.Duration(secs)*time.Second),
"result", b.Result, "connections_closed", res.Closed)
return nil
}
func (d *Daemon) savePending() {
d.mu.Lock()
var p []state.Ban
for _, b := range d.pending {
p = append(p, b)
}
d.mu.Unlock()
d.state.SetPending(p)
d.state.Forget(d.Now().Add(-d.cfg.Defaults.Repeat.Forget.D()))
if err := d.state.Save(); err != nil {
d.log.Warn("saving the state failed", "file", d.cfg.State, "err", err)
}
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ---- status
// Status is what guard status shows.
type Status struct {
Started time.Time `json:"started"`
Watches []WatchStatus `json:"watches"`
Recent []state.Ban `json:"recent"`
Pending []state.Ban `json:"pending"`
}
// WatchStatus is a watch's.
type WatchStatus struct {
Name string `json:"name"`
Source string `json:"source"`
List string `json:"list"`
Hits int `json:"hits"`
Within string `json:"within"`
Lines uint64 `json:"lines"`
Matches uint64 `json:"matches"`
Bans uint64 `json:"bans"`
Top []bounty.Bounty `json:"top"`
}
// Status reports the watches, their highest bounties and the recent bans.
func (d *Daemon) Status() Status {
s := Status{Started: d.started}
for _, w := range d.watches {
s.Watches = append(s.Watches, WatchStatus{Name: w.Cfg.Name, Source: Describe(w.Cfg), List: w.Cfg.Ban.List, Hits: w.Cfg.Hits,
Within: w.Cfg.Within.String(), Lines: w.lines.Load(), Matches: w.matches.Load(), Bans: w.bans.Load(), Top: w.Tracker.Top(10, d.Now())})
}
s.Recent, s.Pending = d.state.Snapshot()
if len(s.Recent) > 50 {
s.Recent = s.Recent[:50]
}
return s
}
// serveStatus answers GET /status on the local socket (root and the
// guard user only: the socket is 0660 in /run/guard).
func (d *Daemon) serveStatus(ctx context.Context) error {
path := d.cfg.Socket
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
os.Remove(path)
ln, err := net.Listen("unix", path)
if err != nil {
return err
}
os.Chmod(path, 0o660)
mux := http.NewServeMux()
mux.HandleFunc("GET /status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(d.Status())
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go srv.Serve(ln)
go func() {
<-ctx.Done()
srv.Close()
os.Remove(path)
}()
return nil
}
// ---- never banned
// NeverBan holds the addresses never banned: prefixes, and host names
// looked up every ten minutes.
type NeverBan struct {
log *slog.Logger
prefixes []netip.Prefix
names []string
mu sync.RWMutex
resolved map[string][]netip.Addr
// Lookup resolves names (tests).
Lookup func(ctx context.Context, host string) ([]netip.Addr, error)
}
// NewNeverBan reads never_ban (checked by config).
func NewNeverBan(list []string, log *slog.Logger) *NeverBan {
n := &NeverBan{log: log, resolved: map[string][]netip.Addr{},
Lookup: func(ctx context.Context, host string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
}}
for _, e := range list {
if p, err := netip.ParsePrefix(e); err == nil {
n.prefixes = append(n.prefixes, p.Masked())
} else if a, err := netip.ParseAddr(e); err == nil {
n.prefixes = append(n.prefixes, netip.PrefixFrom(a.Unmap(), a.Unmap().BitLen()))
} else {
n.names = append(n.names, e)
}
}
return n
}
// Run looks the names up now and every ten minutes.
func (n *NeverBan) Run(ctx context.Context) {
if len(n.names) == 0 {
return
}
for {
n.resolve(ctx)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Minute):
}
}
}
func (n *NeverBan) resolve(ctx context.Context) {
for _, h := range n.names {
c, cancel := context.WithTimeout(ctx, 10*time.Second)
addrs, err := n.Lookup(c, h)
cancel()
if err != nil {
// Keep what it was: a name that can't be looked up now is still
// never banned as it was.
if n.log != nil {
n.log.Warn("can't look up a never_ban name; keeping its last addresses", "name", h, "err", err)
}
continue
}
for i := range addrs {
addrs[i] = addrs[i].Unmap()
}
n.mu.Lock()
n.resolved[h] = addrs
n.mu.Unlock()
}
}
// Why says why an address is never banned ("" when it may be).
func (n *NeverBan) Why(a netip.Addr) string {
a = a.Unmap()
switch {
case a.IsLoopback(), a.IsUnspecified(), a.IsMulticast(), a.IsLinkLocalUnicast():
return "loopback, link-local, multicast or unspecified"
}
for _, p := range n.prefixes {
if p.Contains(a) {
return "in never_ban " + p.String()
}
}
n.mu.RLock()
defer n.mu.RUnlock()
for h, addrs := range n.resolved {
for _, x := range addrs {
if x == a {
return "never_ban " + h
}
}
}
return ""
}
+179
View File
@@ -0,0 +1,179 @@
package daemon
import (
"context"
"errors"
"io"
"log/slog"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"guard/internal/config"
"guard/internal/housecarl"
"guard/internal/source"
"guard/internal/state"
)
type fakeHC struct {
mu sync.Mutex
bans []housecarl.BanInput
fail error
}
func (f *fakeHC) Ban(_ context.Context, list string, in housecarl.BanInput) (*housecarl.BanResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.fail != nil {
return nil, f.fail
}
f.bans = append(f.bans, in)
return &housecarl.BanResult{Bans: []housecarl.Ban{{Address: in.Address, List: list}}}, nil
}
func (f *fakeHC) BanList(_ context.Context, list string) (*housecarl.BanList, error) {
return &housecarl.BanList{Name: list, GroupV6: 64}, nil
}
func (f *fakeHC) setFail(err error) {
f.mu.Lock()
f.fail = err
f.mu.Unlock()
}
func (f *fakeHC) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.bans)
}
func (f *fakeHC) last() housecarl.BanInput {
f.mu.Lock()
defer f.mu.Unlock()
return f.bans[len(f.bans)-1]
}
type chanSource struct{ ch chan source.Line }
func (c *chanSource) Run(ctx context.Context, out chan<- source.Line) error {
for {
select {
case <-ctx.Done():
return nil
case l := <-c.ch:
out <- l
}
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("waited in vain: %s", what)
}
// TestDaemon: hits within the window ban, by the watch's ports and time;
// a repeat offender's ban is longer; IPv6 counts by /64; never_ban is
// never banned; bans wait while Housecarl can't be reached, and a
// refused one isn't sent again.
func TestDaemon(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Parse([]byte(`housecarl: {url: https://fw:8070, list: web}
never_ban: [198.51.100.0/24]
defaults: {hits: 3, within: 10m, ban: 1h, repeat: {factor: 2, max: 3h}}
watch:
- {name: ssh, preset: sshd, file: /var/log/auth.log, ban: {protocol: tcp, port: 22}}
state: ` + filepath.Join(dir, "state.json") + `
socket: ` + filepath.Join(dir, "guard.sock") + `
`))
if err != nil {
t.Fatal(err)
}
st, _ := state.Load(cfg.State)
hc := &fakeHC{}
d, err := New(cfg, hc, nil, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
d.RetryEvery = 50 * time.Millisecond
src := &chanSource{ch: make(chan source.Line)}
d.Watches()[0].Source = src
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { d.Run(ctx); close(done) }()
fail := func(addr string, n int) {
for i := 0; i < n; i++ {
src.ch <- source.Line{Text: "Failed password for root from " + addr + " port 4000 ssh2", Time: time.Now()}
}
}
fail("203.0.113.9", 2)
time.Sleep(50 * time.Millisecond)
if hc.count() != 0 {
t.Fatal("banned below the hits")
}
fail("203.0.113.9", 1)
waitFor(t, "the first ban", func() bool { return hc.count() == 1 })
if b := hc.last(); b.Address != "203.0.113.9" || b.Protocol != "tcp" || b.Ports != "22" || b.Time < 3590 || b.Time > 3600 || b.Source != "guard/ssh" ||
!strings.Contains(b.Reason, "Failed password") {
t.Errorf("ban %+v", b)
}
fail("203.0.113.9", 3)
waitFor(t, "the repeat ban", func() bool { return hc.count() == 2 })
if b := hc.last(); b.Time < 7190 || b.Time > 7200 {
t.Errorf("a repeat offender's second ban: %ds, want 2h", b.Time)
}
fail("203.0.113.9", 3)
waitFor(t, "the third ban", func() bool { return hc.count() == 3 })
if b := hc.last(); b.Time < 10790 || b.Time > 10800 {
t.Errorf("capped at max: %ds, want 3h", b.Time)
}
// IPv6, by /64.
for _, a := range []string{"2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2::3"} {
fail(a, 1)
}
waitFor(t, "the IPv6 ban", func() bool { return hc.count() == 4 })
// never_ban.
fail("198.51.100.7", 5)
time.Sleep(50 * time.Millisecond)
if hc.count() != 4 {
t.Errorf("never_ban banned: %+v", hc.last())
}
// Housecarl down: the ban waits, then goes.
hc.setFail(errors.New("connection refused"))
fail("203.0.113.20", 3)
time.Sleep(150 * time.Millisecond)
if _, pending := st.Snapshot(); hc.count() != 4 || len(pending) != 1 {
t.Errorf("while Housecarl is down: %d bans, %d pending", hc.count(), len(pending))
}
hc.setFail(nil)
waitFor(t, "the ban that waited", func() bool { return hc.count() == 5 && hc.last().Address == "203.0.113.20" })
// Refused: recorded, not sent again.
hc.setFail(&housecarl.Error{Status: 422, Code: "never_ban", Message: "203.0.113.30 isn't banned: it is inside"})
fail("203.0.113.30", 3)
waitFor(t, "the refusal recorded", func() bool {
recent, _ := st.Snapshot()
return len(recent) > 0 && strings.Contains(recent[0].Result, "inside")
})
time.Sleep(150 * time.Millisecond)
if _, pending := st.Snapshot(); len(pending) != 0 {
t.Errorf("a refused ban waits: %+v", pending)
}
s := d.Status()
if len(s.Watches) != 1 || s.Watches[0].Bans != 6 || s.Watches[0].Matches != 23 {
t.Errorf("status %+v", s.Watches)
}
cancel()
<-done
// The state outlives the daemon.
st2, err := state.Load(cfg.State)
if err != nil || st2.Repeats("203.0.113.9/32", time.Now().Add(-time.Hour)) != 3 {
t.Errorf("saved state: %v %+v", err, st2.Offences)
}
}
+253
View File
@@ -0,0 +1,253 @@
// Package housecarl is Guard's client for Housecarl's API: ban lists and
// their bans, and the log of dropped packets (Housecarl's docs/bans.md
// and docs/api.md).
package housecarl
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"guard/internal/version"
)
// Prefix starts every API path.
const Prefix = "/api/v1"
// Client talks to one Housecarl.
type Client struct {
base string
token string
http *http.Client
}
// Options say how to reach Housecarl.
type Options struct {
URL, Socket string
Token string
CACert string
Insecure bool
Timeout time.Duration
}
// New makes a client.
func New(o Options) (*Client, error) {
timeout := o.Timeout
if timeout == 0 {
timeout = 15 * time.Second
}
tr := http.DefaultTransport.(*http.Transport).Clone()
base := strings.TrimRight(o.URL, "/")
if o.Socket != "" {
sock := o.Socket
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
}
base = "http://housecarl"
}
if o.CACert != "" || o.Insecure {
cfg := &tls.Config{InsecureSkipVerify: o.Insecure}
if o.CACert != "" {
pem, err := os.ReadFile(o.CACert)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("%s holds no PEM certificate", o.CACert)
}
cfg.RootCAs = pool
}
tr.TLSClientConfig = cfg
}
return &Client{base: base, token: strings.TrimSpace(o.Token), http: &http.Client{Transport: tr, Timeout: timeout}}, nil
}
// Error is an error answer from Housecarl.
type Error struct {
Status int
Code string
Message string
}
func (e *Error) Error() string {
if e.Message == "" {
return fmt.Sprintf("Housecarl answered %d", e.Status)
}
return e.Message
}
// Refused reports whether Housecarl refused a request for good: asking
// again won't help (a bad request, no permission, an address never
// banned). Anything else (no answer, a server error) is worth retrying.
func Refused(err error) bool {
var he *Error
return errors.As(err, &he) && he.Status >= 400 && he.Status < 500 && he.Status != http.StatusTooManyRequests
}
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
u := c.base + Prefix + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
req.Header.Set("User-Agent", "guard/"+version.Short())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode >= 300 {
var eb struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.Unmarshal(data, &eb)
return &Error{Status: resp.StatusCode, Code: eb.Error.Code, Message: eb.Error.Message}
}
if out != nil && len(data) > 0 {
return json.Unmarshal(data, out)
}
return nil
}
// BanList is a Housecarl ban list (the fields Guard uses).
type BanList struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
DefaultTime int `json:"default_time"`
MaxTime int `json:"max_time"`
GroupV6 int `json:"group_v6"`
Disabled bool `json:"disabled"`
}
// V6Group is the IPv6 prefix length the list bans by.
func (b *BanList) V6Group() int {
if b.GroupV6 > 0 {
return b.GroupV6
}
return 64
}
// Ban is an active ban.
type Ban struct {
ID int64 `json:"id"`
List string `json:"list"`
Address string `json:"address"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Expires *time.Time `json:"expires"`
Timeout int `json:"timeout"`
Created time.Time `json:"created"`
Who string `json:"who"`
Source string `json:"source"`
Reason string `json:"reason"`
Existing bool `json:"existing"`
}
// BanInput asks for a ban.
type BanInput struct {
Address string `json:"address"`
Protocol string `json:"protocol,omitempty"`
Ports string `json:"ports,omitempty"`
Time int `json:"time,omitempty"`
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}
// BanResult is what a ban made.
type BanResult struct {
Bans []Ban `json:"bans"`
Closed int `json:"closed"`
}
// LogEntry is a packet in Housecarl's log.
type LogEntry struct {
Time time.Time `json:"time"`
Prefix string `json:"prefix"`
Protocol string `json:"protocol"`
Source string `json:"source"`
Dest string `json:"destination"`
SrcPort int `json:"source_port"`
DestPort int `json:"destination_port"`
Verdict string `json:"verdict"`
Shadow bool `json:"shadow"`
}
func listPath(list string) string { return "/banlists/" + url.PathEscape(list) }
// BanList reads a ban list.
func (c *Client) BanList(ctx context.Context, list string) (*BanList, error) {
var b BanList
if err := c.do(ctx, "GET", listPath(list), nil, nil, &b); err != nil {
return nil, err
}
return &b, nil
}
// Ban bans an address.
func (c *Client) Ban(ctx context.Context, list string, in BanInput) (*BanResult, error) {
var out BanResult
if err := c.do(ctx, "POST", listPath(list)+"/bans", nil, in, &out); err != nil {
return nil, err
}
return &out, nil
}
// Unban lifts every ban of an address in a list.
func (c *Client) Unban(ctx context.Context, list, addr string) (int, error) {
var out map[string]int
err := c.do(ctx, "DELETE", listPath(list)+"/bans", url.Values{"address": {addr}}, nil, &out)
return out["removed"], err
}
// Bans lists a list's active bans ("" for every list the token may read).
func (c *Client) Bans(ctx context.Context, list string) ([]Ban, error) {
path := "/bans"
if list != "" {
path = listPath(list) + "/bans"
}
var out []Ban
err := c.do(ctx, "GET", path, nil, nil, &out)
return out, err
}
// Log reads Housecarl's log of packets after a time, newest first.
func (c *Client) Log(ctx context.Context, after time.Time) ([]LogEntry, error) {
var out []LogEntry
err := c.do(ctx, "GET", "/log", url.Values{"after": {after.UTC().Format(time.RFC3339Nano)}, "limit": {"2000"}}, nil, &out)
return out, err
}
+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)
}
}
+423
View File
@@ -0,0 +1,423 @@
// Package source reads the lines Guard watches: log files followed like
// tail -F, the systemd journal (through journalctl), and Housecarl's log
// of dropped packets.
package source
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"guard/internal/housecarl"
)
// Line is one line of a log, with its time: when it was written (the
// journal, Housecarl's log) or read (a file).
type Line struct {
Text string
Time time.Time
// Addr is the address, for sources that know it without a pattern
// (Housecarl's log).
Addr netip.Addr
}
// Source sends its lines until ctx ends.
type Source interface {
Run(ctx context.Context, out chan<- Line) error
}
// maxLine is the longest line kept; longer ones are cut.
const maxLine = 64 << 10
// ---- files
// File follows a file, or every file a glob matches, from its end: lines
// written from now on. A file that appears later is read from its start;
// one that is rotated (replaced) is finished, then the new one is read;
// one that is truncated is read again from its start.
type File struct {
Pattern string
// Poll is how often the files are checked (default 1s).
Poll time.Duration
Log *slog.Logger
// Now gives the lines' time (tests).
Now func() time.Time
}
type tailer struct {
path string
f *os.File
info os.FileInfo
partial []byte
}
// Run follows the files.
func (s *File) Run(ctx context.Context, out chan<- Line) error {
poll := s.Poll
if poll == 0 {
poll = time.Second
}
now := s.Now
if now == nil {
now = time.Now
}
tails := map[string]*tailer{}
defer func() {
for _, t := range tails {
if t.f != nil {
t.f.Close()
}
}
}()
first, warned := true, false
for {
paths, _ := filepath.Glob(s.Pattern)
if len(paths) == 0 && !warned && s.Log != nil {
s.Log.Warn("no file to watch yet; checking every few seconds", "file", s.Pattern)
warned = true
}
for _, p := range paths {
if _, ok := tails[p]; !ok {
t := &tailer{path: p}
// Files there when Guard starts are read from their end,
// files that appear later from their start.
if err := t.open(first); err != nil {
if s.Log != nil {
s.Log.Warn("can't read the file", "file", p, "err", err)
}
continue
}
tails[p] = t
warned = false
}
}
first = false
for p, t := range tails {
if err := t.step(ctx, out, now); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
if s.Log != nil {
s.Log.Warn("reading the file failed", "file", p, "err", err)
}
t.f.Close()
delete(tails, p)
}
}
select {
case <-ctx.Done():
return nil
case <-time.After(poll):
}
}
}
func (t *tailer) open(atEnd bool) error {
f, err := os.Open(t.path)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
if atEnd {
if _, err := f.Seek(0, io.SeekEnd); err != nil {
f.Close()
return err
}
}
t.f, t.info, t.partial = f, info, nil
return nil
}
// step sends what was written since the last step, and follows a
// rotation or truncation.
func (t *tailer) step(ctx context.Context, out chan<- Line, now func() time.Time) error {
if err := t.drain(ctx, out, now); err != nil {
return err
}
info, err := os.Stat(t.path)
if err != nil {
// Rotated away, and the new file isn't there yet: keep the old.
return nil
}
if !os.SameFile(info, t.info) {
t.f.Close()
return t.open(false)
}
if pos, err := t.f.Seek(0, io.SeekCurrent); err == nil && info.Size() < pos {
// Truncated (copytruncate): from the start again.
if _, err := t.f.Seek(0, io.SeekStart); err != nil {
return err
}
t.partial = nil
t.info = info
}
return nil
}
func (t *tailer) drain(ctx context.Context, out chan<- Line, now func() time.Time) error {
buf := make([]byte, 32<<10)
for {
n, err := t.f.Read(buf)
if n > 0 {
data := append(t.partial, buf[:n]...)
for {
i := bytes.IndexByte(data, '\n')
if i < 0 {
break
}
line := data[:i]
data = data[i+1:]
if len(line) > maxLine {
line = line[:maxLine]
}
select {
case out <- Line{Text: strings.TrimRight(string(line), "\r"), Time: now()}:
case <-ctx.Done():
return ctx.Err()
}
}
if len(data) > maxLine {
data = data[:maxLine]
}
t.partial = append([]byte(nil), data...)
}
if err == io.EOF || n == 0 {
return nil
}
if err != nil {
return err
}
}
}
// ---- the journal
// Journal follows the systemd journal's entries of some units and/or
// syslog identifiers, through journalctl (no libsystemd: Guard has no
// cgo). It starts with what is written from now on; when journalctl ends
// it is started again after the last entry seen.
type Journal struct {
Units, Identifiers []string
// Command is journalctl (GUARD_JOURNALCTL overrides it, for tests).
Command string
Log *slog.Logger
}
// JournalCommand is the journalctl to run.
func JournalCommand() string {
if c := os.Getenv("GUARD_JOURNALCTL"); c != "" {
return c
}
return "journalctl"
}
// JournalArgs are journalctl's arguments: following from now, or after a
// cursor; or (follow false) the last n entries.
func JournalArgs(units, idents []string, follow bool, cursor string, n int) []string {
args := []string{"--output=json", "--no-pager"}
switch {
case !follow:
args = append(args, "--lines="+strconv.Itoa(n))
case cursor != "":
args = append(args, "--follow", "--lines=all", "--after-cursor="+cursor)
default:
args = append(args, "--follow", "--lines=0")
}
for _, u := range units {
args = append(args, "--unit="+u)
}
for _, i := range idents {
args = append(args, "--identifier="+i)
}
return args
}
// Run follows the journal.
func (s *Journal) Run(ctx context.Context, out chan<- Line) error {
cmd := s.Command
if cmd == "" {
cmd = JournalCommand()
}
cursor := ""
for {
c := exec.CommandContext(ctx, cmd, JournalArgs(s.Units, s.Identifiers, true, cursor, 0)...)
var stderr bytes.Buffer
c.Stderr = &stderr
stdout, err := c.StdoutPipe()
if err != nil {
return err
}
if err := c.Start(); err != nil {
return fmt.Errorf("starting %s: %w", cmd, err)
}
cursor = ReadJournal(ctx, stdout, out, cursor)
err = c.Wait()
if ctx.Err() != nil {
return nil
}
if s.Log != nil {
s.Log.Warn("journalctl ended; starting it again in 5 seconds", "err", err, "stderr", strings.TrimSpace(stderr.String()))
}
select {
case <-ctx.Done():
return nil
case <-time.After(5 * time.Second):
}
}
}
// ReadJournal sends the messages of journalctl's JSON output, and returns
// the last entry's cursor (or cursor, if there was none).
func ReadJournal(ctx context.Context, r io.Reader, out chan<- Line, cursor string) string {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 4<<20)
for sc.Scan() {
l, c, ok := ParseJournal(sc.Bytes())
if c != "" {
cursor = c
}
if !ok {
continue
}
select {
case out <- l:
case <-ctx.Done():
return cursor
}
}
return cursor
}
// ParseJournal reads one entry of journalctl --output=json: its message
// (a string, or bytes when it isn't UTF-8), time and cursor.
func ParseJournal(data []byte) (Line, string, bool) {
var e struct {
Message json.RawMessage `json:"MESSAGE"`
Cursor string `json:"__CURSOR"`
Realtime string `json:"__REALTIME_TIMESTAMP"`
}
if json.Unmarshal(data, &e) != nil {
return Line{}, "", false
}
var text string
if json.Unmarshal(e.Message, &text) != nil {
var b []byte
var ints []int
if json.Unmarshal(e.Message, &ints) != nil {
return Line{}, e.Cursor, false
}
for _, n := range ints {
b = append(b, byte(n))
}
text = string(b)
}
if len(text) > maxLine {
text = text[:maxLine]
}
t := time.Now()
if us, err := strconv.ParseInt(e.Realtime, 10, 64); err == nil {
t = time.UnixMicro(us)
}
return Line{Text: text, Time: t}, e.Cursor, true
}
// ---- Housecarl's log
// LogReader reads Housecarl's log of packets.
type LogReader interface {
Log(ctx context.Context, after time.Time) ([]housecarl.LogEntry, error)
}
// HousecarlDrops follows the packets Housecarl's rules dropped (or
// rejected) and logged, from now on. Protocol limits them ("tcp",
// "udp", "any"); Shadow counts shadow mode's, which weren't dropped.
type HousecarlDrops struct {
Client LogReader
Protocol string
Shadow bool
Poll time.Duration
Log *slog.Logger
}
// Run follows the log.
func (s *HousecarlDrops) Run(ctx context.Context, out chan<- Line) error {
poll := s.Poll
if poll == 0 {
poll = 2 * time.Second
}
after := time.Now()
failing := false
for {
entries, err := s.Client.Log(ctx, after)
switch {
case err != nil && !failing:
if s.Log != nil {
s.Log.Warn("reading Housecarl's log failed (the token needs log:read); trying again", "err", err)
}
failing = true
case err == nil:
failing = false
// Newest first: send them oldest first.
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
if e.Time.After(after) {
after = e.Time
}
if l, ok := s.line(e); ok {
select {
case out <- l:
case <-ctx.Done():
return nil
}
}
}
}
select {
case <-ctx.Done():
return nil
case <-time.After(poll):
}
}
}
func (s *HousecarlDrops) line(e housecarl.LogEntry) (Line, bool) {
if e.Verdict != "drop" && e.Verdict != "reject" || e.Shadow && !s.Shadow {
return Line{}, false
}
if p := strings.ToLower(s.Protocol); p != "any" && p != "" && e.Protocol != p {
return Line{}, false
}
a, err := netip.ParseAddr(e.Source)
if err != nil {
return Line{}, false
}
text := fmt.Sprintf("%s %s %s -> %s", strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(e.Prefix), ":")), e.Protocol,
hostPort(e.Source, e.SrcPort), hostPort(e.Dest, e.DestPort))
return Line{Text: text, Time: e.Time, Addr: a.Unmap()}, true
}
func hostPort(a string, p int) string {
if p == 0 {
return a
}
if strings.Contains(a, ":") {
return "[" + a + "]:" + strconv.Itoa(p)
}
return a + ":" + strconv.Itoa(p)
}
+158
View File
@@ -0,0 +1,158 @@
package source
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"guard/internal/housecarl"
)
// collect gathers lines until want of them came, or a timeout.
func collect(t *testing.T, ch <-chan Line, want int) []string {
t.Helper()
var got []string
deadline := time.After(5 * time.Second)
for len(got) < want {
select {
case l := <-ch:
got = append(got, l.Text)
case <-deadline:
t.Fatalf("got %d lines, want %d: %q", len(got), want, got)
}
}
return got
}
func appendTo(t *testing.T, path, text string) {
t.Helper()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
t.Fatal(err)
}
f.WriteString(text)
f.Close()
}
// TestFile: from the end of a file there at the start; a partial line
// waits for its end; rotation finishes the old file and reads the new
// from its start; truncation starts again; a file that appears later is
// read from its start.
func TestFile(t *testing.T) {
dir := t.TempDir()
log := filepath.Join(dir, "auth.log")
appendTo(t, log, "old line, not read\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan Line, 100)
go (&File{Pattern: filepath.Join(dir, "*.log"), Poll: 20 * time.Millisecond}).Run(ctx, ch)
time.Sleep(100 * time.Millisecond)
appendTo(t, log, "one\ntw")
time.Sleep(60 * time.Millisecond)
appendTo(t, log, "o\r\n")
if got := collect(t, ch, 2); strings.Join(got, ",") != "one,two" {
t.Errorf("appended: %q", got)
}
// Rotation: a last line in the old file, then a new file.
appendTo(t, log, "last of old\n")
os.Rename(log, log+".1")
appendTo(t, log, "first of new\n")
if got := collect(t, ch, 2); strings.Join(got, ",") != "last of old,first of new" {
t.Errorf("rotated: %q", got)
}
// Truncation (copytruncate): seen while the file is shorter than what
// was read.
time.Sleep(60 * time.Millisecond)
os.WriteFile(log, []byte("trunc\n"), 0o644)
if got := collect(t, ch, 1); got[0] != "trunc" {
t.Errorf("truncated: %q", got)
}
// A file that appears later.
appendTo(t, filepath.Join(dir, "other.log"), "new file\n")
if got := collect(t, ch, 1); got[0] != "new file" {
t.Errorf("new file: %q", got)
}
}
// TestJournal: journalctl's JSON through a fake journalctl, started
// again after the last cursor when it ends.
func TestJournal(t *testing.T) {
dir := t.TempDir()
args := filepath.Join(dir, "args")
fake := filepath.Join(dir, "journalctl")
os.WriteFile(fake, []byte(`#!/bin/sh
echo "$*" >> `+args+`
echo '{"MESSAGE":"Failed password for root from 203.0.113.9 port 22 ssh2","__CURSOR":"c1","__REALTIME_TIMESTAMP":"1761127200000000"}'
echo '{"MESSAGE":[104,105,255],"__CURSOR":"c2","__REALTIME_TIMESTAMP":"1761127201000000"}'
echo 'not json'
`), 0o755)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan Line, 100)
go (&Journal{Units: []string{"ssh.service"}, Identifiers: []string{"sshd", "sshd-session"}, Command: fake}).Run(ctx, ch)
got := collect(t, ch, 2)
if got[0] != "Failed password for root from 203.0.113.9 port 22 ssh2" || got[1] != "hi\xff" {
t.Errorf("lines %q", got)
}
// It ends; after 5 seconds it starts again after c2.
time.Sleep(5500 * time.Millisecond)
b, _ := os.ReadFile(args)
calls := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(calls) < 2 || !strings.Contains(calls[0], "--follow --lines=0 --unit=ssh.service --identifier=sshd --identifier=sshd-session") ||
!strings.Contains(calls[1], "--after-cursor=c2") {
t.Errorf("journalctl's arguments: %q", calls)
}
l, _, _ := ParseJournal([]byte(`{"MESSAGE":"x","__REALTIME_TIMESTAMP":"1761127200000000"}`))
if l.Time.Unix() != 1761127200 {
t.Errorf("time %v", l.Time)
}
}
type fakeLog struct{ entries []housecarl.LogEntry }
func (f *fakeLog) Log(_ context.Context, after time.Time) ([]housecarl.LogEntry, error) {
var out []housecarl.LogEntry
for _, e := range f.entries {
if e.Time.After(after) {
out = append([]housecarl.LogEntry{e}, out...) // newest first
}
}
return out, nil
}
// TestHousecarlDrops: drops and rejects of the protocol, not accepts or
// shadow mode's, each with its source address.
func TestHousecarlDrops(t *testing.T) {
now := time.Now().Add(time.Second)
f := &fakeLog{entries: []housecarl.LogEntry{
{Time: now, Prefix: "hc input #3 drop: ", Protocol: "tcp", Source: "203.0.113.9", SrcPort: 4000, Dest: "198.51.100.1", DestPort: 22, Verdict: "drop"},
{Time: now.Add(time.Millisecond), Protocol: "udp", Source: "203.0.113.10", Verdict: "drop"},
{Time: now.Add(2 * time.Millisecond), Protocol: "tcp", Source: "203.0.113.11", Verdict: "accept"},
{Time: now.Add(3 * time.Millisecond), Protocol: "tcp", Source: "203.0.113.12", Verdict: "drop", Shadow: true},
{Time: now.Add(4 * time.Millisecond), Protocol: "tcp", Source: "2001:db8::9", Verdict: "reject"},
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan Line, 100)
go (&HousecarlDrops{Client: f, Protocol: "tcp", Poll: 20 * time.Millisecond}).Run(ctx, ch)
var got []string
for len(got) < 2 {
select {
case l := <-ch:
got = append(got, l.Addr.String()+" "+l.Text)
case <-time.After(3 * time.Second):
t.Fatalf("lines %q", got)
}
}
if got[0] != "203.0.113.9 hc input #3 drop tcp 203.0.113.9:4000 -> 198.51.100.1:22" || !strings.HasPrefix(got[1], "2001:db8::9 ") {
t.Errorf("lines %q", got)
}
select {
case l := <-ch:
t.Errorf("more: %+v", l)
case <-time.After(100 * time.Millisecond):
}
}
+151
View File
@@ -0,0 +1,151 @@
// Package state keeps what Guard must remember across a restart: the
// bans it made (for repeat offenders' longer bans, and guard status),
// and the bans Housecarl hasn't taken yet.
package state
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"time"
)
// MaxRecent is how many recent bans are kept for guard status.
const MaxRecent = 200
// Ban is a ban Guard made (or is waiting to make).
type Ban struct {
Time time.Time `json:"time"`
Watch string `json:"watch"`
List string `json:"list"`
Address string `json:"address"`
// Group is what is counted for repeats: the address, or its IPv6
// prefix.
Group string `json:"group"`
Protocol string `json:"protocol,omitempty"`
Ports string `json:"ports,omitempty"`
Seconds int `json:"seconds"`
Reason string `json:"reason,omitempty"`
// Result is what Housecarl said: "banned", "renewed", or why not.
Result string `json:"result,omitempty"`
}
// State is Guard's memory.
type State struct {
mu sync.Mutex
path string
// Offences are the times each group was banned.
Offences map[string][]time.Time `json:"offences"`
Recent []Ban `json:"recent"`
Pending []Ban `json:"pending"`
}
// Load reads the state (an empty one if the file isn't there).
func Load(path string) (*State, error) {
s := &State{path: path, Offences: map[string][]time.Time{}}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, s); err != nil {
return nil, err
}
if s.Offences == nil {
s.Offences = map[string][]time.Time{}
}
return s, nil
}
// Save writes the state atomically.
func (s *State) Save() error {
s.mu.Lock()
data, err := json.Marshal(s)
s.mu.Unlock()
if err != nil || s.path == "" {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o640); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// Repeats counts a group's bans since a time.
func (s *State) Repeats(group string, since time.Time) int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, t := range s.Offences[group] {
if t.After(since) {
n++
}
}
return n
}
// Banned records a ban Housecarl made.
func (s *State) Banned(b Ban) {
s.mu.Lock()
defer s.mu.Unlock()
s.Offences[b.Group] = append(s.Offences[b.Group], b.Time)
s.recent(b)
}
// Failed records a ban Housecarl refused.
func (s *State) Failed(b Ban) {
s.mu.Lock()
defer s.mu.Unlock()
s.recent(b)
}
func (s *State) recent(b Ban) {
s.Recent = append(s.Recent, b)
if len(s.Recent) > MaxRecent {
s.Recent = append([]Ban(nil), s.Recent[len(s.Recent)-MaxRecent:]...)
}
}
// SetPending replaces the bans waiting for Housecarl.
func (s *State) SetPending(p []Ban) {
s.mu.Lock()
defer s.mu.Unlock()
s.Pending = append([]Ban(nil), p...)
}
// Snapshot returns the recent bans (newest first) and the pending ones.
func (s *State) Snapshot() (recent, pending []Ban) {
s.mu.Lock()
defer s.mu.Unlock()
for i := len(s.Recent) - 1; i >= 0; i-- {
recent = append(recent, s.Recent[i])
}
return recent, append([]Ban(nil), s.Pending...)
}
// Forget drops offences older than a time.
func (s *State) Forget(before time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
for g, ts := range s.Offences {
var keep []time.Time
for _, t := range ts {
if t.After(before) {
keep = append(keep, t)
}
}
if len(keep) == 0 {
delete(s.Offences, g)
} else {
s.Offences[g] = keep
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package version holds Guard's version, set at build time with
//
// go build -ldflags "-X guard/internal/version.Version=1.2.3 -X guard/internal/version.Tag=v1.2.3"
//
// The Makefile sets Version from the VERSION file, and Tag only when the
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
// is changed. Only such a build reports a version number. Other builds
// are development builds, identified by their commit (which Go records
// from git by itself) and by where they stand relative to the last tag
// (Describe, from git describe).
package version
import (
"regexp"
"runtime/debug"
"strconv"
"strings"
"sync"
)
// Version is the Guard version (the VERSION file).
var Version = "0.1.0-dev"
// Tag is the release tag the build is of ("" when it isn't a release).
var Tag = ""
// Commit is the git commit the build is of. It is read from the build
// information when not set at build time.
var Commit = ""
// Describe is 'git describe --tags --long' of the build's commit
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
// commits a development build is past the last tag.
var Describe = ""
// Repo is Guard's source repository.
const Repo = "https://gitea.adbraun.lol/adam/guard"
// Build describes what a binary was built from.
type Build struct {
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Commit string `json:"commit,omitempty"`
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
// Since is the last tag before a development build's commit, and
// Ahead how many commits the build is past it (0: the tag's commit,
// with uncommitted changes).
Since string `json:"since,omitempty"`
Ahead int `json:"ahead,omitempty"`
// Label is the short form to show: the tag of a release, otherwise
// the abbreviated commit (or the version when the commit is unknown).
Label string `json:"label"`
// URL is the release or commit in the repository.
URL string `json:"url"`
}
var info = sync.OnceValue(func() Build {
commit, modified := Commit, false
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
if commit == "" {
commit = s.Value
}
case "vcs.modified":
modified = s.Value == "true"
}
}
}
b := describe(Version, Tag, commit, modified)
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
return b
})
// parseDescribe splits git describe --long output, if it is of commit.
func parseDescribe(d, commit string) (since string, ahead int) {
m := describeRe.FindStringSubmatch(d)
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
return "", 0
}
n, _ := strconv.Atoi(m[2])
return m[1], n
}
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
func describe(version, tag, commit string, modified bool) Build {
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
switch {
case tag != "":
b.Modified = false // the Makefile only sets Tag for clean trees
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
case commit != "":
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
if modified {
b.Label += "-modified"
}
default:
b.Label, b.URL = version, Repo
}
return b
}
// Info describes this build.
func Info() Build { return info() }
// String is the version for logs and 'version' commands. Only a release
// build gives a version ("0.2.0"); any other build says it's a
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
func String() string { return Info().String() }
func (b Build) String() string {
switch {
case b.Tag != "":
return b.Version
case b.Commit == "":
// No git information (built from a source archive).
return b.Version + " (source build)"
}
s := "dev " + b.Label
switch {
case b.Since == "":
case b.Ahead == 1:
s += " (1 commit after " + b.Since + ")"
case b.Ahead > 1:
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
case b.Modified:
s += " (" + b.Since + " with uncommitted changes)"
default:
s += " (at " + b.Since + ")"
}
return s
}
// Short is the version for a User-Agent: the version of a release,
// otherwise the label ("a1b2c3d4e5f6").
func Short() string {
if b := Info(); b.Tag == "" && b.Commit != "" {
return b.Label
}
return Version
}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}