leaderless gossip-based service discovery w/ consul compatibility
5

Configure Feed

Select the types of activity you want to include in your feed.

initial dns implementation

Jes Olson (May 23, 2026, 12:29 PM -0500) c59a32f8 f15a15ac

+312 -15
+1
api/agent.go
··· 67 67 DNSBindAddr string 68 68 HTTPBindAddr string 69 69 SerfBindAddr string 70 + DNSDomain string 70 71 } 71 72 } 72 73
+2 -2
go.mod
··· 4 4 5 5 require ( 6 6 github.com/hashicorp/serf v0.10.1 7 + github.com/miekg/dns v1.1.41 8 + go.etcd.io/bbolt v1.4.3 7 9 golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb 8 10 ) 9 11 ··· 17 19 github.com/hashicorp/go-sockaddr v1.0.0 // indirect 18 20 github.com/hashicorp/golang-lru v0.5.0 // indirect 19 21 github.com/hashicorp/memberlist v0.5.0 // indirect 20 - github.com/miekg/dns v1.1.41 // indirect 21 22 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect 22 - go.etcd.io/bbolt v1.4.3 // indirect 23 23 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect 24 24 golang.org/x/sys v0.29.0 // indirect 25 25 )
+4 -5
go.sum
··· 57 57 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= 58 58 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 59 59 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 60 - github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 61 60 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 62 61 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 62 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 63 63 go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= 64 64 go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 65 65 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= ··· 73 73 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc= 74 74 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= 75 75 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 76 - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 77 76 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 78 77 golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 78 + golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 79 79 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 80 80 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 81 81 golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= ··· 88 88 golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 89 89 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 90 90 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 - golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 92 - golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 91 golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 94 92 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 95 93 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= ··· 101 99 golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 102 100 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 103 101 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 104 - gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 105 102 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 103 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 104 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+13
internal/agent/agent.go
··· 87 87 // services holds the agent's own + gossipped service catalog. 88 88 services *ServiceStore 89 89 90 + // dns is the optional DNS server that resolves 91 + // <node>.node.<domain> and <svc>.service.<domain>. 92 + dns *DNSServer 93 + 90 94 // We receive serf events on this channel 91 95 eventCh chan serf.Event 92 96 logger *slog.Logger ··· 332 336 apiHandler := a.httpHandlers.handler() 333 337 go a.serveAPI(apiHandler) 334 338 339 + // Start DNS server 340 + a.dns = newDNSServer(a) 341 + if err := a.dns.Start(); err != nil { 342 + return err 343 + } 344 + 335 345 return nil 336 346 } 337 347 ··· 363 373 364 374 EXIT: 365 375 a.logger.Info("complete serf shutdown") 376 + if a.dns != nil { 377 + a.dns.Shutdown() 378 + } 366 379 if a.services != nil { 367 380 if err := a.services.Close(); err != nil { 368 381 a.logger.Warn("close service store", "err", err)
+2
internal/agent/agent_endpoint.go
··· 28 28 DNSBindAddr string 29 29 HTTPBindAddr string 30 30 SerfBindAddr string 31 + DNSDomain string 31 32 }{ 32 33 NodeName: s.agent.Config.NodeName, 33 34 Version: s.agent.Config.VersionWithMetadata(), 34 35 DNSBindAddr: s.agent.Config.DNSBindAddr.String(), 35 36 HTTPBindAddr: s.agent.Config.HTTPBindAddr.String(), 36 37 SerfBindAddr: s.agent.Config.SerfBindAddr.String(), 38 + DNSDomain: s.agent.Config.DNSDomain, 37 39 } 38 40 39 41 self := Self{
+7 -4
internal/agent/config.go
··· 6 6 ) 7 7 8 8 const ( 9 - DefaultDNSPort int = 8600 10 - DefaultHTTPPort int = 8500 11 - DefaultSerfPort int = 8301 12 - DefaultDataDir string = "./cascade-data" 9 + DefaultDNSPort int = 8600 10 + DefaultHTTPPort int = 8500 11 + DefaultSerfPort int = 8301 12 + DefaultDataDir string = "./cascade-data" 13 + DefaultDNSDomain string = "consul" 13 14 ) 14 15 15 16 func DefaultConfig() *Config { ··· 24 25 cfg.SerfBindAddr = &net.TCPAddr{IP: net.ParseIP("0.0.0.0"), Port: DefaultSerfPort} 25 26 cfg.NodeName = hostname 26 27 cfg.DataDir = DefaultDataDir 28 + cfg.DNSDomain = DefaultDNSDomain 27 29 28 30 return &cfg 29 31 } ··· 36 38 NodeName string 37 39 StartJoin []string 38 40 DataDir string 41 + DNSDomain string 39 42 40 43 //// non user-configurable 41 44 // HTTPResponseHeaders are used to add HTTP header response fields to the HTTP API responses.
+266
internal/agent/dns.go
··· 1 + package agent 2 + 3 + import ( 4 + "fmt" 5 + "net" 6 + "strings" 7 + "sync" 8 + 9 + "github.com/hashicorp/serf/serf" 10 + "github.com/miekg/dns" 11 + ) 12 + 13 + // DNSServer answers queries for <node>.node.<domain> and 14 + // <svc>.service.<domain>. The domain is configurable via Config.DNSDomain 15 + // and defaults to "consul" so consul SDKs that hardcode .consul resolve 16 + // without changes. 17 + type DNSServer struct { 18 + agent *Agent 19 + domain string // normalized: lowercase, no leading/trailing dot 20 + 21 + udp *dns.Server 22 + tcp *dns.Server 23 + wg sync.WaitGroup 24 + } 25 + 26 + func newDNSServer(a *Agent) *DNSServer { 27 + domain := strings.ToLower(strings.Trim(a.Config.DNSDomain, ".")) 28 + if domain == "" { 29 + domain = DefaultDNSDomain 30 + } 31 + return &DNSServer{agent: a, domain: domain} 32 + } 33 + 34 + // Start binds the UDP and TCP DNS listeners. Returns once both are 35 + // listening; serving happens in background goroutines. 36 + func (d *DNSServer) Start() error { 37 + addr := fmt.Sprintf("%s:%d", d.agent.Config.DNSBindAddr.IP, d.agent.Config.DNSBindAddr.Port) 38 + handler := dns.HandlerFunc(d.handle) 39 + 40 + udpConn, err := net.ListenPacket("udp", addr) 41 + if err != nil { 42 + return fmt.Errorf("dns udp listen %s: %w", addr, err) 43 + } 44 + tcpListener, err := net.Listen("tcp", addr) 45 + if err != nil { 46 + udpConn.Close() 47 + return fmt.Errorf("dns tcp listen %s: %w", addr, err) 48 + } 49 + 50 + d.udp = &dns.Server{PacketConn: udpConn, Handler: handler} 51 + d.tcp = &dns.Server{Listener: tcpListener, Handler: handler} 52 + 53 + d.wg.Add(2) 54 + go func() { 55 + defer d.wg.Done() 56 + if err := d.udp.ActivateAndServe(); err != nil { 57 + d.agent.logger.Error("dns udp serve", err) 58 + } 59 + }() 60 + go func() { 61 + defer d.wg.Done() 62 + if err := d.tcp.ActivateAndServe(); err != nil { 63 + d.agent.logger.Error("dns tcp serve", err) 64 + } 65 + }() 66 + return nil 67 + } 68 + 69 + // Shutdown stops both listeners. Safe to call multiple times. 70 + func (d *DNSServer) Shutdown() { 71 + if d.udp != nil { 72 + _ = d.udp.Shutdown() 73 + } 74 + if d.tcp != nil { 75 + _ = d.tcp.Shutdown() 76 + } 77 + d.wg.Wait() 78 + } 79 + 80 + // handle is the single dispatch entrypoint for all DNS queries. 81 + func (d *DNSServer) handle(w dns.ResponseWriter, req *dns.Msg) { 82 + resp := new(dns.Msg) 83 + resp.SetReply(req) 84 + resp.Authoritative = true 85 + resp.RecursionAvailable = false 86 + 87 + if len(req.Question) == 0 { 88 + resp.SetRcode(req, dns.RcodeFormatError) 89 + _ = w.WriteMsg(resp) 90 + return 91 + } 92 + q := req.Question[0] 93 + 94 + labels, ok := d.stripDomain(q.Name) 95 + if !ok { 96 + resp.SetRcode(req, dns.RcodeRefused) 97 + _ = w.WriteMsg(resp) 98 + return 99 + } 100 + 101 + switch { 102 + case isService(labels): 103 + d.answerService(resp, q, labels) 104 + case isNode(labels): 105 + d.answerNode(resp, q, labels) 106 + default: 107 + resp.SetRcode(req, dns.RcodeNameError) // NXDOMAIN 108 + } 109 + 110 + _ = w.WriteMsg(resp) 111 + } 112 + 113 + // stripDomain returns the labels left of the configured domain. For 114 + // "web.service.consul." with domain "consul", returns ["web", "service"]. 115 + // Returns false if the query isn't in our domain. 116 + func (d *DNSServer) stripDomain(qname string) ([]string, bool) { 117 + name := strings.ToLower(strings.TrimSuffix(qname, ".")) 118 + suffix := "." + d.domain 119 + if name == d.domain { 120 + return nil, true 121 + } 122 + if !strings.HasSuffix(name, suffix) { 123 + return nil, false 124 + } 125 + trimmed := strings.TrimSuffix(name, suffix) 126 + if trimmed == "" { 127 + return nil, true 128 + } 129 + return strings.Split(trimmed, "."), true 130 + } 131 + 132 + func isService(labels []string) bool { 133 + return len(labels) >= 2 && labels[len(labels)-1] == "service" 134 + } 135 + 136 + func isNode(labels []string) bool { 137 + return len(labels) >= 2 && labels[len(labels)-1] == "node" 138 + } 139 + 140 + // answerNode handles <name>.node.<domain> queries (A only). 141 + func (d *DNSServer) answerNode(resp *dns.Msg, q dns.Question, labels []string) { 142 + // labels = [name, "node"]. anything more specific is an NXDOMAIN. 143 + if len(labels) != 2 { 144 + resp.Rcode = dns.RcodeNameError 145 + return 146 + } 147 + name := labels[0] 148 + member, ok := d.findMember(name) 149 + if !ok { 150 + resp.Rcode = dns.RcodeNameError 151 + return 152 + } 153 + if q.Qtype == dns.TypeA || q.Qtype == dns.TypeANY { 154 + if ip := member.Addr.To4(); ip != nil { 155 + resp.Answer = append(resp.Answer, &dns.A{ 156 + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, 157 + A: ip, 158 + }) 159 + } 160 + } 161 + } 162 + 163 + // answerService handles <svc>.service.<domain> and 164 + // <tag>.<svc>.service.<domain> queries (A + SRV). 165 + func (d *DNSServer) answerService(resp *dns.Msg, q dns.Question, labels []string) { 166 + // shapes: [name, "service"] or [tag, name, "service"] 167 + var name, tag string 168 + switch len(labels) { 169 + case 2: 170 + name = labels[0] 171 + case 3: 172 + tag = labels[0] 173 + name = labels[1] 174 + default: 175 + resp.Rcode = dns.RcodeNameError 176 + return 177 + } 178 + 179 + instances := d.agent.CatalogServiceInstances(name) 180 + if tag != "" { 181 + instances = filterByTag(instances, tag) 182 + } 183 + if len(instances) == 0 { 184 + resp.Rcode = dns.RcodeNameError 185 + return 186 + } 187 + 188 + wantA := q.Qtype == dns.TypeA || q.Qtype == dns.TypeANY 189 + wantSRV := q.Qtype == dns.TypeSRV || q.Qtype == dns.TypeANY 190 + 191 + for _, inst := range instances { 192 + ip := d.serviceIP(inst) 193 + if ip == nil { 194 + continue 195 + } 196 + if wantA { 197 + resp.Answer = append(resp.Answer, &dns.A{ 198 + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, 199 + A: ip, 200 + }) 201 + } 202 + if wantSRV { 203 + target := dns.Fqdn(fmt.Sprintf("%s.node.%s", normalizeLabel(inst.Node), d.domain)) 204 + resp.Answer = append(resp.Answer, &dns.SRV{ 205 + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeSRV, Class: dns.ClassINET, Ttl: 0}, 206 + Priority: 1, 207 + Weight: 1, 208 + Port: uint16(inst.Service.Port), 209 + Target: target, 210 + }) 211 + // add an A record in Extra so clients don't need a second lookup 212 + resp.Extra = append(resp.Extra, &dns.A{ 213 + Hdr: dns.RR_Header{Name: target, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0}, 214 + A: ip, 215 + }) 216 + } 217 + } 218 + } 219 + 220 + // serviceIP resolves a NodeService to an IP, honoring the ServiceAddress 221 + // fallback convention (empty service addr → use node addr). 222 + func (d *DNSServer) serviceIP(inst NodeService) net.IP { 223 + if inst.Service.Address != "" { 224 + if ip := net.ParseIP(inst.Service.Address); ip != nil { 225 + return ip.To4() 226 + } 227 + } 228 + if m, ok := d.findMember(inst.Node); ok { 229 + return m.Addr.To4() 230 + } 231 + return nil 232 + } 233 + 234 + func (d *DNSServer) findMember(name string) (serf.Member, bool) { 235 + target := strings.ToLower(name) 236 + for _, m := range d.agent.serf.Members() { 237 + if strings.ToLower(normalizeLabel(m.Name)) == target { 238 + return m, true 239 + } 240 + } 241 + return serf.Member{}, false 242 + } 243 + 244 + func filterByTag(instances []NodeService, tag string) []NodeService { 245 + tag = strings.ToLower(tag) 246 + out := instances[:0:0] 247 + for _, inst := range instances { 248 + for _, t := range inst.Service.Tags { 249 + if strings.ToLower(t) == tag { 250 + out = append(out, inst) 251 + break 252 + } 253 + } 254 + } 255 + return out 256 + } 257 + 258 + // normalizeLabel makes a node or service name safe to embed in a DNS label. 259 + // Currently just lowercases and replaces spaces with hyphens, which covers 260 + // the common cases (hostnames are already valid, but `cascade agent` test 261 + // nodes are named "agent 1" etc.). 262 + func normalizeLabel(s string) string { 263 + s = strings.ToLower(s) 264 + s = strings.ReplaceAll(s, " ", "-") 265 + return s 266 + }
+12 -3
internal/cli/agent.go
··· 21 21 flagBindDNS string 22 22 flagBindHTTP string 23 23 flagBindSerf string 24 - flagJoin string 25 - flagNode string 26 - flagDataDir string 24 + flagJoin string 25 + flagNode string 26 + flagDataDir string 27 + flagDNSDomain string 27 28 } 28 29 29 30 func (c *agentCommand) Usage() { ··· 52 53 53 54 -data-dir=<path> 54 55 directory for cascade's local state (default = ./cascade-data) 56 + 57 + -domain=<name> 58 + DNS suffix served by the agent (default = consul). consul-compat by 59 + default so consul SDKs that hardcode .consul resolve unchanged. 55 60 `) 56 61 } 57 62 ··· 66 71 flags.StringVar(&c.flagJoin, "join", "", "") 67 72 flags.StringVar(&c.flagNode, "node", "", "") 68 73 flags.StringVar(&c.flagDataDir, "data-dir", "", "") 74 + flags.StringVar(&c.flagDNSDomain, "domain", "", "") 69 75 70 76 if err := flags.Parse(args); err != nil { 71 77 fmt.Println(err) ··· 209 215 } 210 216 if c.flagDataDir != "" { 211 217 config.DataDir = c.flagDataDir 218 + } 219 + if c.flagDNSDomain != "" { 220 + config.DNSDomain = c.flagDNSDomain 212 221 } 213 222 return config, nil 214 223 }
+5 -1
internal/cli/status.go
··· 64 64 65 65 self, err := agent.SelfTyped() 66 66 nodeName := "<unknown>" 67 - var dnsAddr, httpAddr, serfAddr string 67 + var dnsAddr, httpAddr, serfAddr, dnsDomain string 68 68 if err == nil { 69 69 nodeName = self.Config.NodeName 70 70 dnsAddr = self.Config.DNSBindAddr 71 71 httpAddr = self.Config.HTTPBindAddr 72 72 serfAddr = self.Config.SerfBindAddr 73 + dnsDomain = self.Config.DNSDomain 73 74 } 74 75 75 76 statusCounts := map[string]int{} ··· 107 108 fmt.Fprintf(tw, " dns\t%s\n", dnsAddr) 108 109 fmt.Fprintf(tw, " http\t%s\n", httpAddr) 109 110 fmt.Fprintf(tw, " serf\t%s\n", serfAddr) 111 + if dnsDomain != "" { 112 + fmt.Fprintf(tw, " domain\t.%s\n", dnsDomain) 113 + } 110 114 fmt.Fprintln(tw) 111 115 } 112 116