mirror of
https://github.com/soypat/lneto.git
synced 2026-08-17 05:13:27 +00:00
fix CI and rework package structure (#111)
This commit is contained in:
+3
-3
@@ -78,11 +78,11 @@ func (n Name) EqualString(strname string) bool {
|
||||
}
|
||||
label := data[1 : 1+labelLen]
|
||||
var seg string
|
||||
idx := strings.IndexByte(strname, '.')
|
||||
if idx < 0 {
|
||||
before, after, ok := strings.Cut(strname, ".")
|
||||
if !ok {
|
||||
seg, strname = strname, ""
|
||||
} else {
|
||||
seg, strname = strname[:idx], strname[idx+1:]
|
||||
seg, strname = before, after
|
||||
}
|
||||
if len(seg) != len(label) || seg != string(label) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net"
|
||||
|
||||
"github.com/soypat/lneto"
|
||||
"github.com/soypat/lneto/dns"
|
||||
"github.com/soypat/lneto/internal"
|
||||
)
|
||||
|
||||
const (
|
||||
// Port is the mDNS UDP port (RFC 6762 §1).
|
||||
Port = 5353
|
||||
|
||||
// Default TTL for mDNS records (RFC 6762 §11).
|
||||
DefaultTTL uint32 = 120
|
||||
// classCacheFlush is bit 15 of the Class field, indicating the record
|
||||
// is from a unique source and should replace cached entries (RFC 6762 §10.2).
|
||||
classCacheFlush uint16 = 1 << 15
|
||||
mdnsTxID = 0
|
||||
mdnsFlags = 0
|
||||
)
|
||||
|
||||
type querierState uint8
|
||||
|
||||
const (
|
||||
querierIdle querierState = iota
|
||||
querierSendQuery // Query ready to be sent.
|
||||
querierAwaitResponse // Waiting for answers.
|
||||
querierFailed // failed query
|
||||
querierDone // Answers collected.
|
||||
)
|
||||
|
||||
// Client provides both querying and service multicast DNS functionality
|
||||
// once configured and attached to MDNS port 5353.
|
||||
//
|
||||
// Clients are attached to MDNS ports and function until manual detachment
|
||||
// due to their dual design: they double as a querier and service discovery.
|
||||
type Client struct {
|
||||
connID uint64
|
||||
closed bool
|
||||
lport uint16
|
||||
ip []byte
|
||||
// Query State:
|
||||
qstate querierState
|
||||
qcode dns.RCode
|
||||
qerr error
|
||||
// qmsg is used for queries to marshal/unmarshal our
|
||||
// outgoing queries and responses to our queries.
|
||||
qans []dns.Resource
|
||||
qqst []dns.Question
|
||||
// Response state:
|
||||
services []Service // Stores services we'd broadcast.
|
||||
rans []dns.Resource
|
||||
rqst []dns.Question
|
||||
}
|
||||
|
||||
type ClientConfig struct {
|
||||
LocalPort uint16
|
||||
Services []Service
|
||||
MulticastAddr []byte
|
||||
}
|
||||
|
||||
func (c *Client) Configure(cfg ClientConfig) error {
|
||||
if cfg.LocalPort == 0 {
|
||||
return lneto.ErrZeroSource
|
||||
}
|
||||
c.reset(cfg.LocalPort)
|
||||
c.services = append(c.services[:0], cfg.Services...)
|
||||
c.ip = append(c.ip[:0], cfg.MulticastAddr...)
|
||||
internal.SliceReuse(&c.rqst, len(cfg.Services))
|
||||
// Each service can produce up to 4 answer records (PTR+SRV+TXT+A).
|
||||
nrans := 2 * len(cfg.Services)
|
||||
if nrans > 0 {
|
||||
nrans = max(4, nrans)
|
||||
}
|
||||
internal.SliceReuse(&c.rans, nrans)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Protocol() uint64 { return uint64(lneto.IPProtoUDP) }
|
||||
|
||||
func (c *Client) LocalPort() uint16 { return c.lport }
|
||||
|
||||
func (c *Client) ConnectionID() *uint64 { return &c.connID }
|
||||
|
||||
type ResolveConfig struct {
|
||||
Questions []dns.Question
|
||||
MaxResponseAnswers uint16
|
||||
}
|
||||
|
||||
func (c *Client) StartResolve(cfg ResolveConfig) error {
|
||||
nq := len(cfg.Questions)
|
||||
if nq > math.MaxUint16 || nq == 0 || cfg.MaxResponseAnswers == 0 {
|
||||
return lneto.ErrInvalidConfig
|
||||
}
|
||||
c.qreset(querierSendQuery)
|
||||
internal.SliceReuse(&c.qans, int(cfg.MaxResponseAnswers))
|
||||
internal.SliceReuse(&c.qqst, nq)
|
||||
c.qqst = c.qqst[:nq]
|
||||
for i := range c.qqst {
|
||||
c.qqst[i].CopyFrom(cfg.Questions[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) reset(localport uint16) {
|
||||
*c = Client{
|
||||
connID: c.connID + 1,
|
||||
lport: localport,
|
||||
// Ensure memory reused:
|
||||
qqst: c.qqst[:0],
|
||||
qans: c.qans[:0],
|
||||
services: c.services[:0],
|
||||
rans: c.rans[:0],
|
||||
ip: c.ip[:0],
|
||||
}
|
||||
}
|
||||
|
||||
// qreset resets the current query state. It is only a partial reset of a Client.
|
||||
func (c *Client) qreset(state querierState) {
|
||||
c.qstate = state
|
||||
}
|
||||
|
||||
// Encapsulate writes a pending mDNS packet into carrierData[offsetToFrame:].
|
||||
// Pending responses take priority over outgoing queries.
|
||||
func (c *Client) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (n int, err error) {
|
||||
if c.isClosed() {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
if len(c.rans) > 0 {
|
||||
// Pending response to an incoming query.
|
||||
n, err = c.encapsResponse(carrierData[offsetToFrame:])
|
||||
} else if c.qstate == querierSendQuery {
|
||||
n, err = c.encapsQuery(carrierData[offsetToFrame:])
|
||||
}
|
||||
if n > 0 && offsetToIP >= 0 {
|
||||
// Set Multicast IP destination and Ethernet MAC.
|
||||
internal.SetMulticast(carrierData, offsetToIP, c.ip)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Demux processes an incoming mDNS response packet. Answers are accumulated
|
||||
// into the internal message. Once sufficient answers are collected or a
|
||||
// timeout occurs the querier transitions to querierDone.
|
||||
func (c *Client) Demux(carrierData []byte, frameOffset int) error {
|
||||
if c.isClosed() {
|
||||
return net.ErrClosed
|
||||
}
|
||||
frame := carrierData[frameOffset:]
|
||||
f, err := dns.NewFrame(frame)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if f.TxID() != 0 {
|
||||
return lneto.ErrPacketDrop
|
||||
}
|
||||
flags := f.Flags()
|
||||
isresponse := flags.IsResponse()
|
||||
if isresponse && c.qstate == querierAwaitResponse {
|
||||
c.qcode = flags.ResponseCode()
|
||||
// Decode response into our message, collecting answers.
|
||||
_, _, c.qerr = dns.DecodeMessage(nil, &c.qans, nil, nil, frame)
|
||||
if c.qerr != nil {
|
||||
c.qstate = querierFailed
|
||||
return c.qerr
|
||||
}
|
||||
c.qstate = querierDone
|
||||
return nil // success.
|
||||
}
|
||||
freeAns := cap(c.rans) - len(c.rans)
|
||||
if !isresponse && len(c.services) > 0 && freeAns > 0 {
|
||||
// Incoming query — match against our services.
|
||||
var query dns.Message
|
||||
query.LimitResourceDecoding(f.QDCount(), 0, 0, 0)
|
||||
_, incomplete, err := query.Decode(frame)
|
||||
if err != nil && !incomplete {
|
||||
return err
|
||||
}
|
||||
for i := range query.Questions {
|
||||
q := &query.Questions[i]
|
||||
for j := range c.services {
|
||||
if matchQuestion(q, &c.services[j]) {
|
||||
addServiceAnswers(&c.rans, q, &c.services[j])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) encapsQuery(frame []byte) (int, error) {
|
||||
msg := dns.Message{
|
||||
Questions: c.qqst,
|
||||
}
|
||||
msglen := msg.Len()
|
||||
if int(msglen) > len(frame) {
|
||||
c.qerr = lneto.ErrShortBuffer
|
||||
c.qstate = querierFailed
|
||||
return 0, c.qerr
|
||||
}
|
||||
// mDNS queries use txid=0 and no flags (RFC 6762 §18.1).
|
||||
data, err := msg.AppendTo(frame[:0], mdnsTxID, mdnsFlags)
|
||||
if err != nil {
|
||||
c.qerr = err
|
||||
c.qstate = querierFailed
|
||||
return 0, err
|
||||
} else if len(data) != int(msglen) {
|
||||
panic("bad dns length calculation") // panic since this is a big bug in lneto.
|
||||
}
|
||||
c.qstate = querierAwaitResponse
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func (c *Client) encapsResponse(frame []byte) (int, error) {
|
||||
var msg dns.Message
|
||||
msg.Answers = c.rans
|
||||
msglen := msg.Len()
|
||||
if int(msglen) > len(frame) {
|
||||
return 0, lneto.ErrShortBuffer
|
||||
}
|
||||
// mDNS responses: txid=0, QR=1, AA=1 (RFC 6762 §18.4, §6).
|
||||
flags := dns.HeaderFlags(1<<15 | 1<<10)
|
||||
data, err := msg.AppendTo(frame[:0], 0, flags)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
c.rans = c.rans[:0] // Drain pending answers after successful send.
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
// Abort closes the client, causing all subsequent Encapsulate/Demux calls to return [net.ErrClosed].
|
||||
func (c *Client) Abort() {
|
||||
c.closed = true
|
||||
}
|
||||
|
||||
func (c *Client) isClosed() bool {
|
||||
return c.closed
|
||||
}
|
||||
|
||||
// AnswersCopyTo checks if [Client.StartResolve] ended succesfully before
|
||||
// doing a deep copy of answers received to the argument buffer using [dns.Resource.CopyFrom].
|
||||
func (c *Client) AnswersCopyTo(dst []dns.Resource) (n int, done bool, err error) {
|
||||
if len(dst) == 0 {
|
||||
return 0, false, lneto.ErrShortBuffer
|
||||
} else if c.qstate == querierIdle {
|
||||
return 0, false, net.ErrClosed
|
||||
} else if c.qstate == querierFailed {
|
||||
return 0, false, c.qerr
|
||||
} else if c.qstate != querierDone {
|
||||
return 0, false, nil
|
||||
}
|
||||
for i := range min(len(dst), len(c.qans)) {
|
||||
dst[i].CopyFrom(c.qans[i])
|
||||
n++
|
||||
}
|
||||
rcode := c.qcode
|
||||
if rcode != 0 {
|
||||
return n, true, rcode
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"github.com/soypat/lneto/dns"
|
||||
)
|
||||
|
||||
// IPv4MulticastAddr is the IPv4 multicast address used by mDNS (224.0.0.251).
|
||||
// Defined by RFC 6762. Packets sent to this address use UDP port 5353 and are
|
||||
// link-local (not routed beyond the local network segment).
|
||||
func IPv4MulticastAddr() [4]byte {
|
||||
return [4]byte{224, 0, 0, 251}
|
||||
}
|
||||
|
||||
// IPv4MulticastMAC is the Ethernet multicast MAC address corresponding to
|
||||
// 224.0.0.251 (01:00:5e:00:00:fb). Used for L2 delivery of mDNS over Ethernet.
|
||||
func IPv4MulticastMAC() [6]byte {
|
||||
return [6]byte{0x01, 0x00, 0x5e, 0x00, 0x00, 0xfb}
|
||||
}
|
||||
|
||||
// Service describes a service to advertise via mDNS.
|
||||
// A single Service produces PTR, SRV, TXT, and A resource records.
|
||||
//
|
||||
// i.e: To generate a hostname styled A record like the one
|
||||
// linux machines provide to reach them at hostname.local:
|
||||
//
|
||||
// s := Service{
|
||||
// Host: dns.NewName("yourhostname.local"),
|
||||
// Addr: ipAddressSlice,
|
||||
// }
|
||||
type Service struct {
|
||||
// Name is the fully-qualified service instance name in wire format,
|
||||
// e.g. "My Web Server._http._tcp.local".
|
||||
Name dns.Name
|
||||
// Host is the hostname in wire format, e.g. "mydevice.local".
|
||||
Host dns.Name
|
||||
// TXTData is raw TXT record data (length-prefixed strings).
|
||||
TXTData []byte
|
||||
// Addr is the IP address for the A record.
|
||||
Addr []byte
|
||||
// TTL is the record TTL in seconds. Zero uses DefaultTTL.
|
||||
TTL uint32
|
||||
// Port is the TCP/UDP port for the SRV record.
|
||||
Port uint16
|
||||
}
|
||||
|
||||
func (s *Service) ttl() uint32 {
|
||||
if s.TTL == 0 {
|
||||
return DefaultTTL
|
||||
}
|
||||
return s.TTL
|
||||
}
|
||||
|
||||
// serviceType extracts the service type portion of the instance name.
|
||||
// For "_http._tcp.local" it returns the same; for "My Web._http._tcp.local"
|
||||
// it returns "_http._tcp.local" by trimming the first label.
|
||||
// Returns a view into the original Name data — zero allocation.
|
||||
func (s *Service) serviceType() dns.Name {
|
||||
var totalLabels int
|
||||
s.Name.VisitLabels(func(label []byte) {
|
||||
totalLabels++
|
||||
})
|
||||
if totalLabels <= 3 {
|
||||
return s.Name
|
||||
}
|
||||
return s.Name.TrimLabels(1)
|
||||
}
|
||||
|
||||
// matchQuestion reports whether the question matches the given service.
|
||||
func matchQuestion(q *dns.Question, svc *Service) bool {
|
||||
switch q.Type {
|
||||
case dns.TypePTR:
|
||||
svcType := svc.serviceType()
|
||||
return dns.NamesEqual(q.Name, svcType)
|
||||
case dns.TypeSRV, dns.TypeTXT:
|
||||
return dns.NamesEqual(q.Name, svc.Name)
|
||||
case dns.TypeA:
|
||||
return dns.NamesEqual(q.Name, svc.Host)
|
||||
case dns.TypeALL:
|
||||
svcType := svc.serviceType()
|
||||
return dns.NamesEqual(q.Name, svc.Name) || dns.NamesEqual(q.Name, svc.Host) || dns.NamesEqual(q.Name, svcType)
|
||||
}
|
||||
return false
|
||||
}
|
||||
func MulticastMAC(ip [4]byte) (mac [6]byte, ok bool) {
|
||||
// Check IPv4 multicast range: 224.0.0.0/4
|
||||
if ip[0]&0xf0 != 0xe0 {
|
||||
return mac, false
|
||||
}
|
||||
|
||||
mac[0] = 0x01
|
||||
mac[1] = 0x00
|
||||
mac[2] = 0x5e
|
||||
|
||||
// Lower 23 bits of IP
|
||||
mac[3] = ip[1] & 0x7f // drop top bit
|
||||
mac[4] = ip[2]
|
||||
mac[5] = ip[3]
|
||||
|
||||
return mac, true
|
||||
}
|
||||
|
||||
// addServiceAnswers adds the appropriate answer records for a matched question.
|
||||
// It grows ans in-place, reusing existing Resource buffers when available.
|
||||
func addServiceAnswers(dst *[]dns.Resource, q *dns.Question, svc *Service) {
|
||||
cacheFlush := dns.Class(uint16(dns.ClassINET) | classCacheFlush)
|
||||
ttl := svc.ttl()
|
||||
txtData := svc.TXTData
|
||||
avail := cap(*dst) - len(*dst)
|
||||
switch q.Type {
|
||||
case dns.TypePTR:
|
||||
if avail < 1 {
|
||||
return
|
||||
}
|
||||
setPTR(growSlice(dst), svc)
|
||||
case dns.TypeSRV:
|
||||
if avail < 2 {
|
||||
return
|
||||
}
|
||||
growSlice(dst).SetSRV(svc.Name, cacheFlush, ttl, 0, 0, svc.Port, svc.Host)
|
||||
growSlice(dst).SetA(svc.Host, cacheFlush, ttl, svc.Addr)
|
||||
case dns.TypeTXT:
|
||||
if avail < 1 {
|
||||
return
|
||||
}
|
||||
growSlice(dst).SetTXT(svc.Name, cacheFlush, ttl, txtData)
|
||||
case dns.TypeA:
|
||||
if avail < 1 {
|
||||
return
|
||||
}
|
||||
growSlice(dst).SetA(svc.Host, cacheFlush, ttl, svc.Addr)
|
||||
case dns.TypeALL:
|
||||
if avail < 4 {
|
||||
return
|
||||
}
|
||||
setPTR(growSlice(dst), svc)
|
||||
growSlice(dst).SetSRV(svc.Name, cacheFlush, ttl, 0, 0, svc.Port, svc.Host)
|
||||
growSlice(dst).SetTXT(svc.Name, cacheFlush, ttl, txtData)
|
||||
growSlice(dst).SetA(svc.Host, cacheFlush, ttl, svc.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
// growSlice grows the slice by one element and returns a pointer to the new last element.
|
||||
// Panics if at capacity — callers must check available space before calling.
|
||||
func growSlice[T any](s *[]T) *T {
|
||||
*s = (*s)[:len(*s)+1]
|
||||
return &(*s)[len(*s)-1]
|
||||
}
|
||||
|
||||
func setPTR(ans *dns.Resource, svc *Service) {
|
||||
svcType := svc.serviceType()
|
||||
ans.SetPTR(svcType, dns.ClassINET, svc.ttl(), svc.Name)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user