banger/internal/vmdns/server.go
Thales Maciel 0a0b0b617b
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.
2026-03-17 15:49:35 -03:00

257 lines
5 KiB
Go

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")
}