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,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)
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user