initial dns implementation
@@ -67,6 +67,7 @@ BuildDate string
DNSBindAddr string HTTPBindAddr string SerfBindAddr string + DNSDomain string } }
@@ -4,6 +4,8 @@ go 1.23
require ( github.com/hashicorp/serf v0.10.1 + github.com/miekg/dns v1.1.41 + go.etcd.io/bbolt v1.4.3 golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb )@@ -17,9 +19,7 @@ github.com/hashicorp/go-multierror v1.1.0 // indirect
github.com/hashicorp/go-sockaddr v1.0.0 // indirect github.com/hashicorp/golang-lru v0.5.0 // indirect github.com/hashicorp/memberlist v0.5.0 // indirect - github.com/miekg/dns v1.1.41 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect - go.etcd.io/bbolt v1.4.3 // indirect golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect golang.org/x/sys v0.29.0 // indirect )
@@ -57,9 +57,9 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=@@ -73,9 +73,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=@@ -88,8 +88,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=@@ -101,5 +99,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -87,6 +87,10 @@
// services holds the agent's own + gossipped service catalog. services *ServiceStore + // dns is the optional DNS server that resolves + // <node>.node.<domain> and <svc>.service.<domain>. + dns *DNSServer + // We receive serf events on this channel eventCh chan serf.Event logger *slog.Logger@@ -332,6 +336,12 @@ // Start API Server
apiHandler := a.httpHandlers.handler() go a.serveAPI(apiHandler) + // Start DNS server + a.dns = newDNSServer(a) + if err := a.dns.Start(); err != nil { + return err + } + return nil }@@ -363,6 +373,9 @@ }
EXIT: a.logger.Info("complete serf shutdown") + if a.dns != nil { + a.dns.Shutdown() + } if a.services != nil { if err := a.services.Close(); err != nil { a.logger.Warn("close service store", "err", err)
@@ -28,12 +28,14 @@ BuildDate string
DNSBindAddr string HTTPBindAddr string SerfBindAddr string + DNSDomain string }{ NodeName: s.agent.Config.NodeName, Version: s.agent.Config.VersionWithMetadata(), DNSBindAddr: s.agent.Config.DNSBindAddr.String(), HTTPBindAddr: s.agent.Config.HTTPBindAddr.String(), SerfBindAddr: s.agent.Config.SerfBindAddr.String(), + DNSDomain: s.agent.Config.DNSDomain, } self := Self{
@@ -6,10 +6,11 @@ "os"
) const ( - DefaultDNSPort int = 8600 - DefaultHTTPPort int = 8500 - DefaultSerfPort int = 8301 - DefaultDataDir string = "./cascade-data" + DefaultDNSPort int = 8600 + DefaultHTTPPort int = 8500 + DefaultSerfPort int = 8301 + DefaultDataDir string = "./cascade-data" + DefaultDNSDomain string = "consul" ) func DefaultConfig() *Config {@@ -24,6 +25,7 @@ cfg.HTTPBindAddr = &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: DefaultHTTPPort}
cfg.SerfBindAddr = &net.TCPAddr{IP: net.ParseIP("0.0.0.0"), Port: DefaultSerfPort} cfg.NodeName = hostname cfg.DataDir = DefaultDataDir + cfg.DNSDomain = DefaultDNSDomain return &cfg }@@ -36,6 +38,7 @@ SerfBindAddr *net.TCPAddr
NodeName string StartJoin []string DataDir string + DNSDomain string //// non user-configurable // HTTPResponseHeaders are used to add HTTP header response fields to the HTTP API responses.
@@ -0,0 +1,266 @@
+package agent + +import ( + "fmt" + "net" + "strings" + "sync" + + "github.com/hashicorp/serf/serf" + "github.com/miekg/dns" +) + +// DNSServer answers queries for <node>.node.<domain> and +// <svc>.service.<domain>. The domain is configurable via Config.DNSDomain +// and defaults to "consul" so consul SDKs that hardcode .consul resolve +// without changes. +type DNSServer struct { + agent *Agent + domain string // normalized: lowercase, no leading/trailing dot + + udp *dns.Server + tcp *dns.Server + wg sync.WaitGroup +} + +func newDNSServer(a *Agent) *DNSServer { + domain := strings.ToLower(strings.Trim(a.Config.DNSDomain, ".")) + if domain == "" { + domain = DefaultDNSDomain + } + return &DNSServer{agent: a, domain: domain} +} + +// Start binds the UDP and TCP DNS listeners. Returns once both are +// listening; serving happens in background goroutines. +func (d *DNSServer) Start() error { + addr := fmt.Sprintf("%s:%d", d.agent.Config.DNSBindAddr.IP, d.agent.Config.DNSBindAddr.Port) + handler := dns.HandlerFunc(d.handle) + + udpConn, err := net.ListenPacket("udp", addr) + if err != nil { + return fmt.Errorf("dns udp listen %s: %w", addr, err) + } + tcpListener, err := net.Listen("tcp", addr) + if err != nil { + udpConn.Close() + return fmt.Errorf("dns tcp listen %s: %w", addr, err) + } + + d.udp = &dns.Server{PacketConn: udpConn, Handler: handler} + d.tcp = &dns.Server{Listener: tcpListener, Handler: handler} + + d.wg.Add(2) + go func() { + defer d.wg.Done() + if err := d.udp.ActivateAndServe(); err != nil { + d.agent.logger.Error("dns udp serve", err) + } + }() + go func() { + defer d.wg.Done() + if err := d.tcp.ActivateAndServe(); err != nil { + d.agent.logger.Error("dns tcp serve", err) + } + }() + return nil +} + +// Shutdown stops both listeners. Safe to call multiple times. +func (d *DNSServer) Shutdown() { + if d.udp != nil { + _ = d.udp.Shutdown() + } + if d.tcp != nil { + _ = d.tcp.Shutdown() + } + d.wg.Wait() +} + +// handle is the single dispatch entrypoint for all DNS queries. +func (d *DNSServer) handle(w dns.ResponseWriter, req *dns.Msg) { + resp := new(dns.Msg) + resp.SetReply(req) + resp.Authoritative = true + resp.RecursionAvailable = false + + if len(req.Question) == 0 { + resp.SetRcode(req, dns.RcodeFormatError) + _ = w.WriteMsg(resp) + return + } + q := req.Question[0] + + labels, ok := d.stripDomain(q.Name) + if !ok { + resp.SetRcode(req, dns.RcodeRefused) + _ = w.WriteMsg(resp) + return + } + + switch { + case isService(labels): + d.answerService(resp, q, labels) + case isNode(labels): + d.answerNode(resp, q, labels) + default: + resp.SetRcode(req, dns.RcodeNameError) // NXDOMAIN + } + + _ = w.WriteMsg(resp) +} + +// stripDomain returns the labels left of the configured domain. For +// "web.service.consul." with domain "consul", returns ["web", "service"]. +// Returns false if the query isn't in our domain. +func (d *DNSServer) stripDomain(qname string) ([]string, bool) { + name := strings.ToLower(strings.TrimSuffix(qname, ".")) + suffix := "." + d.domain + if name == d.domain { + return nil, true + } + if !strings.HasSuffix(name, suffix) { + return nil, false + } + trimmed := strings.TrimSuffix(name, suffix) + if trimmed == "" { + return nil, true + } + return strings.Split(trimmed, "."), true +} + +func isService(labels []string) bool { + return len(labels) >= 2 && labels[len(labels)-1] == "service" +} + +func isNode(labels []string) bool { + return len(labels) >= 2 && labels[len(labels)-1] == "node" +} + +// answerNode handles <name>.node.<domain> queries (A only). +func (d *DNSServer) answerNode(resp *dns.Msg, q dns.Question, labels []string) { + // labels = [name, "node"]. anything more specific is an NXDOMAIN. + if len(labels) != 2 { + resp.Rcode = dns.RcodeNameError + return + } + name := labels[0] + member, ok := d.findMember(name) + if !ok { + resp.Rcode = dns.RcodeNameError + return + } + if q.Qtype == dns.TypeA || q.Qtype == dns.TypeANY { + if ip := member.Addr.To4(); ip != nil { + resp.Answer = append(resp.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, + A: ip, + }) + } + } +} + +// answerService handles <svc>.service.<domain> and +// <tag>.<svc>.service.<domain> queries (A + SRV). +func (d *DNSServer) answerService(resp *dns.Msg, q dns.Question, labels []string) { + // shapes: [name, "service"] or [tag, name, "service"] + var name, tag string + switch len(labels) { + case 2: + name = labels[0] + case 3: + tag = labels[0] + name = labels[1] + default: + resp.Rcode = dns.RcodeNameError + return + } + + instances := d.agent.CatalogServiceInstances(name) + if tag != "" { + instances = filterByTag(instances, tag) + } + if len(instances) == 0 { + resp.Rcode = dns.RcodeNameError + return + } + + wantA := q.Qtype == dns.TypeA || q.Qtype == dns.TypeANY + wantSRV := q.Qtype == dns.TypeSRV || q.Qtype == dns.TypeANY + + for _, inst := range instances { + ip := d.serviceIP(inst) + if ip == nil { + continue + } + if wantA { + resp.Answer = append(resp.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, + A: ip, + }) + } + if wantSRV { + target := dns.Fqdn(fmt.Sprintf("%s.node.%s", normalizeLabel(inst.Node), d.domain)) + resp.Answer = append(resp.Answer, &dns.SRV{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: 0}, + Priority: 1, + Weight: 1, + Port: uint16(inst.Service.Port), + Target: target, + }) + // add an A record in Extra so clients don't need a second lookup + resp.Extra = append(resp.Extra, &dns.A{ + Hdr: dns.RR_Header{Name: target, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, + A: ip, + }) + } + } +} + +// serviceIP resolves a NodeService to an IP, honoring the ServiceAddress +// fallback convention (empty service addr → use node addr). +func (d *DNSServer) serviceIP(inst NodeService) net.IP { + if inst.Service.Address != "" { + if ip := net.ParseIP(inst.Service.Address); ip != nil { + return ip.To4() + } + } + if m, ok := d.findMember(inst.Node); ok { + return m.Addr.To4() + } + return nil +} + +func (d *DNSServer) findMember(name string) (serf.Member, bool) { + target := strings.ToLower(name) + for _, m := range d.agent.serf.Members() { + if strings.ToLower(normalizeLabel(m.Name)) == target { + return m, true + } + } + return serf.Member{}, false +} + +func filterByTag(instances []NodeService, tag string) []NodeService { + tag = strings.ToLower(tag) + out := instances[:0:0] + for _, inst := range instances { + for _, t := range inst.Service.Tags { + if strings.ToLower(t) == tag { + out = append(out, inst) + break + } + } + } + return out +} + +// normalizeLabel makes a node or service name safe to embed in a DNS label. +// Currently just lowercases and replaces spaces with hyphens, which covers +// the common cases (hostnames are already valid, but `cascade agent` test +// nodes are named "agent 1" etc.). +func normalizeLabel(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "-") + return s +}
@@ -21,9 +21,10 @@
flagBindDNS string flagBindHTTP string flagBindSerf string - flagJoin string - flagNode string - flagDataDir string + flagJoin string + flagNode string + flagDataDir string + flagDNSDomain string } func (c *agentCommand) Usage() {@@ -52,6 +53,10 @@ name of this node, must be globally unique (default = hostname)
-data-dir=<path> directory for cascade's local state (default = ./cascade-data) + + -domain=<name> + DNS suffix served by the agent (default = consul). consul-compat by + default so consul SDKs that hardcode .consul resolve unchanged. `) }@@ -66,6 +71,7 @@ flags.StringVar(&c.flagBindSerf, "bind-serf", "", "")
flags.StringVar(&c.flagJoin, "join", "", "") flags.StringVar(&c.flagNode, "node", "", "") flags.StringVar(&c.flagDataDir, "data-dir", "", "") + flags.StringVar(&c.flagDNSDomain, "domain", "", "") if err := flags.Parse(args); err != nil { fmt.Println(err)@@ -209,6 +215,9 @@ config.NodeName = c.flagNode
} if c.flagDataDir != "" { config.DataDir = c.flagDataDir + } + if c.flagDNSDomain != "" { + config.DNSDomain = c.flagDNSDomain } return config, nil }
@@ -64,12 +64,13 @@ }
self, err := agent.SelfTyped() nodeName := "<unknown>" - var dnsAddr, httpAddr, serfAddr string + var dnsAddr, httpAddr, serfAddr, dnsDomain string if err == nil { nodeName = self.Config.NodeName dnsAddr = self.Config.DNSBindAddr httpAddr = self.Config.HTTPBindAddr serfAddr = self.Config.SerfBindAddr + dnsDomain = self.Config.DNSDomain } statusCounts := map[string]int{}@@ -107,6 +108,9 @@ fmt.Fprintln(tw, "bind addrs")
fmt.Fprintf(tw, " dns\t%s\n", dnsAddr) fmt.Fprintf(tw, " http\t%s\n", httpAddr) fmt.Fprintf(tw, " serf\t%s\n", serfAddr) + if dnsDomain != "" { + fmt.Fprintf(tw, " domain\t.%s\n", dnsDomain) + } fmt.Fprintln(tw) }