summaryrefslogtreecommitdiff
path: root/dnsd
diff options
context:
space:
mode:
authorAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
committerAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
commitcfc0d34a584254a9ed76620951c810771aff6f3b (patch)
tree7fba034f7347ce0003e7a3a56adcaf862c834907 /dnsd
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
Diffstat (limited to 'dnsd')
-rw-r--r--dnsd/server.go201
-rw-r--r--dnsd/server_test.go172
2 files changed, 373 insertions, 0 deletions
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 <compact-mac>.<username>.<zone> 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 <mac>.<username> 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: <user>.zone CNAME -> <mac>.<user>.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)
+ }
+}