diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index 730be93..b730591 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -7,6 +7,9 @@ import ( "net" "os" "strings" + "time" + + "github.com/miekg/dns" "banger/internal/firecracker" "banger/internal/guestconfig" @@ -282,7 +285,15 @@ func (dnsCapability) AddDoctorChecks(_ context.Context, report *system.Report) { conn, err := net.ListenPacket("udp", vmdns.DefaultListenAddr) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "address already in use") { - report.AddWarn("feature vm dns", "listener address "+vmdns.DefaultListenAddr+" is already in use") + // "Already in use" is the expected state when banger's own + // daemon is running. Probe the listener with a *.vm query + // the banger DNS server is the only thing on the host + // authoritative for, and pass if the response shape matches. + if probeBangerDNS(vmdns.DefaultListenAddr) { + report.AddPass("feature vm dns", "banger DNS server is already serving "+vmdns.DefaultListenAddr) + return + } + report.AddWarn("feature vm dns", "listener address "+vmdns.DefaultListenAddr+" is held by another process") return } report.AddFail("feature vm dns", "cannot bind "+vmdns.DefaultListenAddr+": "+err.Error()) @@ -292,6 +303,22 @@ func (dnsCapability) AddDoctorChecks(_ context.Context, report *system.Report) { report.AddPass("feature vm dns", "listener can bind "+vmdns.DefaultListenAddr) } +// probeBangerDNS returns true iff a UDP DNS query to addr is answered +// by something that behaves like banger's vmdns server: a *.vm name +// produces an authoritative NXDOMAIN. Any other listener (a stub +// resolver, a different DNS server) either refuses, recurses, or +// returns non-authoritative — all distinguishable from this probe. +func probeBangerDNS(addr string) bool { + client := &dns.Client{Net: "udp", Timeout: 500 * time.Millisecond} + req := new(dns.Msg) + req.SetQuestion("doctor-probe-not-a-real-vm.vm.", dns.TypeA) + resp, _, err := client.Exchange(req, addr) + if err != nil || resp == nil { + return false + } + return resp.Authoritative && resp.Rcode == dns.RcodeNameError +} + // natCapability sets up host-side NAT so guest traffic can reach the // outside world. Needs VMService (tap lookup + aliveness) and // HostNetwork (NAT rules), plus the daemon logger for the cleanup diff --git a/internal/daemon/capabilities_test.go b/internal/daemon/capabilities_test.go index 35cc888..e1376a1 100644 --- a/internal/daemon/capabilities_test.go +++ b/internal/daemon/capabilities_test.go @@ -3,6 +3,7 @@ package daemon import ( "context" "errors" + "net" "reflect" "testing" @@ -10,6 +11,7 @@ import ( "banger/internal/guestconfig" "banger/internal/model" "banger/internal/system" + "banger/internal/vmdns" ) type testCapability struct { @@ -146,6 +148,32 @@ func TestContributeHooksPopulateGuestAndMachineConfig(t *testing.T) { } } +func TestProbeBangerDNSAcceptsRealServer(t *testing.T) { + server, err := vmdns.New("127.0.0.1:0", nil) + if err != nil { + t.Fatalf("vmdns.New: %v", err) + } + t.Cleanup(func() { _ = server.Close() }) + + if !probeBangerDNS(server.Addr()) { + t.Fatal("probeBangerDNS rejected the real banger DNS server") + } +} + +func TestProbeBangerDNSRejectsSilentListener(t *testing.T) { + // A UDP listener that drops every datagram. The probe should + // time out and return false — i.e. "this is not banger". + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("ListenPacket: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + if probeBangerDNS(conn.LocalAddr().String()) { + t.Fatal("probeBangerDNS accepted a silent non-DNS listener") + } +} + func TestDefaultCapabilitiesInOrder(t *testing.T) { d := &Daemon{} wireServices(d)