initial commit
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/owen/vibedns/internal/app"
|
||||
"github.com/owen/vibedns/internal/auditlog"
|
||||
"github.com/owen/vibedns/internal/config"
|
||||
"github.com/owen/vibedns/internal/database"
|
||||
"github.com/owen/vibedns/internal/models"
|
||||
)
|
||||
|
||||
// newTestApp builds a fully wired application against a temporary database,
|
||||
// without binding any listener.
|
||||
func newTestApp(t *testing.T) *app.App {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
db, err := database.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if _, err := db.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
boot := config.DefaultBootstrap()
|
||||
boot.DBPath = path
|
||||
|
||||
a, err := app.New(ctx, boot, db, log)
|
||||
if err != nil {
|
||||
t.Fatalf("build app: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func testActor() auditlog.Actor {
|
||||
return auditlog.Actor{Name: "test", Source: auditlog.SourceCLI, ClientIP: "127.0.0.1"}
|
||||
}
|
||||
|
||||
// TestZoneLifecycleAndResolution walks the path an operator actually takes:
|
||||
// create a zone, add records, and confirm the DNS engine serves them.
|
||||
func TestZoneLifecycleAndResolution(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{
|
||||
Name: "example.com",
|
||||
Description: "integration test zone",
|
||||
AdminEmail: "hostmaster@example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create zone: %v", err)
|
||||
}
|
||||
if zone.Name != "example.com." {
|
||||
t.Errorf("zone name = %q, want the normalised form", zone.Name)
|
||||
}
|
||||
|
||||
records := []app.RecordInput{
|
||||
{Name: "@", Type: "A", Data: "192.0.2.10"},
|
||||
{Name: "www", Type: "CNAME", Data: "example.com."},
|
||||
{Name: "mail", Type: "A", Data: "192.0.2.20"},
|
||||
{Name: "@", Type: "MX", Data: "10 mail.example.com."},
|
||||
{Name: "txt", Type: "TXT", Fields: map[string]string{"text": "hello world"}},
|
||||
}
|
||||
for _, in := range records {
|
||||
if _, err := a.CreateRecord(ctx, testActor(), zone.ID, in); err != nil {
|
||||
t.Fatalf("create record %s %s: %v", in.Name, in.Type, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The snapshot is rebuilt on demand rather than waiting for the debounce.
|
||||
if err := a.Reload(ctx); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
snap := a.Snapshot()
|
||||
if snap.ZoneCount != 1 {
|
||||
t.Fatalf("indexed zones = %d, want 1", snap.ZoneCount)
|
||||
}
|
||||
if len(snap.Problems) != 0 {
|
||||
t.Errorf("build problems: %v", snap.Problems)
|
||||
}
|
||||
|
||||
client := netip.MustParseAddr("127.0.0.1")
|
||||
tests := []struct {
|
||||
name string
|
||||
qname string
|
||||
qtype uint16
|
||||
rcode int
|
||||
wantIn string
|
||||
answers int
|
||||
}{
|
||||
{"apex A", "example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.10", 1},
|
||||
{"host A", "mail.example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.20", 1},
|
||||
{"CNAME is followed", "www.example.com.", dns.TypeA, dns.RcodeSuccess, "192.0.2.10", 2},
|
||||
{"MX", "example.com.", dns.TypeMX, dns.RcodeSuccess, "mail.example.com.", 1},
|
||||
{"TXT", "txt.example.com.", dns.TypeTXT, dns.RcodeSuccess, "hello world", 1},
|
||||
{"NODATA", "mail.example.com.", dns.TypeTXT, dns.RcodeSuccess, "", 0},
|
||||
{"NXDOMAIN", "missing.example.com.", dns.TypeA, dns.RcodeNameError, "", 0},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
msg, source, err := a.DNS.Resolve(ctx, tc.qname, tc.qtype, client, false)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if msg.Rcode != tc.rcode {
|
||||
t.Errorf("rcode = %s, want %s", dns.RcodeToString[msg.Rcode], dns.RcodeToString[tc.rcode])
|
||||
}
|
||||
if len(msg.Answer) != tc.answers {
|
||||
t.Errorf("answers = %d, want %d: %v", len(msg.Answer), tc.answers, msg.Answer)
|
||||
}
|
||||
if source != models.SourceAuthoritative {
|
||||
t.Errorf("source = %q, want authoritative", source)
|
||||
}
|
||||
if tc.wantIn != "" {
|
||||
var found bool
|
||||
for _, rr := range msg.Answer {
|
||||
if strings.Contains(rr.String(), tc.wantIn) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no answer contains %q: %v", tc.wantIn, msg.Answer)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCNAMEConflictIsRejected(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("create zone: %v", err)
|
||||
}
|
||||
|
||||
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
|
||||
app.RecordInput{Name: "www", Type: "A", Data: "192.0.2.1"}); err != nil {
|
||||
t.Fatalf("create A: %v", err)
|
||||
}
|
||||
|
||||
// A CNAME cannot coexist with the A record already at that name.
|
||||
_, err = a.CreateRecord(ctx, testActor(), zone.ID,
|
||||
app.RecordInput{Name: "www", Type: "CNAME", Data: "other.example.com."})
|
||||
if err == nil {
|
||||
t.Fatal("expected the conflicting CNAME to be rejected")
|
||||
}
|
||||
if app.StatusOf(err) != 400 {
|
||||
t.Errorf("status = %d, want 400", app.StatusOf(err))
|
||||
}
|
||||
|
||||
// And a CNAME at the apex is always wrong.
|
||||
_, err = a.CreateRecord(ctx, testActor(), zone.ID,
|
||||
app.RecordInput{Name: "@", Type: "CNAME", Data: "other.example.com."})
|
||||
if err == nil {
|
||||
t.Error("expected an apex CNAME to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseZoneCreationFromCIDR(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{
|
||||
CIDR: "192.168.1.0/24",
|
||||
Kind: "reverse4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create reverse zone: %v", err)
|
||||
}
|
||||
if zone.Name != "1.168.192.in-addr.arpa." {
|
||||
t.Errorf("zone name = %q, want 1.168.192.in-addr.arpa.", zone.Name)
|
||||
}
|
||||
|
||||
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
|
||||
app.RecordInput{Name: "10", Type: "PTR", Data: "host.example.com."}); err != nil {
|
||||
t.Fatalf("create PTR: %v", err)
|
||||
}
|
||||
if err := a.Reload(ctx); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
msg, _, err := a.DNS.Resolve(ctx, "10.1.168.192.in-addr.arpa.", dns.TypePTR,
|
||||
netip.MustParseAddr("127.0.0.1"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve PTR: %v", err)
|
||||
}
|
||||
if len(msg.Answer) != 1 {
|
||||
t.Fatalf("PTR answers = %d, want 1", len(msg.Answer))
|
||||
}
|
||||
if ptr, ok := msg.Answer[0].(*dns.PTR); !ok || ptr.Ptr != "host.example.com." {
|
||||
t.Errorf("PTR answer = %v", msg.Answer[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyBlocking exercises the filtering path end to end: import a
|
||||
// blocklist, attach it to a policy and a network, and confirm the DNS engine
|
||||
// blocks a matching query from a client in that network.
|
||||
func TestPolicyBlocking(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
list, err := a.CreateDomainList(ctx, testActor(), app.ListInput{
|
||||
Kind: models.KindBlacklist, Name: "Test Blocks",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create list: %v", err)
|
||||
}
|
||||
|
||||
summary, err := a.ImportDomains(ctx, testActor(), list.ID,
|
||||
strings.NewReader("0.0.0.0 ads.example\ntracker.example.net\n# a comment\n"), true)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if summary.Imported != 2 {
|
||||
t.Fatalf("imported = %d, want 2", summary.Imported)
|
||||
}
|
||||
|
||||
policy, err := a.CreatePolicy(ctx, testActor(), app.PolicyInput{
|
||||
Name: "Test Policy", BlockAction: "nxdomain", ListIDs: []int64{list.ID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
if _, err := a.CreateNetwork(ctx, testActor(), app.NetworkInput{
|
||||
Name: "Test Net", CIDR: "100.64.30.0/24", PolicyIDs: []int64{policy.ID},
|
||||
}); err != nil {
|
||||
t.Fatalf("create network: %v", err)
|
||||
}
|
||||
|
||||
if err := a.Reload(ctx); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
inNetwork := netip.MustParseAddr("100.64.30.5")
|
||||
outside := netip.MustParseAddr("192.0.2.1")
|
||||
|
||||
// A blocked name from inside the network.
|
||||
msg, source, err := a.DNS.Resolve(ctx, "ads.example.", dns.TypeA, inNetwork, false)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if msg.Rcode != dns.RcodeNameError {
|
||||
t.Errorf("rcode = %s, want NXDOMAIN", dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
if source != models.SourceBlocked {
|
||||
t.Errorf("source = %q, want blocked", source)
|
||||
}
|
||||
|
||||
// A subdomain of a blocked name is covered without being stored.
|
||||
msg, _, _ = a.DNS.Resolve(ctx, "cdn.ads.example.", dns.TypeA, inNetwork, false)
|
||||
if msg.Rcode != dns.RcodeNameError {
|
||||
t.Errorf("subdomain rcode = %s, want NXDOMAIN", dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
|
||||
// The same name from a client outside the network is not blocked. With no
|
||||
// upstream reachable in a test it will fail to resolve, but it must not be
|
||||
// blocked, and it must not be refused for a private client.
|
||||
_, source, _ = a.DNS.Resolve(ctx, "ads.example.", dns.TypeA, outside, false)
|
||||
if source == models.SourceBlocked {
|
||||
t.Error("a client outside the configured network was filtered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecursionIsRefusedByDefaultForPublicClients is the open-resolver guard.
|
||||
func TestRecursionIsRefusedForClientsOutsideTheACL(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A public address is not in the default private-network ACL.
|
||||
public := netip.MustParseAddr("203.0.113.50")
|
||||
msg, source, err := a.DNS.Resolve(ctx, "example.org.", dns.TypeA, public, false)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if msg.Rcode != dns.RcodeRefused {
|
||||
t.Errorf("rcode = %s, want REFUSED for a client outside the recursion ACL",
|
||||
dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
if source != models.SourceRefused {
|
||||
t.Errorf("source = %q, want refused", source)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthoritativeAnswersSurviveRecursionDenial: a client that may not
|
||||
// recurse must still get answers for zones we are authoritative for.
|
||||
func TestAuthoritativeAnswersWorkWithoutRecursionRights(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "internal.example"})
|
||||
if err != nil {
|
||||
t.Fatalf("create zone: %v", err)
|
||||
}
|
||||
if _, err := a.CreateRecord(ctx, testActor(), zone.ID,
|
||||
app.RecordInput{Name: "@", Type: "A", Data: "192.0.2.1"}); err != nil {
|
||||
t.Fatalf("create record: %v", err)
|
||||
}
|
||||
if err := a.Reload(ctx); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
|
||||
public := netip.MustParseAddr("203.0.113.50")
|
||||
msg, source, err := a.DNS.Resolve(ctx, "internal.example.", dns.TypeA, public, false)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if msg.Rcode != dns.RcodeSuccess {
|
||||
t.Errorf("rcode = %s, want NOERROR: an authoritative zone must answer "+
|
||||
"even when the client may not recurse", dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
if source != models.SourceAuthoritative {
|
||||
t.Errorf("source = %q, want authoritative", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsValidationRejectsOpenResolver(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
next := a.Settings()
|
||||
next.Resolver.AllowNetworks = nil // would deny everyone
|
||||
if err := a.SaveSettings(ctx, testActor(), app.GroupResolver, next); err == nil {
|
||||
t.Error("an empty recursion ACL with recursion enabled should be rejected")
|
||||
}
|
||||
|
||||
next = a.Settings()
|
||||
next.Resolver.Upstreams = nil
|
||||
if err := a.SaveSettings(ctx, testActor(), app.GroupResolver, next); err == nil {
|
||||
t.Error("enabling recursion with no upstreams should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigExportOmitsSecrets(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := a.CreateAPIToken(ctx, testActor(), "test-token", ""); err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := a.WriteConfigExport(ctx, &buf, false); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
for _, forbidden := range []string{"argon2id", "password_hash", "token_hash", "csrf_key", "vibedns_"} {
|
||||
if strings.Contains(out, forbidden) {
|
||||
t.Errorf("the configuration export contains %q, which must never leave the server", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogRecordsChanges(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "audited.example"}); err != nil {
|
||||
t.Fatalf("create zone: %v", err)
|
||||
}
|
||||
|
||||
entries, total, err := a.AuditLogs(ctx, database.AuditFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("read audit log: %v", err)
|
||||
}
|
||||
if total == 0 {
|
||||
t.Fatal("no audit entry was recorded for a zone creation")
|
||||
}
|
||||
var found bool
|
||||
for _, e := range entries {
|
||||
if e.Action == "zone.create" && e.ObjectName == "audited.example." {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no zone.create entry found in %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkRecordOperations(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
zone, err := a.CreateZone(ctx, testActor(), app.ZoneInput{Name: "bulk.example"})
|
||||
if err != nil {
|
||||
t.Fatalf("create zone: %v", err)
|
||||
}
|
||||
|
||||
var ids []int64
|
||||
for _, name := range []string{"a", "b", "c"} {
|
||||
rec, err := a.CreateRecord(ctx, testActor(), zone.ID,
|
||||
app.RecordInput{Name: name, Type: "A", Data: "192.0.2.1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
ids = append(ids, rec.ID)
|
||||
}
|
||||
|
||||
n, err := a.BulkRecords(ctx, testActor(), zone.ID, ids, app.BulkDisable)
|
||||
if err != nil {
|
||||
t.Fatalf("bulk disable: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("disabled %d, want 3", n)
|
||||
}
|
||||
|
||||
// Disabled records must not be served.
|
||||
if err := a.Reload(ctx); err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
msg, _, _ := a.DNS.Resolve(ctx, "a.bulk.example.", dns.TypeA, netip.MustParseAddr("127.0.0.1"), false)
|
||||
if len(msg.Answer) != 0 {
|
||||
t.Errorf("a disabled record was still served: %v", msg.Answer)
|
||||
}
|
||||
|
||||
n, err = a.BulkRecords(ctx, testActor(), zone.ID, ids, app.BulkDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("bulk delete: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("deleted %d, want 3", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user