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>
159 lines
5.4 KiB
Go
159 lines
5.4 KiB
Go
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):
|
|
}
|
|
}
|