check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
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: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
|
": no such file or directory",
|
|
notDatabase: "SWW_WAF_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": "SWW_WAF_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)
|
|
}
|
|
}
|