summaryrefslogtreecommitdiff
path: root/dnsd/server.go
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/server.go
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
Diffstat (limited to 'dnsd/server.go')
-rw-r--r--dnsd/server.go201
1 files changed, 201 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,
+ }
+}