check / check (push) Waiting to run
SWWAF_LOOKUP_SOURCE=file looks every client up in the file SWWAF_LOOKUP_DB_PATH names, without GeoJS. file without the path, the path with another source, or a file that cannot be read stops the start. The file is read whole into memory, so overwriting it in place cannot disturb a lookup, and read again 2 seconds after its last change; a replacement that cannot be read is logged, counted and sent as a file_error alert, and the old one stays in use. Metrics give when it was read and the failed reads. Tests write their databases through internal/lookup/lookuptest. Deviation: go.mod and go.sum written by hand; go runs only through make. Judgement call: the 2-second wait, as the rule files have. Model: opus-5-5
380 lines
9.9 KiB
Go
380 lines
9.9 KiB
Go
package lookup
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/maxmind/mmdbwriter/mmdbtype"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
|
)
|
|
|
|
// testNetblock is the netblock the tests' lookup databases place, and
|
|
// testClient a client in it.
|
|
const (
|
|
testNetblock = "203.0.113.0/24"
|
|
testClient = "203.0.113.9/32"
|
|
)
|
|
|
|
func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
germany := lookuptest.Network{ASN: "AS64496", ASName: "Example Net", Country: "DE"}
|
|
northKorea := lookuptest.Network{ASN: "AS64511", ASName: "Other Net", Country: "KP"}
|
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
|
testNetblock: germany,
|
|
"2001:db8::/32": northKorea,
|
|
})
|
|
|
|
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
|
|
|
f, err := OpenFile(FileParams{
|
|
Path: path,
|
|
Now: func() time.Time { return now },
|
|
ProcessLog: slog.New(slog.DiscardHandler),
|
|
Alerts: newQueue(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", path, err)
|
|
}
|
|
|
|
for client, want := range map[string]lookuptest.Network{
|
|
testClient: germany,
|
|
// An IPv6 client is its /64.
|
|
"2001:db8:1:2::/64": northKorea,
|
|
"198.51.100.7/32": {},
|
|
} {
|
|
prefix := netip.MustParsePrefix(client)
|
|
|
|
got := f.LookUp(prefix)
|
|
if got != (Answer{
|
|
Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country,
|
|
Answered: now,
|
|
}) {
|
|
t.Errorf("%s has the answer %+v, want %+v, answered %s", client, got, want, now)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecordThatCannotBeReadPlacesTheClientNowhere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The AS number is a number, where a string belongs. The writer writes
|
|
// a record's fields in the order of their names, so as_name is read
|
|
// before the AS number fails.
|
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
|
lookuptest.WriteRecords(t, path, map[string]mmdbtype.Map{
|
|
testNetblock: {
|
|
"asn": mmdbtype.Uint32(64496),
|
|
"as_name": mmdbtype.String("Example Net"),
|
|
"country_code": mmdbtype.String("DE"),
|
|
},
|
|
})
|
|
|
|
f := openFile(t, path, newQueue())
|
|
|
|
answer := f.LookUp(netip.MustParsePrefix(testClient))
|
|
if answer.ASN != "" || answer.ASName != "" || answer.Country != "" {
|
|
t.Errorf("%s is placed %+v, want nowhere", testClient, answer)
|
|
}
|
|
}
|
|
|
|
func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
missing := filepath.Join(dir, "missing.mmdb")
|
|
notDatabase := filepath.Join(dir, "not.mmdb")
|
|
writeFile(t, notDatabase, "not a lookup database\n")
|
|
|
|
for path, want := range map[string]string{
|
|
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
|
": no such file or directory",
|
|
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
|
} {
|
|
_, err := OpenFile(FileParams{
|
|
Path: path,
|
|
Now: time.Now,
|
|
ProcessLog: slog.New(slog.DiscardHandler),
|
|
Alerts: newQueue(),
|
|
})
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("opening %s failed with %v, want %s", path, err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The tests below run readAfterChanges in a synctest bubble, where time is
|
|
// a clock of the test's own: time.Sleep moves it on at once, and
|
|
// synctest.Wait returns once readAfterChanges waits again, so that every
|
|
// reading due by then is done. The test sends the changes itself, as the
|
|
// watch of a directory cannot run in a bubble.
|
|
|
|
func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
synctest.Test(t, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "ipinfo_lite.mmdb")
|
|
writeDatabase(t, path, "DE")
|
|
|
|
queue := newQueue()
|
|
f := openFile(t, path, queue)
|
|
changes := watch(t, f)
|
|
|
|
other := filepath.Join(dir, "replacement.mmdb")
|
|
writeDatabase(t, other, "KP")
|
|
|
|
replacement, err := os.ReadFile(other) //nolint:gosec // a file the test wrote
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", other, err)
|
|
}
|
|
|
|
// The file in use is overwritten in place, and keeps giving what
|
|
// it gave. Its first part alone is not a .mmdb file.
|
|
file, err := os.Create(path) //nolint:gosec // a file the test wrote
|
|
if err != nil {
|
|
t.Fatalf("create %s: %v", path, err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = file.Close()
|
|
}()
|
|
|
|
half := len(replacement) / 2
|
|
write(t, file, replacement[:half])
|
|
|
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
|
|
|
time.Sleep(quietTime - time.Nanosecond)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "DE")
|
|
|
|
// The second part starts the wait again.
|
|
write(t, file, replacement[half:])
|
|
|
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
|
|
|
time.Sleep(quietTime - time.Nanosecond)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "DE")
|
|
|
|
time.Sleep(time.Nanosecond)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "KP")
|
|
|
|
if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 {
|
|
t.Errorf("read at %s, with %d failures; want read now, with none",
|
|
f.LastRead(), f.ReadFailures())
|
|
}
|
|
|
|
wantAlerts(t, queue)
|
|
})
|
|
}
|
|
|
|
func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
synctest.Test(t, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
|
writeDatabase(t, path, "DE")
|
|
|
|
queue := newQueue()
|
|
f := openFile(t, path, queue)
|
|
read := f.LastRead()
|
|
changes := watch(t, f)
|
|
|
|
writeFile(t, path, "not a lookup database\n")
|
|
|
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
|
|
|
// Long after, the replacement has been read once.
|
|
time.Sleep(time.Hour)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "DE")
|
|
|
|
if !f.LastRead().Equal(read) || f.ReadFailures() != 1 {
|
|
t.Errorf("read at %s, with %d failures; want read at %s, with one",
|
|
f.LastRead(), f.ReadFailures(), read)
|
|
}
|
|
|
|
wantAlerts(t, queue, alerts.Alert{
|
|
Time: read.Add(quietTime),
|
|
Event: alerts.EventFileError,
|
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
|
Detail: map[string]any{
|
|
"file": path,
|
|
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
|
"error opening database: invalid MaxMind DB file",
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestChangeOfAnotherFileInTheDirectoryIsNoReplacement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
synctest.Test(t, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "ipinfo_lite.mmdb")
|
|
writeDatabase(t, path, "DE")
|
|
|
|
f := openFile(t, path, newQueue())
|
|
changes := watch(t, f)
|
|
|
|
// The wait that starts with the watch ends with a reading.
|
|
time.Sleep(quietTime)
|
|
synctest.Wait()
|
|
writeDatabase(t, path, "KP")
|
|
|
|
changes <- fsnotify.Event{Name: filepath.Join(dir, "other.mmdb"), Op: fsnotify.Create}
|
|
|
|
time.Sleep(quietTime)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "DE")
|
|
|
|
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
|
|
|
time.Sleep(quietTime)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "KP")
|
|
})
|
|
}
|
|
|
|
func TestReplacementSavedBeforeTheWatchStartsIsRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
synctest.Test(t, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
|
writeDatabase(t, path, "DE")
|
|
|
|
f := openFile(t, path, newQueue())
|
|
|
|
// Saved after OpenFile read the file, and before its directory was
|
|
// watched, so that no change is seen for it.
|
|
writeDatabase(t, path, "KP")
|
|
watch(t, f)
|
|
time.Sleep(quietTime)
|
|
synctest.Wait()
|
|
wantCountry(t, f, "KP")
|
|
})
|
|
}
|
|
|
|
// newQueue returns a queue of alerts for a webhook that is never sent
|
|
// them, so that they wait in it for the test to look at.
|
|
func newQueue() *alerts.Queue {
|
|
return alerts.New(alerts.Params{
|
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
|
Events: alerts.Events(),
|
|
Cooldown: 15 * time.Minute,
|
|
Now: time.Now,
|
|
})
|
|
}
|
|
|
|
// writeDatabase writes a lookup database at path that places testNetblock
|
|
// in country, and no other address.
|
|
func writeDatabase(t *testing.T, path, country string) {
|
|
t.Helper()
|
|
|
|
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
|
testNetblock: {ASN: "AS64496", ASName: "Example Net", Country: country},
|
|
})
|
|
}
|
|
|
|
// openFile opens the lookup database at path, which raises its alerts to
|
|
// queue.
|
|
func openFile(t *testing.T, path string, queue *alerts.Queue) *File {
|
|
t.Helper()
|
|
|
|
f, err := OpenFile(FileParams{
|
|
Path: path,
|
|
Now: time.Now,
|
|
ProcessLog: slog.New(slog.DiscardHandler),
|
|
Alerts: queue,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", path, err)
|
|
}
|
|
|
|
return f
|
|
}
|
|
|
|
// watch runs f's readAfterChanges until the test ends, and returns the
|
|
// channel that sends it changes.
|
|
func watch(t *testing.T, f *File) chan<- fsnotify.Event {
|
|
t.Helper()
|
|
|
|
changes := make(chan fsnotify.Event)
|
|
ctx, stop := context.WithCancel(t.Context())
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
f.readAfterChanges(ctx, changes, nil)
|
|
close(stopped)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
stop()
|
|
<-stopped
|
|
})
|
|
|
|
return changes
|
|
}
|
|
|
|
// wantCountry checks the country f gives testClient.
|
|
func wantCountry(t *testing.T, f *File, want string) {
|
|
t.Helper()
|
|
|
|
got := f.LookUp(netip.MustParsePrefix(testClient)).Country
|
|
if got != want {
|
|
t.Errorf("%s is in %q, want %q", testClient, got, want)
|
|
}
|
|
}
|
|
|
|
// wantAlerts checks the alerts waiting in queue, and that it held none
|
|
// back.
|
|
func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) {
|
|
t.Helper()
|
|
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != len(want) || (len(want) > 0 && !reflect.DeepEqual(waiting, want)) {
|
|
t.Errorf("alerts waiting %+v, want %+v", waiting, want)
|
|
}
|
|
|
|
if queue.Suppressed() != 0 {
|
|
t.Errorf("%d alerts held back, want none", queue.Suppressed())
|
|
}
|
|
}
|
|
|
|
// writeFile writes content to the file at path.
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
|
|
err := os.WriteFile(path, []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// write writes data to the end of file.
|
|
func write(t *testing.T, file *os.File, data []byte) {
|
|
t.Helper()
|
|
|
|
_, err := file.Write(data)
|
|
if err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|