Replace mapdns with daemon DNS
Serve daemon-managed .vm names directly from bangerd on 127.0.0.1:42069 instead of shelling out to mapdns. This keeps DNS state tied to VM lifecycle and lets the daemon rebuild records from running VMs after startup or reconcile. Add a small in-process authoritative DNS server, register and remove records from the VM start/stop/delete paths, and show the listener in daemon status. Remove the mapdns config and preflight surface, stop helper-flow DNS publishing in customize.sh and interactive.sh, drop dns.sh from the runtime bundle, and update docs/tests for the new local-resolver integration model. Validated with GOCACHE=/tmp/banger-gocache go test ./..., GOCACHE=/tmp/banger-gocache make build, and bash -n customize.sh interactive.sh.
This commit is contained in:
parent
430f66d5dd
commit
0a0b0b617b
24 changed files with 576 additions and 278 deletions
257
internal/vmdns/server.go
Normal file
257
internal/vmdns/server.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package vmdns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListenAddr = "127.0.0.1:42069"
|
||||
recordTTLSeconds = 5
|
||||
vmZoneSuffix = ".vm."
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
records map[string]netip.Addr
|
||||
|
||||
addr string
|
||||
server *dns.Server
|
||||
conn net.PacketConn
|
||||
done chan error
|
||||
}
|
||||
|
||||
func New(addr string, logger *slog.Logger) (*Server, error) {
|
||||
packetConn, err := net.ListenPacket("udp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
logger: logger,
|
||||
records: make(map[string]netip.Addr),
|
||||
addr: packetConn.LocalAddr().String(),
|
||||
conn: packetConn,
|
||||
done: make(chan error, 1),
|
||||
}
|
||||
s.server = &dns.Server{
|
||||
PacketConn: packetConn,
|
||||
Handler: dns.HandlerFunc(s.handleDNS),
|
||||
}
|
||||
|
||||
go func() {
|
||||
s.done <- s.server.ActivateAndServe()
|
||||
close(s.done)
|
||||
}()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Addr() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.addr
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
if s == nil || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
connErr := error(nil)
|
||||
if s.conn != nil {
|
||||
connErr = s.conn.Close()
|
||||
s.conn = nil
|
||||
}
|
||||
shutdownErr := s.server.Shutdown()
|
||||
if isIgnorableCloseErr(shutdownErr) {
|
||||
shutdownErr = nil
|
||||
}
|
||||
var serveErr error
|
||||
select {
|
||||
case serveErr = <-s.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
serveErr = errors.New("timed out waiting for vm dns server shutdown")
|
||||
}
|
||||
if isClosedServeErr(serveErr) {
|
||||
serveErr = nil
|
||||
}
|
||||
s.server = nil
|
||||
s.done = nil
|
||||
return errors.Join(connErr, shutdownErr, serveErr)
|
||||
}
|
||||
|
||||
func (s *Server) Set(name, guestIP string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(guestIP))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse guest IP %q: %w", guestIP, err)
|
||||
}
|
||||
if !addr.Is4() {
|
||||
return fmt.Errorf("guest IP must be IPv4: %q", guestIP)
|
||||
}
|
||||
fqdn, err := normalizeVMName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.records[fqdn] = addr
|
||||
s.mu.Unlock()
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("vm dns record set", "dns_name", displayName(fqdn), "guest_ip", addr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Remove(name string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
fqdn, err := normalizeVMName(name)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.records, fqdn)
|
||||
s.mu.Unlock()
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("vm dns record removed", "dns_name", displayName(fqdn))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Replace(records map[string]string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
next := make(map[string]netip.Addr, len(records))
|
||||
for name, guestIP := range records {
|
||||
fqdn, err := normalizeVMName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(guestIP))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse guest IP for %s: %w", name, err)
|
||||
}
|
||||
if !addr.Is4() {
|
||||
return fmt.Errorf("guest IP for %s must be IPv4: %q", name, guestIP)
|
||||
}
|
||||
next[fqdn] = addr
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.records = next
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Lookup(name string) (netip.Addr, bool) {
|
||||
if s == nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
fqdn, err := normalizeVMName(name)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
addr, ok := s.records[fqdn]
|
||||
return addr, ok
|
||||
}
|
||||
|
||||
func RecordName(vmName string) string {
|
||||
name := strings.TrimSpace(strings.ToLower(vmName))
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
if strings.HasSuffix(name, ".vm") {
|
||||
return name
|
||||
}
|
||||
return name + ".vm"
|
||||
}
|
||||
|
||||
func normalizeVMName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", errors.New("dns name is required")
|
||||
}
|
||||
fqdn := strings.ToLower(dns.Fqdn(name))
|
||||
if !strings.HasSuffix(fqdn, vmZoneSuffix) {
|
||||
return "", fmt.Errorf("dns name must end with .vm: %q", name)
|
||||
}
|
||||
return fqdn, nil
|
||||
}
|
||||
|
||||
func displayName(fqdn string) string {
|
||||
return strings.TrimSuffix(fqdn, ".")
|
||||
}
|
||||
|
||||
func isVMQueryName(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(dns.Fqdn(name)), vmZoneSuffix)
|
||||
}
|
||||
|
||||
func (s *Server) handleDNS(w dns.ResponseWriter, req *dns.Msg) {
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(req)
|
||||
resp.Authoritative = true
|
||||
|
||||
if len(req.Question) == 0 {
|
||||
resp.Rcode = dns.RcodeFormatError
|
||||
_ = w.WriteMsg(resp)
|
||||
return
|
||||
}
|
||||
|
||||
question := req.Question[0]
|
||||
if !isVMQueryName(question.Name) {
|
||||
resp.Rcode = dns.RcodeRefused
|
||||
_ = w.WriteMsg(resp)
|
||||
return
|
||||
}
|
||||
|
||||
addr, ok := s.Lookup(question.Name)
|
||||
if !ok {
|
||||
resp.Rcode = dns.RcodeNameError
|
||||
_ = w.WriteMsg(resp)
|
||||
return
|
||||
}
|
||||
|
||||
if question.Qtype == dns.TypeA {
|
||||
resp.Answer = []dns.RR{
|
||||
&dns.A{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: strings.ToLower(dns.Fqdn(question.Name)),
|
||||
Rrtype: dns.TypeA,
|
||||
Class: dns.ClassINET,
|
||||
Ttl: recordTTLSeconds,
|
||||
},
|
||||
A: net.IP(addr.AsSlice()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_ = w.WriteMsg(resp)
|
||||
}
|
||||
|
||||
func isClosedServeErr(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, net.ErrClosed) || strings.Contains(strings.ToLower(err.Error()), "closed")
|
||||
}
|
||||
|
||||
func isIgnorableCloseErr(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "server not started")
|
||||
}
|
||||
126
internal/vmdns/server_test.go
Normal file
126
internal/vmdns/server_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package vmdns
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestRecordName(t *testing.T) {
|
||||
if got := RecordName("DevBox"); got != "devbox.vm" {
|
||||
t.Fatalf("RecordName = %q, want devbox.vm", got)
|
||||
}
|
||||
if got := RecordName("already.vm"); got != "already.vm" {
|
||||
t.Fatalf("RecordName = %q, want already.vm", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerAnswersVMQueries(t *testing.T) {
|
||||
server := startTestServer(t)
|
||||
if err := server.Set("devbox.vm", "172.16.0.8"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
t.Run("A record", func(t *testing.T) {
|
||||
resp := exchangeQuery(t, server.Addr(), "devbox.vm.", dns.TypeA)
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
t.Fatalf("rcode = %d, want success", resp.Rcode)
|
||||
}
|
||||
if len(resp.Answer) != 1 {
|
||||
t.Fatalf("answer count = %d, want 1", len(resp.Answer))
|
||||
}
|
||||
a, ok := resp.Answer[0].(*dns.A)
|
||||
if !ok {
|
||||
t.Fatalf("answer type = %T, want *dns.A", resp.Answer[0])
|
||||
}
|
||||
if got := a.A.String(); got != "172.16.0.8" {
|
||||
t.Fatalf("A = %q, want 172.16.0.8", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known AAAA returns NODATA", func(t *testing.T) {
|
||||
resp := exchangeQuery(t, server.Addr(), "devbox.vm.", dns.TypeAAAA)
|
||||
if resp.Rcode != dns.RcodeSuccess {
|
||||
t.Fatalf("rcode = %d, want success", resp.Rcode)
|
||||
}
|
||||
if len(resp.Answer) != 0 {
|
||||
t.Fatalf("answer count = %d, want 0", len(resp.Answer))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown name returns NXDOMAIN", func(t *testing.T) {
|
||||
resp := exchangeQuery(t, server.Addr(), "missing.vm.", dns.TypeA)
|
||||
if resp.Rcode != dns.RcodeNameError {
|
||||
t.Fatalf("rcode = %d, want NXDOMAIN", resp.Rcode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outside zone returns REFUSED", func(t *testing.T) {
|
||||
resp := exchangeQuery(t, server.Addr(), "example.com.", dns.TypeA)
|
||||
if resp.Rcode != dns.RcodeRefused {
|
||||
t.Fatalf("rcode = %d, want REFUSED", resp.Rcode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerReplaceSwapsRecordSet(t *testing.T) {
|
||||
server := startTestServer(t)
|
||||
if err := server.Replace(map[string]string{
|
||||
"alpha.vm": "172.16.0.2",
|
||||
"beta.vm": "172.16.0.3",
|
||||
}); err != nil {
|
||||
t.Fatalf("Replace: %v", err)
|
||||
}
|
||||
if _, ok := server.Lookup("alpha.vm"); !ok {
|
||||
t.Fatal("alpha.vm missing after replace")
|
||||
}
|
||||
if err := server.Replace(map[string]string{"beta.vm": "172.16.0.4"}); err != nil {
|
||||
t.Fatalf("Replace second set: %v", err)
|
||||
}
|
||||
if _, ok := server.Lookup("alpha.vm"); ok {
|
||||
t.Fatal("alpha.vm should have been removed by replace")
|
||||
}
|
||||
addr, ok := server.Lookup("beta.vm")
|
||||
if !ok || addr.String() != "172.16.0.4" {
|
||||
t.Fatalf("beta.vm = %v, %v, want 172.16.0.4", addr, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerFailsWhenAddressAlreadyInUse(t *testing.T) {
|
||||
packetConn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("ListenPacket: %v", err)
|
||||
}
|
||||
defer packetConn.Close()
|
||||
|
||||
if _, err := New(packetConn.LocalAddr().String(), nil); err == nil {
|
||||
t.Fatal("New() succeeded on occupied UDP address, want failure")
|
||||
}
|
||||
}
|
||||
|
||||
func startTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
server, err := New("127.0.0.1:0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := server.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
func exchangeQuery(t *testing.T, addr, name string, qtype uint16) *dns.Msg {
|
||||
t.Helper()
|
||||
client := &dns.Client{Net: "udp"}
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion(name, qtype)
|
||||
resp, _, err := client.Exchange(req, addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Exchange(%s, %d): %v", name, qtype, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue