mirror of
https://github.com/soypat/lneto.git
synced 2026-08-11 10:23:47 +00:00
591 lines
15 KiB
Go
591 lines
15 KiB
Go
package mdns
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"net"
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"github.com/soypat/lneto/dns"
|
|
)
|
|
|
|
func mustNewName(s string) dns.Name {
|
|
n, err := dns.NewName(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func testService() Service {
|
|
return Service{
|
|
Name: mustNewName("My Web._http._tcp.local"),
|
|
Host: mustNewName("mydevice.local"),
|
|
Addr: []byte{192, 168, 1, 50},
|
|
Port: 80,
|
|
}
|
|
}
|
|
|
|
func newQuerier(t *testing.T, questions []dns.Question, maxAnswers uint16) *Client {
|
|
t.Helper()
|
|
var c Client
|
|
err := c.Configure(ClientConfig{LocalPort: Port})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = c.StartResolve(ResolveConfig{
|
|
Questions: questions,
|
|
MaxResponseAnswers: maxAnswers,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &c
|
|
}
|
|
|
|
func newResponder(t *testing.T, services []Service) *Client {
|
|
t.Helper()
|
|
var c Client
|
|
err := c.Configure(ClientConfig{
|
|
LocalPort: Port,
|
|
Services: services,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &c
|
|
}
|
|
|
|
// queryRespond performs the full query->demux->encapsulate->demux cycle
|
|
// between a querier and responder, returning the response length.
|
|
func queryRespond(t *testing.T, querier, responder *Client, buf []byte) int {
|
|
t.Helper()
|
|
n, err := querier.Encapsulate(buf, -1, 0)
|
|
if err != nil || n == 0 {
|
|
t.Fatal("querier encapsulate:", err, n)
|
|
}
|
|
if err = responder.Demux(buf[:n], 0); err != nil {
|
|
t.Fatal("responder demux:", err)
|
|
}
|
|
n, err = responder.Encapsulate(buf, -1, 0)
|
|
if err != nil {
|
|
t.Fatal("responder encapsulate:", err)
|
|
}
|
|
if n > 0 {
|
|
if err = querier.Demux(buf[:n], 0); err != nil {
|
|
t.Fatal("querier demux:", err)
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestClientQueryPTR(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}}, 8)
|
|
|
|
var buf [1024]byte
|
|
|
|
// Querier encapsulates query.
|
|
n, err := querier.Encapsulate(buf[:], -1, 0)
|
|
if err != nil || n == 0 {
|
|
t.Fatal("querier encapsulate:", err, n)
|
|
}
|
|
|
|
// Verify query wire format: txid=0, flags=0.
|
|
f, _ := dns.NewFrame(buf[:n])
|
|
if f.TxID() != 0 {
|
|
t.Errorf("mDNS query txid=%d, want 0", f.TxID())
|
|
}
|
|
if f.Flags() != 0 {
|
|
t.Errorf("mDNS query flags=%d, want 0", f.Flags())
|
|
}
|
|
if f.QDCount() != 1 {
|
|
t.Errorf("mDNS query QDCount=%d, want 1", f.QDCount())
|
|
}
|
|
|
|
// Responder demuxes and encapsulates response.
|
|
if err = responder.Demux(buf[:n], 0); err != nil {
|
|
t.Fatal("responder demux:", err)
|
|
}
|
|
n, err = responder.Encapsulate(buf[:], -1, 0)
|
|
if err != nil || n == 0 {
|
|
t.Fatal("responder encapsulate:", err, n)
|
|
}
|
|
|
|
// Verify response wire format: QR=1, AA=1.
|
|
f, _ = dns.NewFrame(buf[:n])
|
|
flags := f.Flags()
|
|
if !flags.IsResponse() {
|
|
t.Error("mDNS response missing QR bit")
|
|
}
|
|
if !flags.IsAuthorativeAnswer() {
|
|
t.Error("mDNS response missing AA bit")
|
|
}
|
|
if f.ANCount() == 0 {
|
|
t.Fatal("mDNS response has 0 answers")
|
|
}
|
|
|
|
// Querier demuxes response and reads answers.
|
|
if err = querier.Demux(buf[:n], 0); err != nil {
|
|
t.Fatal("querier demux:", err)
|
|
}
|
|
var answers [8]dns.Resource
|
|
nans, done, err := querier.AnswersCopyTo(answers[:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
} else if !done {
|
|
t.Fatal("expected done")
|
|
} else if nans == 0 {
|
|
t.Fatal("querier got 0 answers")
|
|
}
|
|
|
|
// The PTR answer data should contain the instance name in wire format.
|
|
ptrData := answers[0].RawData()
|
|
if len(ptrData) == 0 {
|
|
t.Fatal("PTR answer has no data")
|
|
}
|
|
var ptrName dns.Name
|
|
_, err = ptrName.Decode(ptrData, 0)
|
|
if err != nil {
|
|
t.Fatal("decode PTR name:", err)
|
|
}
|
|
if ptrName.String() != svc.Name.String() {
|
|
t.Errorf("PTR target=%q, want %q", ptrName.String(), svc.Name.String())
|
|
}
|
|
}
|
|
|
|
func TestClientQuerySRV(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("My Web._http._tcp.local"),
|
|
Type: dns.TypeSRV,
|
|
Class: dns.ClassINET,
|
|
}}, 8)
|
|
|
|
var buf [1024]byte
|
|
queryRespond(t, querier, responder, buf[:])
|
|
|
|
var answers [8]dns.Resource
|
|
nans, done, err := querier.AnswersCopyTo(answers[:])
|
|
if err != nil || !done {
|
|
t.Fatal("expected done without error:", err)
|
|
}
|
|
// SRV query should return SRV + A record.
|
|
if nans < 2 {
|
|
t.Fatalf("expected at least 2 answers (SRV+A), got %d", nans)
|
|
}
|
|
|
|
// First answer should be SRV. Parse priority(2)+weight(2)+port(2)+target.
|
|
srvData := answers[0].RawData()
|
|
if len(srvData) < 6 {
|
|
t.Fatalf("SRV data too short: %d bytes", len(srvData))
|
|
}
|
|
gotPort := binary.BigEndian.Uint16(srvData[4:6])
|
|
if gotPort != svc.Port {
|
|
t.Errorf("SRV port=%d, want %d", gotPort, svc.Port)
|
|
}
|
|
var srvTarget dns.Name
|
|
_, err = srvTarget.Decode(srvData, 6)
|
|
if err != nil {
|
|
t.Fatal("decode SRV target:", err)
|
|
}
|
|
if srvTarget.String() != svc.Host.String() {
|
|
t.Errorf("SRV target=%q, want %q", srvTarget.String(), svc.Host.String())
|
|
}
|
|
|
|
// Second answer should be A record with 4-byte IP.
|
|
aData := answers[1].RawData()
|
|
if len(aData) != 4 {
|
|
t.Fatalf("A record data length=%d, want 4", len(aData))
|
|
}
|
|
if [4]byte(aData) != [4]byte(svc.Addr) {
|
|
t.Errorf("A record addr=%v, want %v", aData, svc.Addr)
|
|
}
|
|
}
|
|
|
|
func TestClientQueryARecord(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("mydevice.local"),
|
|
Type: dns.TypeA,
|
|
Class: dns.ClassINET,
|
|
}}, 4)
|
|
|
|
var buf [1024]byte
|
|
queryRespond(t, querier, responder, buf[:])
|
|
|
|
var answers [4]dns.Resource
|
|
nans, done, err := querier.AnswersCopyTo(answers[:])
|
|
if err != nil || !done {
|
|
t.Fatal("expected done without error:", err)
|
|
}
|
|
if nans != 1 {
|
|
t.Fatalf("expected 1 answer, got %d", nans)
|
|
}
|
|
aData := answers[0].RawData()
|
|
if [4]byte(aData) != [4]byte(svc.Addr) {
|
|
t.Errorf("A record addr=%v, want %v", aData, svc.Addr)
|
|
}
|
|
}
|
|
|
|
// TODO: TestClientAnnounce — test unsolicited announcement of all registered services.
|
|
// func TestClientAnnounce(t *testing.T) { ... }
|
|
|
|
// TODO: TestClientProbeFinish — test probing sequence for name uniqueness (RFC 6762 §8.1).
|
|
// func TestClientProbeFinish(t *testing.T) { ... }
|
|
|
|
func TestClientIgnoresQueriesWithoutServices(t *testing.T) {
|
|
// A querier-only client should ignore incoming queries.
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}}, 4)
|
|
|
|
// Encapsulate query, then feed it back — should be ignored (not a response).
|
|
var buf [512]byte
|
|
n, _ := querier.Encapsulate(buf[:], -1, 0)
|
|
err := querier.Demux(buf[:n], 0)
|
|
if err != nil {
|
|
t.Fatal("demux query:", err)
|
|
}
|
|
// No response should be pending.
|
|
n, _ = querier.Encapsulate(buf[:], -1, 0)
|
|
if n != 0 {
|
|
t.Error("querier without services should not respond to queries")
|
|
}
|
|
}
|
|
|
|
func TestClientResponderIgnoresResponses(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
|
|
// Build a response packet (QR=1).
|
|
var msg dns.Message
|
|
var buf [512]byte
|
|
const responseFlags = dns.HeaderFlags(1 << 15)
|
|
data, err := msg.AppendTo(buf[:0], 0, responseFlags)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Feed a response to the responder — should be ignored.
|
|
err = responder.Demux(data, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Nothing should be pending.
|
|
n, _ := responder.Encapsulate(buf[:], -1, 0)
|
|
if n != 0 {
|
|
t.Error("responder should not respond to responses")
|
|
}
|
|
}
|
|
|
|
func TestClientUnmatchedQuery(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
|
|
// Query for a name the responder doesn't know.
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("_ftp._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}}, 4)
|
|
|
|
var buf [1024]byte
|
|
n, _ := querier.Encapsulate(buf[:], -1, 0)
|
|
responder.Demux(buf[:n], 0)
|
|
|
|
// Responder should have nothing pending.
|
|
n, _ = responder.Encapsulate(buf[:], -1, 0)
|
|
if n != 0 {
|
|
t.Error("responder should not respond to unmatched query")
|
|
}
|
|
}
|
|
|
|
func TestClientMultipleResponders(t *testing.T) {
|
|
// Two responder clients advertising different instances of the same service type.
|
|
svc1 := Service{
|
|
Name: mustNewName("Device A._http._tcp.local"),
|
|
Host: mustNewName("device-a.local"),
|
|
Addr: []byte{192, 168, 1, 10},
|
|
Port: 80,
|
|
}
|
|
svc2 := Service{
|
|
Name: mustNewName("Device B._http._tcp.local"),
|
|
Host: mustNewName("device-b.local"),
|
|
Addr: []byte{192, 168, 1, 11},
|
|
Port: 8080,
|
|
}
|
|
|
|
responder1 := newResponder(t, []Service{svc1})
|
|
responder2 := newResponder(t, []Service{svc2})
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}}, 8)
|
|
|
|
var buf [1024]byte
|
|
|
|
// Querier sends query.
|
|
n, _ := querier.Encapsulate(buf[:], -1, 0)
|
|
query := make([]byte, n)
|
|
copy(query, buf[:n])
|
|
|
|
// Both responders process the query.
|
|
responder1.Demux(query, 0)
|
|
responder2.Demux(query, 0)
|
|
|
|
// Querier receives response from responder 1.
|
|
n, _ = responder1.Encapsulate(buf[:], -1, 0)
|
|
querier.Demux(buf[:n], 0)
|
|
|
|
var answers [8]dns.Resource
|
|
nans, _, err := querier.AnswersCopyTo(answers[:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if nans < 1 {
|
|
t.Fatalf("expected at least 1 answer from first responder, got %d", nans)
|
|
}
|
|
|
|
// Start a new resolve to receive from responder 2.
|
|
querier.StartResolve(ResolveConfig{
|
|
Questions: []dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}},
|
|
MaxResponseAnswers: 8,
|
|
})
|
|
// Re-send query for second responder.
|
|
n, _ = querier.Encapsulate(buf[:], -1, 0)
|
|
|
|
n, _ = responder2.Encapsulate(buf[:], -1, 0)
|
|
querier.Demux(buf[:n], 0)
|
|
|
|
nans, _, err = querier.AnswersCopyTo(answers[:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if nans < 1 {
|
|
t.Fatalf("expected at least 1 answer from second responder, got %d", nans)
|
|
}
|
|
}
|
|
|
|
func TestClientResponderSendsOnce(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
querier := newQuerier(t, []dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}}, 8)
|
|
|
|
var buf [1024]byte
|
|
|
|
// Querier sends query, responder processes it.
|
|
n, err := querier.Encapsulate(buf[:], -1, 0)
|
|
if err != nil || n == 0 {
|
|
t.Fatal("querier encapsulate:", err, n)
|
|
}
|
|
if err = responder.Demux(buf[:n], 0); err != nil {
|
|
t.Fatal("responder demux:", err)
|
|
}
|
|
|
|
// First Encapsulate should produce a response.
|
|
n, err = responder.Encapsulate(buf[:], -1, 0)
|
|
if err != nil || n == 0 {
|
|
t.Fatal("responder first encapsulate:", err, n)
|
|
}
|
|
|
|
// Subsequent Encapsulate calls without new queries must produce nothing.
|
|
const maxSpurious = 5
|
|
for i := range maxSpurious {
|
|
n, err = responder.Encapsulate(buf[:], -1, 0)
|
|
if err != nil {
|
|
t.Fatal("responder encapsulate:", err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("responder sent spurious response on call %d (got %d bytes); expected silence after first response", i+1, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClientResponderHandlesQueryWithKnownAnswers(t *testing.T) {
|
|
// RFC 6762 §7.1: mDNS queries may include known-answer records.
|
|
// The responder must not return an error when decoding such queries.
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
|
|
// Build an mDNS query with a known-answer record (QDCount=1, ANCount=1).
|
|
ptrName := mustNewName("Other Device._http._tcp.local")
|
|
ptrData, perr := ptrName.AppendTo(nil)
|
|
if perr != nil {
|
|
t.Fatal("encode PTR name:", perr)
|
|
}
|
|
var msg dns.Message
|
|
msg.Questions = []dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}}
|
|
msg.Answers = []dns.Resource{
|
|
dns.NewResource(mustNewName("_http._tcp.local"), dns.TypePTR, dns.ClassINET, 120, ptrData),
|
|
}
|
|
var buf [1024]byte
|
|
data, err := msg.AppendTo(buf[:0], 0, 0) // txid=0, flags=0 (query).
|
|
if err != nil {
|
|
t.Fatal("build query with known-answer:", err)
|
|
}
|
|
|
|
// Demux must not return an error.
|
|
err = responder.Demux(data, 0)
|
|
if err != nil {
|
|
t.Fatalf("responder demux returned error on query with known-answers: %v", err)
|
|
}
|
|
|
|
// Responder should still generate a response for its service.
|
|
n, err := responder.Encapsulate(buf[:], -1, 0)
|
|
if err != nil {
|
|
t.Fatal("responder encapsulate:", err)
|
|
}
|
|
if n == 0 {
|
|
t.Fatal("responder should respond to query even when known-answers are present")
|
|
}
|
|
}
|
|
|
|
func TestClientAbort(t *testing.T) {
|
|
svc := testService()
|
|
responder := newResponder(t, []Service{svc})
|
|
|
|
responder.Abort()
|
|
|
|
// Demux on aborted client should return ErrClosed.
|
|
var buf [512]byte
|
|
var msg dns.Message
|
|
msg.AddQuestions([]dns.Question{{
|
|
Name: mustNewName("_http._tcp.local"),
|
|
Type: dns.TypePTR,
|
|
Class: dns.ClassINET,
|
|
}})
|
|
data, _ := msg.AppendTo(buf[:0], 0, 0)
|
|
err := responder.Demux(data, 0)
|
|
if err != net.ErrClosed {
|
|
t.Errorf("expected net.ErrClosed, got %v", err)
|
|
}
|
|
|
|
// Encapsulate on aborted client should return ErrClosed.
|
|
_, err = responder.Encapsulate(buf[:], -1, 0)
|
|
if err != net.ErrClosed {
|
|
t.Errorf("expected net.ErrClosed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientReceive(t *testing.T) {
|
|
var client Client
|
|
var buf [512]byte
|
|
const (
|
|
hostname = "server"
|
|
domain = hostname + ".local"
|
|
)
|
|
|
|
addr := netip.AddrFrom4([4]byte{192, 168, 1, 1})
|
|
multicast := netip.AddrFrom4([4]byte{224, 0, 0, 251})
|
|
err := client.Configure(ClientConfig{
|
|
LocalPort: Port,
|
|
Services: []Service{
|
|
{
|
|
Host: dns.MustNewName(domain),
|
|
Addr: addr.AsSlice(),
|
|
},
|
|
},
|
|
MulticastAddr: multicast.AsSlice(),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pkts := []struct {
|
|
name string
|
|
qtype dns.Type
|
|
unicast bool
|
|
}{
|
|
{domain, dns.TypeA, false}, // QM multicast
|
|
{"rds-th-TH010-e6614864d3511735.local", dns.TypeAAAA, true}, // QU
|
|
{"rds-th-TH010-e6614864d3511735.local", dns.TypeA, true}, // QU
|
|
}
|
|
for _, pkt := range pkts {
|
|
n := buildMDNSQuery(t, buf[:], pkt.name, pkt.qtype, pkt.unicast)
|
|
err = client.Demux(buf[:n], 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
const off = 14 + 20
|
|
n, err := client.Encapsulate(buf[:], 14, off)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dfrm, err := dns.NewFrame(buf[off:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = dfrm
|
|
var msg dns.Message
|
|
msg.LimitResourceDecoding(0, 1, 0, 0)
|
|
msg.Reset()
|
|
_, incomplete, err := msg.Decode(buf[off:])
|
|
if err != nil {
|
|
t.Fatal("decoding answer:", err)
|
|
} else if incomplete {
|
|
t.Fatal("incomplete decode, expected 1 answer")
|
|
} else if len(msg.Answers) != 1 {
|
|
t.Fatal("expected 1 answer")
|
|
}
|
|
ans := msg.Answers[0]
|
|
if !bytes.Equal(ans.RawData(), addr.AsSlice()) {
|
|
t.Errorf("expected answer addr %s, got %d", addr, ans.RawData())
|
|
}
|
|
n, err = client.Encapsulate(buf[:], 14, off)
|
|
if err != nil {
|
|
t.Fatal("expected no error on re-encapsulate")
|
|
} else if n > 0 {
|
|
t.Error("expected no data sent after first reply")
|
|
}
|
|
}
|
|
|
|
func buildMDNSQuery(t *testing.T, buf []byte, name string, qtype dns.Type, unicast bool) (n int) {
|
|
var msg dns.Message
|
|
class := dns.ClassINET
|
|
if unicast {
|
|
// mDNS QU bit (RFC 6762 §5.4)
|
|
class |= 1 << 15
|
|
}
|
|
msg.AddQuestions([]dns.Question{{
|
|
Name: mustNewName(name),
|
|
Type: qtype,
|
|
Class: class,
|
|
}})
|
|
if len(buf) < int(msg.Len()) {
|
|
t.Fatal("short buffer")
|
|
}
|
|
// mDNS uses ID = 0
|
|
flags := dns.NewClientHeaderFlags(dns.OpCodeQuery, false)
|
|
buf, err := msg.AppendTo(buf[:0], 0, flags)
|
|
if err != nil {
|
|
t.Fatal("unable to build mdns query:", err)
|
|
}
|
|
return len(buf)
|
|
}
|