From cfc0d34a584254a9ed76620951c810771aff6f3b Mon Sep 17 00:00:00 2001 From: Alexander Hunt Date: Sun, 21 Jun 2026 12:43:31 -0700 Subject: STUFF --- dnsd/server.go | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++ dnsd/server_test.go | 172 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 dnsd/server.go create mode 100644 dnsd/server_test.go (limited to 'dnsd') diff --git a/dnsd/server.go b/dnsd/server.go new file mode 100644 index 0000000..295431e --- /dev/null +++ b/dnsd/server.go @@ -0,0 +1,201 @@ +// Package dnsd is the LAN's authoritative DNS server. It answers the configured +// zone (e.g. penguins.lan) from user-owned records plus dynamically-synthesized +// per-device records, and forwards anything outside the zone to upstream +// resolvers. Modeled on the sibling hatecomputers.club hcdns server. +package dnsd + +import ( + "database/sql" + "fmt" + "log" + "strings" + + "github.com/miekg/dns" + + "penguins.lan/args" + "penguins.lan/database" + "penguins.lan/utils" +) + +const maxRecursion = 10 + +type Handler struct { + DB *sql.DB + Zone string // fqdn, e.g. "penguins.lan." + ServerIP string // apex A target; "" leaves the apex unanswered + Resolvers []string +} + +// resolveExternal forwards a query to the first upstream resolver that answers. +// With no resolvers configured (the LAN has no outside internet) it returns +// nothing. +func (h *Handler) resolveExternal(name string, qtype uint16) ([]dns.RR, error) { + if len(h.Resolvers) == 0 { + return nil, nil + } + client := &dns.Client{} + msg := &dns.Msg{} + msg.SetQuestion(name, qtype) + msg.RecursionDesired = true + + var lastErr error + for _, resolver := range h.Resolvers { + in, _, err := client.Exchange(msg, resolver) + if err != nil { + lastErr = err + continue + } + return in.Answer, nil + } + return nil, lastErr +} + +// synthesizeDevice answers .. with the device's +// current LAN IP, if the label is a MAC owned by that user and the device is +// live. These records aren't stored — they track DHCP in real time. +func (h *Handler) synthesizeDevice(name string, qtype uint16) ([]dns.RR, error) { + if qtype != dns.TypeA { + return nil, nil + } + rest, ok := strings.CutSuffix(name, "."+h.Zone) + if !ok { + return nil, nil + } + labels := strings.Split(rest, ".") + if len(labels) != 2 { + return nil, nil // only . lives directly under the zone + } + macLabel, username := labels[0], labels[1] + + mac, err := utils.ParseMacCompact(macLabel) + if err != nil { + return nil, nil // not a MAC label + } + owned, err := database.MACOwnedByUsername(h.DB, mac.Format(), username) + if err != nil { + return nil, err + } + if !owned { + return nil, nil + } + ip, err := database.IPForMac(h.DB, mac.Format()) + if err != nil || ip == "" { + return nil, err + } + rr, err := dns.NewRR(fmt.Sprintf("%s 30 IN A %s", name, ip)) + if err != nil { + return nil, err + } + return []dns.RR{rr}, nil +} + +// resolveLocal answers an in-zone name: stored CNAMEs (followed recursively), +// then stored records of the requested type, then the apex and dynamic device +// records as fallbacks. +func (h *Handler) resolveLocal(name string, qtype uint16, depth int) ([]dns.RR, error) { + if depth == 0 { + return nil, fmt.Errorf("dns: too much recursion resolving %s", name) + } + + var answers []dns.RR + + cnames, err := database.FindDNSRecords(h.DB, name, "CNAME") + if err != nil { + return nil, err + } + for _, rec := range cnames { + rr, err := dns.NewRR(fmt.Sprintf("%s %d IN CNAME %s", rec.Name, rec.TTL, dns.Fqdn(rec.Content))) + if err != nil { + return nil, err + } + answers = append(answers, rr) + + if qtype != dns.TypeCNAME { + followed, err := h.resolveLocal(dns.Fqdn(rec.Content), qtype, depth-1) + if err != nil { + return nil, err + } + answers = append(answers, followed...) + } + } + + qtypeName := dns.TypeToString[qtype] + records, err := database.FindDNSRecords(h.DB, name, qtypeName) + if err != nil { + return nil, err + } + for _, rec := range records { + rr, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", rec.Name, rec.TTL, qtypeName, rec.Content)) + if err != nil { + return nil, err + } + answers = append(answers, rr) + } + + if len(answers) > 0 { + return answers, nil + } + + if name == h.Zone && qtype == dns.TypeA && h.ServerIP != "" { + rr, err := dns.NewRR(fmt.Sprintf("%s 300 IN A %s", name, h.ServerIP)) + if err != nil { + return nil, err + } + return []dns.RR{rr}, nil + } + + return h.synthesizeDevice(name, qtype) +} + +func (h *Handler) inZone(name string) bool { + return name == h.Zone || strings.HasSuffix(name, "."+h.Zone) +} + +func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + msg := &dns.Msg{} + msg.SetReply(r) + + for _, q := range r.Question { + name := dns.Fqdn(strings.ToLower(q.Name)) + if h.inZone(name) { + msg.Authoritative = true + answers, err := h.resolveLocal(name, q.Qtype, maxRecursion) + if err != nil { + log.Println("dnsd: resolve failed:", err) + msg.SetRcode(r, dns.RcodeServerFailure) + w.WriteMsg(msg) + return + } + msg.Answer = append(msg.Answer, answers...) + } else { + answers, err := h.resolveExternal(name, q.Qtype) + if err != nil { + log.Println("dnsd: forward failed:", err) + msg.SetRcode(r, dns.RcodeServerFailure) + w.WriteMsg(msg) + return + } + msg.Answer = append(msg.Answer, answers...) + } + } + + if len(msg.Answer) == 0 && msg.Authoritative { + msg.SetRcode(r, dns.RcodeNameError) + } + w.WriteMsg(msg) +} + +func MakeServer(argv *args.Arguments, db *sql.DB) *dns.Server { + handler := &Handler{ + DB: db, + Zone: dns.Fqdn(argv.DnsZone), + ServerIP: argv.ServerIP, + Resolvers: argv.DnsResolvers, + } + return &dns.Server{ + Addr: fmt.Sprintf(":%d", argv.DnsPort), + Net: "udp", + Handler: handler, + UDPSize: 65535, + } +} diff --git a/dnsd/server_test.go b/dnsd/server_test.go new file mode 100644 index 0000000..a9cb726 --- /dev/null +++ b/dnsd/server_test.go @@ -0,0 +1,172 @@ +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) + } +} -- cgit v1.3