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.
257 lines
5 KiB
Go
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")
|
|
}
|