package dnsd import ( "database/sql" "net" "path/filepath" "testing" "github.com/miekg/dns" "penguins.lan/database" "penguins.lan/utils" ) const ( testUser = "tux" testMac = "aa:bb:cc:dd:ee:ff" testMacLbl = "aabbccddeeff" testIP = "192.168.8.50" testZone = "penguins.lan." testApexIP = "10.0.0.1" testDevName = testMacLbl + "." + testUser + "." + testZone ) // capWriter captures the reply a Handler writes instead of sending it. type capWriter struct{ msg *dns.Msg } func (c *capWriter) WriteMsg(m *dns.Msg) error { c.msg = m; return nil } func (c *capWriter) Write([]byte) (int, error) { return 0, nil } func (c *capWriter) Close() error { return nil } func (c *capWriter) TsigStatus() error { return nil } func (c *capWriter) TsigTimersOnly(bool) {} func (c *capWriter) Hijack() {} func (c *capWriter) LocalAddr() net.Addr { return &net.UDPAddr{} } func (c *capWriter) RemoteAddr() net.Addr { return &net.UDPAddr{} } func testDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "d.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if _, err := database.Migrate(db); err != nil { t.Fatal(err) } return db } func testHandler(db *sql.DB) *Handler { return &Handler{DB: db, Zone: testZone, ServerIP: testApexIP} } // query runs one question through the handler and returns the reply. func query(t *testing.T, h *Handler, name string, qtype uint16) *dns.Msg { t.Helper() m := &dns.Msg{} m.SetQuestion(dns.Fqdn(name), qtype) w := &capWriter{} h.ServeDNS(w, m) if w.msg == nil { t.Fatal("handler wrote no reply") } return w.msg } // seedDevice creates the user, binds the MAC, and marks it live at testIP. func seedDevice(t *testing.T, db *sql.DB) { t.Helper() user, err := database.CreateUser(db, "u1", testUser) if err != nil { t.Fatal(err) } if err := database.AssociateMAC(db, testMac, user.ID); err != nil { t.Fatal(err) } if err := database.RefreshConnections(db, map[string]string{testIP: testMac}); err != nil { t.Fatal(err) } } func TestDeviceRecordSynthesized(t *testing.T) { db := testDB(t) seedDevice(t, db) reply := query(t, testHandler(db), testDevName, dns.TypeA) if len(reply.Answer) != 1 { t.Fatalf("answers = %d, want 1: %v", len(reply.Answer), reply.Answer) } a, ok := reply.Answer[0].(*dns.A) if !ok || a.A.String() != testIP { t.Fatalf("device A = %v, want %s", reply.Answer[0], testIP) } } func TestPrimaryCNAMEFollowsToDeviceA(t *testing.T) { db := testDB(t) seedDevice(t, db) // the wizard stores this: .zone CNAME -> ..zone if _, err := database.SaveDNSRecord(db, &database.DNSRecord{ ID: "r1", UserID: "u1", Name: testUser + "." + testZone, Type: "CNAME", Content: testDevName, TTL: 300, }); err != nil { t.Fatal(err) } reply := query(t, testHandler(db), testUser+"."+testZone, dns.TypeA) var sawCNAME, sawA bool for _, rr := range reply.Answer { switch v := rr.(type) { case *dns.CNAME: sawCNAME = v.Target == testDevName case *dns.A: sawA = v.A.String() == testIP } } if !sawCNAME || !sawA { t.Fatalf("want CNAME->device + A=%s, got %v", testIP, reply.Answer) } } func TestApexAnswersServerIP(t *testing.T) { db := testDB(t) reply := query(t, testHandler(db), testZone, dns.TypeA) if len(reply.Answer) != 1 { t.Fatalf("apex answers = %v", reply.Answer) } if a, ok := reply.Answer[0].(*dns.A); !ok || a.A.String() != testApexIP { t.Fatalf("apex A = %v, want %s", reply.Answer[0], testApexIP) } } func TestCustomTXTRecord(t *testing.T) { db := testDB(t) seedDevice(t, db) name := "hello." + testUser + "." + testZone if _, err := database.SaveDNSRecord(db, &database.DNSRecord{ ID: "r2", UserID: "u1", Name: name, Type: "TXT", Content: "\"waddle\"", TTL: 60, }); err != nil { t.Fatal(err) } reply := query(t, testHandler(db), name, dns.TypeTXT) if len(reply.Answer) != 1 { t.Fatalf("txt answers = %v", reply.Answer) } txt, ok := reply.Answer[0].(*dns.TXT) if !ok || len(txt.Txt) != 1 || txt.Txt[0] != "waddle" { t.Fatalf("txt = %v, want [waddle]", reply.Answer[0]) } } func TestUnknownNameIsNXDOMAIN(t *testing.T) { db := testDB(t) seedDevice(t, db) reply := query(t, testHandler(db), "nope."+testZone, dns.TypeA) if reply.Rcode != dns.RcodeNameError { t.Fatalf("rcode = %d, want NXDOMAIN(%d)", reply.Rcode, dns.RcodeNameError) } } // guard: a foreign MAC label under someone else's username must not resolve. func TestDeviceRecordRejectsForeignMAC(t *testing.T) { db := testDB(t) seedDevice(t, db) other, _ := utils.ParseMac("11:22:33:44:55:66") reply := query(t, testHandler(db), other.Compact()+"."+testUser+"."+testZone, dns.TypeA) if reply.Rcode != dns.RcodeNameError { t.Fatalf("foreign mac rcode = %d, want NXDOMAIN", reply.Rcode) } }