external resolver

This commit is contained in:
Simone Margaritelli 2024-09-20 10:29:45 +02:00
parent e656a6cbfa
commit b0a197b377
15 changed files with 1768 additions and 70 deletions

26
epson_xp630.yml Normal file
View file

@ -0,0 +1,26 @@
- name: TEST_NAME
service: _ipps._tcp
domain: local.
port: 6631
records:
- txtvers=1
- ty=EPSON XP-630 Series
- usb_MFG=EPSON
- usb_MDL=XP-630 Series
- product=(EPSON XP-630 Series)
- pdl=application/octet-stream,image/pwg-raster,image/urf,image/jpeg
- rp=ipp/print
- qtotal=1
- Color=T
- Duplex=T
- Scan=T
- Fax=F
- kind=document,envelope,label,photo
- PaperMax=legal-A4
- URF=CP1,MT1-3-5-8-10-11-12,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7,V1.4
- mopria-certified=1.2
- priority=30
- adminurl=http://EPSON59F5BA.local.:80/PRESENTATION/BONJOUR
- note=
- UUID=cfe92100-67c4-11d4-a45f-44d24459f5ba
- TLS=1.2

27
external_responder.yml Normal file
View file

@ -0,0 +1,27 @@
- name: TEST_NAME
service: _ipps._tcp
domain: local.
port: 6631
responder: 134.122.95.96
records:
- txtvers=1
- ty=EPSON XP-630 Series
- usb_MFG=EPSON
- usb_MDL=XP-630 Series
- product=(EPSON XP-630 Series)
- pdl=application/octet-stream,image/pwg-raster,image/urf,image/jpeg
- rp=ipp/print
- qtotal=1
- Color=T
- Duplex=T
- Scan=T
- Fax=F
- kind=document,envelope,label,photo
- PaperMax=legal-A4
- URF=CP1,MT1-3-5-8-10-11-12,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7,V1.4
- mopria-certified=1.2
- priority=30
- adminurl=http://EPSON59F5BA.local.:80/PRESENTATION/BONJOUR
- note=
- UUID=cfe92100-67c4-11d4-a45f-44d24459f5ba
- TLS=1.2

2
go.mod
View file

@ -12,6 +12,7 @@ require (
github.com/bettercap/nrf24 v0.0.0-20190219153547-aa37e6d0e0eb
github.com/bettercap/readline v0.0.0-20210228151553-655e48bcb7bf
github.com/bettercap/recording v0.0.0-20190408083647-3ce1dcf032e3
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/dustin/go-humanize v1.0.1
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380
github.com/evilsocket/islazy v1.11.0
@ -42,7 +43,6 @@ require (
require (
github.com/antchfx/xpath v1.3.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/chzyer/logex v1.2.1 // indirect
github.com/elazarl/goproxy/ext v0.0.0-20210110162100-a92cc753f88e // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect

View file

@ -5,63 +5,60 @@ import (
"errors"
"fmt"
"io/ioutil"
"net"
"os"
"strings"
"time"
tls_utils "github.com/bettercap/bettercap/v2/tls"
"github.com/bettercap/bettercap/v2/zeroconf"
"github.com/evilsocket/islazy/fs"
"github.com/evilsocket/islazy/tui"
"github.com/grandcat/zeroconf"
yaml "gopkg.in/yaml.v3"
)
type Advertiser struct {
Filename string
Mapping map[string]zeroconf.ServiceEntry
Servers map[string]*zeroconf.Server
Acceptors map[string]*Acceptor
Filename string
Services []ServiceData
Servers []*zeroconf.Server
Acceptors []*Acceptor
}
type setupResult struct {
err error
key string
server *zeroconf.Server
}
func (mod *ZeroGod) startAdvertiser(fileName string) error {
if mod.advertiser != nil {
return fmt.Errorf("advertiser already started for %s", mod.advertiser.Filename)
}
func (mod *ZeroGod) loadTLSConfig() (*tls.Config, error) {
var certFile string
var keyFile string
var err error
// read tls configuration
if err, certFile = mod.StringParam("zerogod.advertise.certificate"); err != nil {
return err
return nil, err
} else if certFile, err = fs.Expand(certFile); err != nil {
return err
return nil, err
}
if err, keyFile = mod.StringParam("zerogod.advertise.key"); err != nil {
return err
return nil, err
} else if keyFile, err = fs.Expand(keyFile); err != nil {
return err
return nil, err
}
if !fs.Exists(certFile) || !fs.Exists(keyFile) {
cfg, err := tls_utils.CertConfigFromModule("zerogod.advertise", mod.SessionModule)
if err != nil {
return err
return nil, err
}
mod.Debug("%+v", cfg)
mod.Info("generating server TLS key to %s", keyFile)
mod.Info("generating server TLS certificate to %s", certFile)
if err := tls_utils.Generate(cfg, certFile, keyFile, false); err != nil {
return err
return nil, err
}
} else {
mod.Info("loading server TLS key from %s", keyFile)
@ -70,22 +67,23 @@ func (mod *ZeroGod) startAdvertiser(fileName string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
return nil, err
}
tlsConfig := tls.Config{
return &tls.Config{
Certificates: []tls.Certificate{cert},
InsecureSkipVerify: true,
}, nil
}
func (mod *ZeroGod) startAdvertiser(fileName string) error {
if mod.advertiser != nil {
return fmt.Errorf("advertiser already started for %s", mod.advertiser.Filename)
}
data, err := ioutil.ReadFile(fileName)
tlsConfig, err := mod.loadTLSConfig()
if err != nil {
return fmt.Errorf("could not read %s: %v", fileName, err)
}
mapping := make(map[string]zeroconf.ServiceEntry)
if err = yaml.Unmarshal(data, &mapping); err != nil {
return fmt.Errorf("could not deserialize %s: %v", fileName, err)
return err
}
hostName, err := os.Hostname()
@ -96,73 +94,121 @@ func (mod *ZeroGod) startAdvertiser(fileName string) error {
hostName += "."
}
mod.Info("loaded %d services from %s, advertising with host=%s iface=%s ipv4=%s ipv6=%s",
len(mapping),
fileName,
hostName,
mod.Session.Interface.Name(),
mod.Session.Interface.IpAddress,
mod.Session.Interface.Ip6Address)
data, err := ioutil.ReadFile(fileName)
if err != nil {
return fmt.Errorf("could not read %s: %v", fileName, err)
}
var services []ServiceData
if err = yaml.Unmarshal(data, &services); err != nil {
return fmt.Errorf("could not deserialize %s: %v", fileName, err)
}
mod.Info("loaded %d services from %s", len(services), fileName)
advertiser := &Advertiser{
Filename: fileName,
Mapping: mapping,
Servers: make(map[string]*zeroconf.Server),
Acceptors: make(map[string]*Acceptor),
Services: services,
Servers: make([]*zeroconf.Server, 0),
Acceptors: make([]*Acceptor, 0),
}
svcChan := make(chan setupResult)
// TODO: support external responders
// paralleize initialization
for key, svc := range mapping {
go func(key string, svc zeroconf.ServiceEntry) {
mod.Info("unregistering instance %s ...", tui.Yellow(fmt.Sprintf("%s.%s.%s", svc.Instance, svc.Service, svc.Domain)))
for _, svc := range services {
go func(svc ServiceData) {
mod.Info("unregistering instance %s ...", tui.Yellow(svc.FullName()))
// create a first instance just to deregister it from the network
server, err := zeroconf.Register(svc.Instance, svc.Service, svc.Domain, svc.Port, svc.Text, nil)
if err != nil {
svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.Instance, err)}
// deregister the service from the network first
if err := svc.Unregister(); err != nil {
svcChan <- setupResult{err: fmt.Errorf("could not unregister service %s: %v", svc.FullName(), err)}
return
}
server.Shutdown()
// give some time to the network to adjust
time.Sleep(time.Duration(1) * time.Second)
// now create it again to actually advertise
if server, err = zeroconf.Register(svc.Instance, svc.Service, svc.Domain, svc.Port, svc.Text, nil); err != nil {
svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.Instance, err)}
return
}
var server *zeroconf.Server
mod.Info("advertising service %s", tui.Yellow(svc.Service))
// now create it again to actually advertise
if svc.Responder == "" {
// use our own IP
if server, err = zeroconf.Register(
svc.Name,
svc.Service,
svc.Domain,
svc.Port,
svc.Records,
nil); err != nil {
svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.FullName(), err)}
return
}
mod.Info("advertising %s with responder=%s port=%d",
tui.Yellow(svc.FullName()),
tui.Red(svc.Responder),
svc.Port)
} else {
responderHostName := ""
// try first to do a reverse DNS of the ip
if addr, err := net.LookupAddr(svc.Responder); err == nil && len(addr) > 0 {
responderHostName = addr[0]
} else {
mod.Debug("could not get responder %s reverse dns entry: %v", svc.Responder, err)
}
// if we don't have a host, create a .nip.io representation
if responderHostName == "" {
responderHostName = fmt.Sprintf("%s.nip.io.", strings.ReplaceAll(svc.Responder, ".", "-"))
}
// use external responder
if server, err = zeroconf.RegisterExternalResponder(
svc.Name,
svc.Service,
svc.Domain,
svc.Port,
responderHostName,
[]string{svc.Responder},
svc.Records,
nil); err != nil {
svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.FullName(), err)}
return
}
mod.Info("advertising %s with responder=%s hostname=%s port=%d",
tui.Yellow(svc.FullName()),
tui.Red(svc.Responder),
tui.Yellow(responderHostName),
svc.Port)
}
svcChan <- setupResult{
key: key,
server: server,
}
}(key, svc)
}(svc)
}
for res := range svcChan {
if res.err != nil {
return res.err
}
advertiser.Servers[res.key] = res.server
if len(advertiser.Servers) == len(mapping) {
advertiser.Servers = append(advertiser.Servers, res.server)
if len(advertiser.Servers) == len(advertiser.Services) {
break
}
}
// now create the tcp acceptors
for key, svc := range mapping {
acceptor := NewAcceptor(mod, key, hostName, uint16(svc.Port), &tlsConfig)
if err := acceptor.Start(); err != nil {
return err
// now create the tcp acceptors for entries without an explicit responder address
for _, svc := range advertiser.Services {
if svc.Responder == "" {
acceptor := NewAcceptor(mod, svc.FullName(), hostName, uint16(svc.Port), tlsConfig)
if err := acceptor.Start(); err != nil {
return err
}
advertiser.Acceptors = append(advertiser.Acceptors, acceptor)
}
advertiser.Acceptors[key] = acceptor
}
mod.advertiser = advertiser
@ -177,7 +223,7 @@ func (mod *ZeroGod) stopAdvertiser() error {
return errors.New("advertiser not started")
}
mod.Info("stopping %d services ...", len(mod.advertiser.Mapping))
mod.Info("stopping %d services ...", len(mod.advertiser.Services))
for key, server := range mod.advertiser.Servers {
mod.Info("stopping %s ...", key)

View file

@ -7,9 +7,8 @@ import (
"github.com/bettercap/bettercap/v2/network"
"github.com/bettercap/bettercap/v2/session"
"github.com/bettercap/bettercap/v2/tls"
"github.com/bettercap/bettercap/v2/zeroconf"
"github.com/evilsocket/islazy/tui"
"github.com/grandcat/zeroconf"
)
type ZeroGod struct {

View file

@ -6,8 +6,8 @@ import (
"github.com/bettercap/bettercap/v2/modules/syn_scan"
"github.com/bettercap/bettercap/v2/network"
"github.com/bettercap/bettercap/v2/zeroconf"
"github.com/evilsocket/islazy/str"
"github.com/grandcat/zeroconf"
)
func (mod *ZeroGod) updateEndpointMeta(address string, endpoint *network.Endpoint, svc *zeroconf.ServiceEntry) {

View file

@ -256,6 +256,8 @@ func ippOnGetPrinterAttributes(mod *ZeroGod, client net.Conn, ipp_req *ipp.Reque
}
*/
// TODO: allow customization of these attributes.
// rfc2911 section 4.4
ipp_resp.PrinterAttributes = []ipp.Attributes{
{

View file

@ -3,10 +3,60 @@ package zerogod
import (
"fmt"
"io/ioutil"
"strings"
"github.com/bettercap/bettercap/v2/zeroconf"
"github.com/evilsocket/islazy/str"
yaml "gopkg.in/yaml.v3"
)
type ServiceData struct {
Name string `yaml:"name"` // Instance name (e.g. "My web page")
Service string `yaml:"service"` // Service name (e.g. _http._tcp.)
Domain string `yaml:"domain"` // If blank, assumes "local"
Port int `yaml:"port"` // Service port
Records []string `yaml:"records,omitempty"` // Service DNS text records
Responder string `yaml:"responder,omitempty"` // Optional IP to use instead of our tcp acceptor
}
func (svc ServiceData) FullName() string {
return fmt.Sprintf("%s.%s.%s",
strings.Trim(svc.Name, "."),
strings.Trim(svc.Service, "."),
strings.Trim(svc.Domain, "."))
}
func (svc ServiceData) Unregister() error {
if server, err := zeroconf.Register(svc.Name, svc.Service, svc.Domain, svc.Port, svc.Records, nil); err != nil {
return err
} else {
server.Shutdown()
}
return nil
}
func svcEntriesToData(services map[string]*zeroconf.ServiceEntry) []ServiceData {
data := make([]ServiceData, 0)
for _, svc := range services {
// filter out empty DNS records
records := ([]string)(nil)
for _, txt := range svc.Text {
if txt = str.Trim(txt); len(txt) > 0 {
records = append(records, txt)
}
}
data = append(data, ServiceData{
Name: svc.Instance,
Service: svc.Service,
Domain: svc.Domain,
Port: svc.Port,
Records: records,
})
}
return data
}
func (mod *ZeroGod) save(address, filename string) error {
if address == "" {
return fmt.Errorf("address cannot be empty")
@ -16,7 +66,8 @@ func (mod *ZeroGod) save(address, filename string) error {
}
if ipServices, found := mod.mapping[address]; found {
data, err := yaml.Marshal(ipServices)
services := svcEntriesToData(ipServices)
data, err := yaml.Marshal(services)
if err != nil {
return err
}
@ -26,7 +77,7 @@ func (mod *ZeroGod) save(address, filename string) error {
return err
}
mod.Info("mDNS information saved to %s", filename)
mod.Info("zeroconf information saved to %s", filename)
} else {
return fmt.Errorf("no mDNS information found for address %s", address)
}

View file

@ -4,9 +4,9 @@ import (
"fmt"
"sort"
"github.com/bettercap/bettercap/v2/zeroconf"
"github.com/evilsocket/islazy/str"
"github.com/evilsocket/islazy/tui"
"github.com/grandcat/zeroconf"
)
type entry struct {

482
zeroconf/client.go Normal file
View file

@ -0,0 +1,482 @@
package zeroconf
import (
"context"
"fmt"
"log"
"net"
"runtime"
"strings"
"time"
"github.com/cenkalti/backoff"
"github.com/miekg/dns"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
// IPType specifies the IP traffic the client listens for.
// This does not guarantee that only mDNS entries of this sepcific
// type passes. E.g. typical mDNS packets distributed via IPv4, often contain
// both DNS A and AAAA entries.
type IPType uint8
// Options for IPType.
const (
IPv4 = 0x01
IPv6 = 0x02
IPv4AndIPv6 = (IPv4 | IPv6) //< Default option.
)
type clientOpts struct {
listenOn IPType
ifaces []net.Interface
}
// ClientOption fills the option struct to configure intefaces, etc.
type ClientOption func(*clientOpts)
// SelectIPTraffic selects the type of IP packets (IPv4, IPv6, or both) this
// instance listens for.
// This does not guarantee that only mDNS entries of this sepcific
// type passes. E.g. typical mDNS packets distributed via IPv4, may contain
// both DNS A and AAAA entries.
func SelectIPTraffic(t IPType) ClientOption {
return func(o *clientOpts) {
o.listenOn = t
}
}
// SelectIfaces selects the interfaces to query for mDNS records
func SelectIfaces(ifaces []net.Interface) ClientOption {
return func(o *clientOpts) {
o.ifaces = ifaces
}
}
// Resolver acts as entry point for service lookups and to browse the DNS-SD.
type Resolver struct {
c *client
}
// NewResolver creates a new resolver and joins the UDP multicast groups to
// listen for mDNS messages.
func NewResolver(options ...ClientOption) (*Resolver, error) {
// Apply default configuration and load supplied options.
var conf = clientOpts{
listenOn: IPv4AndIPv6,
}
for _, o := range options {
if o != nil {
o(&conf)
}
}
c, err := newClient(conf)
if err != nil {
return nil, err
}
return &Resolver{
c: c,
}, nil
}
// Browse for all services of a given type in a given domain.
func (r *Resolver) Browse(ctx context.Context, service, domain string, entries chan<- *ServiceEntry) error {
params := defaultParams(service)
if domain != "" {
params.SetDomain(domain)
}
params.Entries = entries
params.isBrowsing = true
ctx, cancel := context.WithCancel(ctx)
go r.c.mainloop(ctx, params)
err := r.c.query(params)
if err != nil {
cancel()
return err
}
// If previous probe was ok, it should be fine now. In case of an error later on,
// the entries' queue is closed.
go func() {
if err := r.c.periodicQuery(ctx, params); err != nil {
cancel()
}
}()
return nil
}
// Lookup a specific service by its name and type in a given domain.
func (r *Resolver) Lookup(ctx context.Context, instance, service, domain string, entries chan<- *ServiceEntry) error {
params := defaultParams(service)
params.Instance = instance
if domain != "" {
params.SetDomain(domain)
}
params.Entries = entries
ctx, cancel := context.WithCancel(ctx)
go r.c.mainloop(ctx, params)
err := r.c.query(params)
if err != nil {
// cancel mainloop
cancel()
return err
}
// If previous probe was ok, it should be fine now. In case of an error later on,
// the entries' queue is closed.
go func() {
if err := r.c.periodicQuery(ctx, params); err != nil {
cancel()
}
}()
return nil
}
// defaultParams returns a default set of QueryParams.
func defaultParams(service string) *lookupParams {
return newLookupParams("", service, "local", false, make(chan *ServiceEntry))
}
// Client structure encapsulates both IPv4/IPv6 UDP connections.
type client struct {
ipv4conn *ipv4.PacketConn
ipv6conn *ipv6.PacketConn
ifaces []net.Interface
}
// Client structure constructor
func newClient(opts clientOpts) (*client, error) {
ifaces := opts.ifaces
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
// IPv4 interfaces
var ipv4conn *ipv4.PacketConn
if (opts.listenOn & IPv4) > 0 {
var err error
ipv4conn, err = joinUdp4Multicast(ifaces)
if err != nil {
return nil, err
}
}
// IPv6 interfaces
var ipv6conn *ipv6.PacketConn
if (opts.listenOn & IPv6) > 0 {
var err error
ipv6conn, err = joinUdp6Multicast(ifaces)
if err != nil {
return nil, err
}
}
return &client{
ipv4conn: ipv4conn,
ipv6conn: ipv6conn,
ifaces: ifaces,
}, nil
}
// Start listeners and waits for the shutdown signal from exit channel
func (c *client) mainloop(ctx context.Context, params *lookupParams) {
// start listening for responses
msgCh := make(chan *dns.Msg, 32)
if c.ipv4conn != nil {
go c.recv(ctx, c.ipv4conn, msgCh)
}
if c.ipv6conn != nil {
go c.recv(ctx, c.ipv6conn, msgCh)
}
// Iterate through channels from listeners goroutines
var entries, sentEntries map[string]*ServiceEntry
sentEntries = make(map[string]*ServiceEntry)
for {
select {
case <-ctx.Done():
// Context expired. Notify subscriber that we are done here.
params.done()
c.shutdown()
return
case msg := <-msgCh:
entries = make(map[string]*ServiceEntry)
sections := append(msg.Answer, msg.Ns...)
sections = append(sections, msg.Extra...)
for _, answer := range sections {
switch rr := answer.(type) {
case *dns.PTR:
if params.ServiceName() != rr.Hdr.Name {
continue
}
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Ptr {
continue
}
if _, ok := entries[rr.Ptr]; !ok {
entries[rr.Ptr] = NewServiceEntry(
trimDot(strings.Replace(rr.Ptr, rr.Hdr.Name, "", -1)),
params.Service,
params.Domain)
}
entries[rr.Ptr].TTL = rr.Hdr.Ttl
case *dns.SRV:
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name {
continue
} else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) {
continue
}
if _, ok := entries[rr.Hdr.Name]; !ok {
entries[rr.Hdr.Name] = NewServiceEntry(
trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)),
params.Service,
params.Domain)
}
entries[rr.Hdr.Name].HostName = rr.Target
entries[rr.Hdr.Name].Port = int(rr.Port)
entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl
case *dns.TXT:
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name {
continue
} else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) {
continue
}
if _, ok := entries[rr.Hdr.Name]; !ok {
entries[rr.Hdr.Name] = NewServiceEntry(
trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)),
params.Service,
params.Domain)
}
entries[rr.Hdr.Name].Text = rr.Txt
entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl
}
}
// Associate IPs in a second round as other fields should be filled by now.
for _, answer := range sections {
switch rr := answer.(type) {
case *dns.A:
for k, e := range entries {
if e.HostName == rr.Hdr.Name {
entries[k].AddrIPv4 = append(entries[k].AddrIPv4, rr.A)
}
}
case *dns.AAAA:
for k, e := range entries {
if e.HostName == rr.Hdr.Name {
entries[k].AddrIPv6 = append(entries[k].AddrIPv6, rr.AAAA)
}
}
}
}
}
if len(entries) > 0 {
for k, e := range entries {
if e.TTL == 0 {
delete(entries, k)
delete(sentEntries, k)
continue
}
if _, ok := sentEntries[k]; ok {
continue
}
// If this is an DNS-SD query do not throw PTR away.
// It is expected to have only PTR for enumeration
if params.ServiceRecord.ServiceTypeName() != params.ServiceRecord.ServiceName() {
// Require at least one resolved IP address for ServiceEntry
// TODO: wait some more time as chances are high both will arrive.
if len(e.AddrIPv4) == 0 && len(e.AddrIPv6) == 0 {
continue
}
}
// Submit entry to subscriber and cache it.
// This is also a point to possibly stop probing actively for a
// service entry.
params.Entries <- e
sentEntries[k] = e
if !params.isBrowsing {
params.disableProbing()
}
}
}
}
}
// Shutdown client will close currently open connections and channel implicitly.
func (c *client) shutdown() {
if c.ipv4conn != nil {
c.ipv4conn.Close()
}
if c.ipv6conn != nil {
c.ipv6conn.Close()
}
}
// Data receiving routine reads from connection, unpacks packets into dns.Msg
// structures and sends them to a given msgCh channel
func (c *client) recv(ctx context.Context, l interface{}, msgCh chan *dns.Msg) {
var readFrom func([]byte) (n int, src net.Addr, err error)
switch pConn := l.(type) {
case *ipv6.PacketConn:
readFrom = func(b []byte) (n int, src net.Addr, err error) {
n, _, src, err = pConn.ReadFrom(b)
return
}
case *ipv4.PacketConn:
readFrom = func(b []byte) (n int, src net.Addr, err error) {
n, _, src, err = pConn.ReadFrom(b)
return
}
default:
return
}
buf := make([]byte, 65536)
var fatalErr error
for {
// Handles the following cases:
// - ReadFrom aborts with error due to closed UDP connection -> causes ctx cancel
// - ReadFrom aborts otherwise.
// TODO: the context check can be removed. Verify!
if ctx.Err() != nil || fatalErr != nil {
return
}
n, _, err := readFrom(buf)
if err != nil {
fatalErr = err
continue
}
msg := new(dns.Msg)
if err := msg.Unpack(buf[:n]); err != nil {
// log.Printf("[WARN] mdns: Failed to unpack packet: %v", err)
continue
}
select {
case msgCh <- msg:
// Submit decoded DNS message and continue.
case <-ctx.Done():
// Abort.
return
}
}
}
// periodicQuery sens multiple probes until a valid response is received by
// the main processing loop or some timeout/cancel fires.
// TODO: move error reporting to shutdown function as periodicQuery is called from
// go routine context.
func (c *client) periodicQuery(ctx context.Context, params *lookupParams) error {
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 4 * time.Second
bo.MaxInterval = 60 * time.Second
bo.MaxElapsedTime = 0
bo.Reset()
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
}()
for {
// Backoff and cancel logic.
wait := bo.NextBackOff()
if wait == backoff.Stop {
return fmt.Errorf("periodicQuery: abort due to timeout")
}
if timer == nil {
timer = time.NewTimer(wait)
} else {
timer.Reset(wait)
}
select {
case <-timer.C:
// Wait for next iteration.
case <-params.stopProbing:
// Chan is closed (or happened in the past).
// Done here. Received a matching mDNS entry.
return nil
case <-ctx.Done():
return ctx.Err()
}
// Do periodic query.
if err := c.query(params); err != nil {
return err
}
}
}
// Performs the actual query by service name (browse) or service instance name (lookup),
// start response listeners goroutines and loops over the entries channel.
func (c *client) query(params *lookupParams) error {
var serviceName, serviceInstanceName string
serviceName = fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain))
// send the query
m := new(dns.Msg)
if params.Instance != "" { // service instance name lookup
serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName)
m.Question = []dns.Question{
{Name: serviceInstanceName, Qtype: dns.TypeSRV, Qclass: dns.ClassINET},
{Name: serviceInstanceName, Qtype: dns.TypeTXT, Qclass: dns.ClassINET},
}
} else if len(params.Subtypes) > 0 { // service subtype browse
m.SetQuestion(params.Subtypes[0], dns.TypePTR)
} else { // service name browse
m.SetQuestion(serviceName, dns.TypePTR)
}
m.RecursionDesired = false
if err := c.sendQuery(m); err != nil {
return err
}
return nil
}
// Pack the dns.Msg and write to available connections (multicast)
func (c *client) sendQuery(msg *dns.Msg) error {
buf, err := msg.Pack()
if err != nil {
return err
}
if c.ipv4conn != nil {
// See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG
// As of Golang 1.18.4
// On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented.
var wcm ipv4.ControlMessage
for ifi := range c.ifaces {
switch runtime.GOOS {
case "darwin", "ios", "linux":
wcm.IfIndex = c.ifaces[ifi].Index
default:
if err := c.ipv4conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil {
log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err)
}
}
c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
}
}
if c.ipv6conn != nil {
// See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG
// As of Golang 1.18.4
// On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented.
var wcm ipv6.ControlMessage
for ifi := range c.ifaces {
switch runtime.GOOS {
case "darwin", "ios", "linux":
wcm.IfIndex = c.ifaces[ifi].Index
default:
if err := c.ipv6conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil {
log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err)
}
}
c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
}
}
return nil
}

117
zeroconf/connection.go Normal file
View file

@ -0,0 +1,117 @@
package zeroconf
import (
"fmt"
"net"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
var (
// Multicast groups used by mDNS
mdnsGroupIPv4 = net.IPv4(224, 0, 0, 251)
mdnsGroupIPv6 = net.ParseIP("ff02::fb")
// mDNS wildcard addresses
mdnsWildcardAddrIPv4 = &net.UDPAddr{
IP: net.ParseIP("224.0.0.0"),
Port: 5353,
}
mdnsWildcardAddrIPv6 = &net.UDPAddr{
IP: net.ParseIP("ff02::"),
// IP: net.ParseIP("fd00::12d3:26e7:48db:e7d"),
Port: 5353,
}
// mDNS endpoint addresses
ipv4Addr = &net.UDPAddr{
IP: mdnsGroupIPv4,
Port: 5353,
}
ipv6Addr = &net.UDPAddr{
IP: mdnsGroupIPv6,
Port: 5353,
}
)
func joinUdp6Multicast(interfaces []net.Interface) (*ipv6.PacketConn, error) {
udpConn, err := net.ListenUDP("udp6", mdnsWildcardAddrIPv6)
if err != nil {
return nil, err
}
// Join multicast groups to receive announcements
pkConn := ipv6.NewPacketConn(udpConn)
pkConn.SetControlMessage(ipv6.FlagInterface, true)
_ = pkConn.SetMulticastHopLimit(255)
if len(interfaces) == 0 {
interfaces = listMulticastInterfaces()
}
// log.Println("Using multicast interfaces: ", interfaces)
var failedJoins int
for _, iface := range interfaces {
if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
// log.Println("Udp6 JoinGroup failed for iface ", iface)
failedJoins++
}
}
if failedJoins == len(interfaces) {
pkConn.Close()
return nil, fmt.Errorf("udp6: failed to join any of these interfaces: %v", interfaces)
}
return pkConn, nil
}
func joinUdp4Multicast(interfaces []net.Interface) (*ipv4.PacketConn, error) {
udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4)
if err != nil {
// log.Printf("[ERR] bonjour: Failed to bind to udp4 mutlicast: %v", err)
return nil, err
}
// Join multicast groups to receive announcements
pkConn := ipv4.NewPacketConn(udpConn)
pkConn.SetControlMessage(ipv4.FlagInterface, true)
_ = pkConn.SetMulticastTTL(255)
if len(interfaces) == 0 {
interfaces = listMulticastInterfaces()
}
// log.Println("Using multicast interfaces: ", interfaces)
var failedJoins int
for _, iface := range interfaces {
if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
// log.Println("Udp4 JoinGroup failed for iface ", iface)
failedJoins++
}
}
if failedJoins == len(interfaces) {
pkConn.Close()
return nil, fmt.Errorf("udp4: failed to join any of these interfaces: %v", interfaces)
}
return pkConn, nil
}
func listMulticastInterfaces() []net.Interface {
var interfaces []net.Interface
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for _, ifi := range ifaces {
if (ifi.Flags & net.FlagUp) == 0 {
continue
}
if (ifi.Flags & net.FlagMulticast) > 0 {
interfaces = append(interfaces, ifi)
}
}
return interfaces
}

14
zeroconf/doc.go Normal file
View file

@ -0,0 +1,14 @@
// Package zeroconf is a pure Golang library that employs Multicast DNS-SD for
// browsing and resolving services in your network and registering own services
// in the local network.
//
// It basically implements aspects of the standards
// RFC 6762 (mDNS) and
// RFC 6763 (DNS-SD).
// Though it does not support all requirements yet, the aim is to provide a
// complient solution in the long-term with the community.
//
// By now, it should be compatible to [Avahi](http://avahi.org/) (tested) and
// Apple's Bonjour (untested). Should work in the most office, home and private
// environments.
package zeroconf

796
zeroconf/server.go Normal file
View file

@ -0,0 +1,796 @@
package zeroconf
import (
"errors"
"fmt"
"log"
"math/rand"
"net"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
const (
// Number of Multicast responses sent for a query message (default: 1 < x < 9)
multicastRepetitions = 2
)
// Register a service by given arguments. This call will take the system's hostname
// and lookup IP by that hostname.
func Register(instance, service, domain string, port int, text []string, ifaces []net.Interface) (*Server, error) {
entry := NewServiceEntry(instance, service, domain)
entry.Port = port
entry.Text = text
if entry.Instance == "" {
return nil, fmt.Errorf("missing service instance name")
}
if entry.Service == "" {
return nil, fmt.Errorf("missing service name")
}
if entry.Domain == "" {
entry.Domain = "local."
}
if entry.Port == 0 {
return nil, fmt.Errorf("missing port")
}
var err error
if entry.HostName == "" {
entry.HostName, err = os.Hostname()
if err != nil {
return nil, fmt.Errorf("could not determine host")
}
}
if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) {
entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain))
}
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
for _, iface := range ifaces {
v4, v6 := addrsForInterface(&iface)
entry.AddrIPv4 = append(entry.AddrIPv4, v4...)
entry.AddrIPv6 = append(entry.AddrIPv6, v6...)
}
if entry.AddrIPv4 == nil && entry.AddrIPv6 == nil {
return nil, fmt.Errorf("could not determine host IP addresses")
}
s, err := newServer(ifaces)
if err != nil {
return nil, err
}
s.service = entry
go s.mainloop()
go s.probe()
return s, nil
}
// RegisterExternalResponder registers a service proxy. This call will skip the hostname/IP lookup and
// will use the provided values.
func RegisterExternalResponder(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface) (*Server, error) {
entry := NewServiceEntry(instance, service, domain)
entry.Port = port
entry.Text = text
entry.HostName = host
if entry.Instance == "" {
return nil, fmt.Errorf("missing service instance name")
}
if entry.Service == "" {
return nil, fmt.Errorf("missing service name")
}
if entry.HostName == "" {
return nil, fmt.Errorf("missing host name")
}
if entry.Domain == "" {
entry.Domain = "local"
}
if entry.Port == 0 {
return nil, fmt.Errorf("missing port")
}
/*
if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) {
entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain))
}
*/
for _, ip := range ips {
ipAddr := net.ParseIP(ip)
if ipAddr == nil {
return nil, fmt.Errorf("failed to parse given IP: %v", ip)
} else if ipv4 := ipAddr.To4(); ipv4 != nil {
entry.AddrIPv4 = append(entry.AddrIPv4, ipAddr)
} else if ipv6 := ipAddr.To16(); ipv6 != nil {
entry.AddrIPv6 = append(entry.AddrIPv6, ipAddr)
} else {
return nil, fmt.Errorf("the IP is neither IPv4 nor IPv6: %#v", ipAddr)
}
}
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
s, err := newServer(ifaces)
if err != nil {
return nil, err
}
s.service = entry
go s.mainloop()
go s.probe()
return s, nil
}
const (
qClassCacheFlush uint16 = 1 << 15
)
// Server structure encapsulates both IPv4/IPv6 UDP connections
type Server struct {
service *ServiceEntry
ipv4conn *ipv4.PacketConn
ipv6conn *ipv6.PacketConn
ifaces []net.Interface
shouldShutdown chan struct{}
shutdownLock sync.Mutex
shutdownEnd sync.WaitGroup
isShutdown bool
ttl uint32
}
// Constructs server structure
func newServer(ifaces []net.Interface) (*Server, error) {
ipv4conn, err4 := joinUdp4Multicast(ifaces)
if err4 != nil {
log.Printf("[zeroconf] no suitable IPv4 interface: %s", err4.Error())
}
ipv6conn, err6 := joinUdp6Multicast(ifaces)
if err6 != nil {
log.Printf("[zeroconf] no suitable IPv6 interface: %s", err6.Error())
}
if err4 != nil && err6 != nil {
// No supported interface left.
return nil, fmt.Errorf("no supported interface")
}
s := &Server{
ipv4conn: ipv4conn,
ipv6conn: ipv6conn,
ifaces: ifaces,
ttl: 3200,
shouldShutdown: make(chan struct{}),
}
return s, nil
}
// Start listeners and waits for the shutdown signal from exit channel
func (s *Server) mainloop() {
if s.ipv4conn != nil {
go s.recv4(s.ipv4conn)
}
if s.ipv6conn != nil {
go s.recv6(s.ipv6conn)
}
}
// Shutdown closes all udp connections and unregisters the service
func (s *Server) Shutdown() {
s.shutdown()
}
// SetText updates and announces the TXT records
func (s *Server) SetText(text []string) {
s.service.Text = text
s.announceText()
}
// TTL sets the TTL for DNS replies
func (s *Server) TTL(ttl uint32) {
s.ttl = ttl
}
// Shutdown server will close currently open connections & channel
func (s *Server) shutdown() error {
s.shutdownLock.Lock()
defer s.shutdownLock.Unlock()
if s.isShutdown {
return errors.New("server is already shutdown")
}
err := s.unregister()
close(s.shouldShutdown)
if s.ipv4conn != nil {
s.ipv4conn.Close()
}
if s.ipv6conn != nil {
s.ipv6conn.Close()
}
// Wait for connection and routines to be closed
s.shutdownEnd.Wait()
s.isShutdown = true
return err
}
// recv is a long running routine to receive packets from an interface
func (s *Server) recv4(c *ipv4.PacketConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
s.shutdownEnd.Add(1)
defer s.shutdownEnd.Done()
for {
select {
case <-s.shouldShutdown:
return
default:
var ifIndex int
n, cm, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if cm != nil {
ifIndex = cm.IfIndex
}
_ = s.parsePacket(buf[:n], ifIndex, from)
}
}
}
// recv is a long running routine to receive packets from an interface
func (s *Server) recv6(c *ipv6.PacketConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
s.shutdownEnd.Add(1)
defer s.shutdownEnd.Done()
for {
select {
case <-s.shouldShutdown:
return
default:
var ifIndex int
n, cm, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if cm != nil {
ifIndex = cm.IfIndex
}
_ = s.parsePacket(buf[:n], ifIndex, from)
}
}
}
// parsePacket is used to parse an incoming packet
func (s *Server) parsePacket(packet []byte, ifIndex int, from net.Addr) error {
var msg dns.Msg
if err := msg.Unpack(packet); err != nil {
// log.Printf("[ERR] zeroconf: Failed to unpack packet: %v", err)
return err
}
return s.handleQuery(&msg, ifIndex, from)
}
// handleQuery is used to handle an incoming query
func (s *Server) handleQuery(query *dns.Msg, ifIndex int, from net.Addr) error {
// Ignore questions with authoritative section for now
if len(query.Ns) > 0 {
return nil
}
// Handle each question
var err error
for _, q := range query.Question {
resp := dns.Msg{}
resp.SetReply(query)
resp.Compress = true
resp.RecursionDesired = false
resp.Authoritative = true
resp.Question = nil // RFC6762 section 6 "responses MUST NOT contain any questions"
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
if err = s.handleQuestion(q, &resp, query, ifIndex); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle question %v: %v", q, err)
continue
}
// Check if there is an answer
if len(resp.Answer) == 0 {
continue
}
if isUnicastQuestion(q) {
// Send unicast
if e := s.unicastResponse(&resp, ifIndex, from); e != nil {
err = e
}
} else {
// Send mulicast
if e := s.multicastResponse(&resp, ifIndex); e != nil {
err = e
}
}
}
return err
}
// RFC6762 7.1. Known-Answer Suppression
func isKnownAnswer(resp *dns.Msg, query *dns.Msg) bool {
if len(resp.Answer) == 0 || len(query.Answer) == 0 {
return false
}
if resp.Answer[0].Header().Rrtype != dns.TypePTR {
return false
}
answer := resp.Answer[0].(*dns.PTR)
for _, known := range query.Answer {
hdr := known.Header()
if hdr.Rrtype != answer.Hdr.Rrtype {
continue
}
ptr := known.(*dns.PTR)
if ptr.Ptr == answer.Ptr && hdr.Ttl >= answer.Hdr.Ttl/2 {
// log.Printf("skipping known answer: %v", ptr)
return true
}
}
return false
}
// handleQuestion is used to handle an incoming question
func (s *Server) handleQuestion(q dns.Question, resp *dns.Msg, query *dns.Msg, ifIndex int) error {
if s.service == nil {
return nil
}
switch q.Name {
case s.service.ServiceTypeName():
s.serviceTypeName(resp, s.ttl)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
case s.service.ServiceName():
s.composeBrowsingAnswers(resp, ifIndex)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
case s.service.ServiceInstanceName():
s.composeLookupAnswers(resp, s.ttl, ifIndex, false)
default:
// handle matching subtype query
for _, subtype := range s.service.Subtypes {
subtype = fmt.Sprintf("%s._sub.%s", subtype, s.service.ServiceName())
if q.Name == subtype {
s.composeBrowsingAnswers(resp, ifIndex)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
break
}
}
}
return nil
}
func (s *Server) composeBrowsingAnswers(resp *dns.Msg, ifIndex int) {
ptr := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Ptr: s.service.ServiceInstanceName(),
}
resp.Answer = append(resp.Answer, ptr)
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
resp.Extra = append(resp.Extra, srv, txt)
resp.Extra = s.appendAddrs(resp.Extra, s.ttl, ifIndex, false)
}
func (s *Server) composeLookupAnswers(resp *dns.Msg, ttl uint32, ifIndex int, flushCache bool) {
// From RFC6762
// The most significant bit of the rrclass for a record in the Answer
// Section of a response message is the Multicast DNS cache-flush bit
// and is discussed in more detail below in Section 10.2, "Announcements
// to Flush Outdated Cache Entries".
ptr := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceInstanceName(),
}
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: ttl,
},
Txt: s.service.Text,
}
dnssd := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceTypeName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceName(),
}
resp.Answer = append(resp.Answer, srv, txt, ptr, dnssd)
for _, subtype := range s.service.Subtypes {
resp.Answer = append(resp.Answer,
&dns.PTR{
Hdr: dns.RR_Header{
Name: subtype,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceInstanceName(),
})
}
resp.Answer = s.appendAddrs(resp.Answer, ttl, ifIndex, flushCache)
}
func (s *Server) serviceTypeName(resp *dns.Msg, ttl uint32) {
// From RFC6762
// 9. Service Type Enumeration
//
// For this purpose, a special meta-query is defined. A DNS query for
// PTR records with the name "_services._dns-sd._udp.<Domain>" yields a
// set of PTR records, where the rdata of each PTR record is the two-
// label <Service> name, plus the same domain, e.g.,
// "_http._tcp.<Domain>".
dnssd := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceTypeName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceName(),
}
resp.Answer = append(resp.Answer, dnssd)
}
// Perform probing & announcement
// TODO: implement a proper probing & conflict resolution
func (s *Server) probe() {
q := new(dns.Msg)
q.SetQuestion(s.service.ServiceInstanceName(), dns.TypePTR)
q.RecursionDesired = false
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
q.Ns = []dns.RR{srv, txt}
randomizer := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < multicastRepetitions; i++ {
if err := s.multicastResponse(q, 0); err != nil {
log.Println("[ERR] zeroconf: failed to send probe:", err.Error())
}
time.Sleep(time.Duration(randomizer.Intn(250)) * time.Millisecond)
}
// From RFC6762
// The Multicast DNS responder MUST send at least two unsolicited
// responses, one second apart. To provide increased robustness against
// packet loss, a responder MAY send up to eight unsolicited responses,
// provided that the interval between unsolicited responses increases by
// at least a factor of two with every response sent.
timeout := 1 * time.Second
for i := 0; i < multicastRepetitions; i++ {
for _, intf := range s.ifaces {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
// TODO: make response authoritative if we are the publisher
resp.Compress = true
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
s.composeLookupAnswers(resp, s.ttl, intf.Index, true)
if err := s.multicastResponse(resp, intf.Index); err != nil {
log.Println("[ERR] zeroconf: failed to send announcement:", err.Error())
}
}
time.Sleep(timeout)
timeout *= 2
}
}
// announceText sends a Text announcement with cache flush enabled
func (s *Server) announceText() {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
resp.Answer = []dns.RR{txt}
s.multicastResponse(resp, 0)
}
func (s *Server) unregister() error {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
s.composeLookupAnswers(resp, 0, 0, true)
return s.multicastResponse(resp, 0)
}
func (s *Server) appendAddrs(list []dns.RR, ttl uint32, ifIndex int, flushCache bool) []dns.RR {
v4 := s.service.AddrIPv4
v6 := s.service.AddrIPv6
if len(v4) == 0 && len(v6) == 0 {
iface, _ := net.InterfaceByIndex(ifIndex)
if iface != nil {
a4, a6 := addrsForInterface(iface)
v4 = append(v4, a4...)
v6 = append(v6, a6...)
}
}
if ttl > 0 {
// RFC6762 Section 10 says A/AAAA records SHOULD
// use TTL of 120s, to account for network interface
// and IP address changes.
ttl = 120
}
var cacheFlushBit uint16
if flushCache {
cacheFlushBit = qClassCacheFlush
}
for _, ipv4 := range v4 {
a := &dns.A{
Hdr: dns.RR_Header{
Name: s.service.HostName,
Rrtype: dns.TypeA,
Class: dns.ClassINET | cacheFlushBit,
Ttl: ttl,
},
A: ipv4,
}
list = append(list, a)
}
for _, ipv6 := range v6 {
aaaa := &dns.AAAA{
Hdr: dns.RR_Header{
Name: s.service.HostName,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET | cacheFlushBit,
Ttl: ttl,
},
AAAA: ipv6,
}
list = append(list, aaaa)
}
return list
}
func addrsForInterface(iface *net.Interface) ([]net.IP, []net.IP) {
var v4, v6, v6local []net.IP
addrs, _ := iface.Addrs()
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
v4 = append(v4, ipnet.IP)
} else {
switch ip := ipnet.IP.To16(); ip != nil {
case ip.IsGlobalUnicast():
v6 = append(v6, ipnet.IP)
case ip.IsLinkLocalUnicast():
v6local = append(v6local, ipnet.IP)
}
}
}
}
if len(v6) == 0 {
v6 = v6local
}
return v4, v6
}
// unicastResponse is used to send a unicast response packet
func (s *Server) unicastResponse(resp *dns.Msg, ifIndex int, from net.Addr) error {
buf, err := resp.Pack()
if err != nil {
return err
}
addr := from.(*net.UDPAddr)
if addr.IP.To4() != nil {
if ifIndex != 0 {
var wcm ipv4.ControlMessage
wcm.IfIndex = ifIndex
_, err = s.ipv4conn.WriteTo(buf, &wcm, addr)
} else {
_, err = s.ipv4conn.WriteTo(buf, nil, addr)
}
return err
} else {
if ifIndex != 0 {
var wcm ipv6.ControlMessage
wcm.IfIndex = ifIndex
_, err = s.ipv6conn.WriteTo(buf, &wcm, addr)
} else {
_, err = s.ipv6conn.WriteTo(buf, nil, addr)
}
return err
}
}
// multicastResponse us used to send a multicast response packet
func (s *Server) multicastResponse(msg *dns.Msg, ifIndex int) error {
buf, err := msg.Pack()
if err != nil {
return fmt.Errorf("error creating multicast response: %v", err)
}
if s.ipv4conn != nil {
// See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG
// As of Golang 1.18.4
// On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented.
var wcm ipv4.ControlMessage
if ifIndex != 0 {
switch runtime.GOOS {
case "darwin", "ios", "linux":
wcm.IfIndex = ifIndex
default:
iface, _ := net.InterfaceByIndex(ifIndex)
if err := s.ipv4conn.SetMulticastInterface(iface); err != nil {
log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err)
}
}
s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
} else {
for _, intf := range s.ifaces {
switch runtime.GOOS {
case "darwin", "ios", "linux":
wcm.IfIndex = intf.Index
default:
if err := s.ipv4conn.SetMulticastInterface(&intf); err != nil {
log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err)
}
}
s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
}
}
}
if s.ipv6conn != nil {
// See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG
// As of Golang 1.18.4
// On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented.
var wcm ipv6.ControlMessage
if ifIndex != 0 {
switch runtime.GOOS {
case "darwin", "ios", "linux":
wcm.IfIndex = ifIndex
default:
iface, _ := net.InterfaceByIndex(ifIndex)
if err := s.ipv6conn.SetMulticastInterface(iface); err != nil {
log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err)
}
}
s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
} else {
for _, intf := range s.ifaces {
switch runtime.GOOS {
case "darwin", "ios", "linux":
wcm.IfIndex = intf.Index
default:
if err := s.ipv6conn.SetMulticastInterface(&intf); err != nil {
log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err)
}
}
s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
}
}
}
return nil
}
func isUnicastQuestion(q dns.Question) bool {
// From RFC6762
// 18.12. Repurposing of Top Bit of qclass in Question Section
//
// In the Question Section of a Multicast DNS query, the top bit of the
// qclass field is used to indicate that unicast responses are preferred
// for this particular question. (See Section 5.4.)
return q.Qclass&qClassCacheFlush != 0
}

125
zeroconf/service.go Normal file
View file

@ -0,0 +1,125 @@
package zeroconf
import (
"fmt"
"net"
"sync"
)
// ServiceRecord contains the basic description of a service, which contains instance name, service type & domain
type ServiceRecord struct {
Instance string `json:"name"` // Instance name (e.g. "My web page")
Service string `json:"type"` // Service name (e.g. _http._tcp.)
Subtypes []string `json:"subtypes"` // Service subtypes
Domain string `json:"domain"` // If blank, assumes "local"
// private variable populated on ServiceRecord creation
serviceName string
serviceInstanceName string
serviceTypeName string
}
// ServiceName returns a complete service name (e.g. _foobar._tcp.local.), which is composed
// of a service name (also referred as service type) and a domain.
func (s *ServiceRecord) ServiceName() string {
return s.serviceName
}
// ServiceInstanceName returns a complete service instance name (e.g. MyDemo\ Service._foobar._tcp.local.),
// which is composed from service instance name, service name and a domain.
func (s *ServiceRecord) ServiceInstanceName() string {
return s.serviceInstanceName
}
// ServiceTypeName returns the complete identifier for a DNS-SD query.
func (s *ServiceRecord) ServiceTypeName() string {
return s.serviceTypeName
}
func (s *ServiceRecord) SetDomain(domain string) {
s.Domain = domain
s.serviceName = fmt.Sprintf("%s.%s.", trimDot(s.Service), trimDot(domain))
if s.Instance != "" {
s.serviceInstanceName = fmt.Sprintf("%s.%s", trimDot(s.Instance), s.ServiceName())
}
// Cache service type name domain
typeNameDomain := "local"
if len(s.Domain) > 0 {
typeNameDomain = trimDot(s.Domain)
}
s.serviceTypeName = fmt.Sprintf("_services._dns-sd._udp.%s.", typeNameDomain)
}
// NewServiceRecord constructs a ServiceRecord.
func NewServiceRecord(instance, service string, domain string) *ServiceRecord {
service, subtypes := parseSubtypes(service)
s := &ServiceRecord{
Instance: instance,
Service: service,
Domain: domain,
serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)),
}
for _, subtype := range subtypes {
s.Subtypes = append(s.Subtypes, fmt.Sprintf("%s._sub.%s", trimDot(subtype), s.serviceName))
}
s.SetDomain(domain)
return s
}
// lookupParams contains configurable properties to create a service discovery request
type lookupParams struct {
ServiceRecord
Entries chan<- *ServiceEntry // Entries Channel
isBrowsing bool
stopProbing chan struct{}
once sync.Once
}
// newLookupParams constructs a lookupParams.
func newLookupParams(instance, service, domain string, isBrowsing bool, entries chan<- *ServiceEntry) *lookupParams {
p := &lookupParams{
ServiceRecord: *NewServiceRecord(instance, service, domain),
Entries: entries,
isBrowsing: isBrowsing,
}
if !isBrowsing {
p.stopProbing = make(chan struct{})
}
return p
}
// Notify subscriber that no more entries will arrive. Mostly caused
// by an expired context.
func (l *lookupParams) done() {
close(l.Entries)
}
func (l *lookupParams) disableProbing() {
l.once.Do(func() { close(l.stopProbing) })
}
// ServiceEntry represents a browse/lookup result for client API.
// It is also used to configure service registration (server API), which is
// used to answer multicast queries.
type ServiceEntry struct {
ServiceRecord
HostName string `json:"hostname"` // Host machine DNS name
Port int `json:"port"` // Service Port
Text []string `json:"text"` // Service info served as a TXT record
TTL uint32 `json:"ttl"` // TTL of the service record
AddrIPv4 []net.IP `json:"-"` // Host machine IPv4 address
AddrIPv6 []net.IP `json:"-"` // Host machine IPv6 address
}
// NewServiceEntry constructs a ServiceEntry.
func NewServiceEntry(instance, service string, domain string) *ServiceEntry {
return &ServiceEntry{
ServiceRecord: *NewServiceRecord(instance, service, domain),
}
}

13
zeroconf/utils.go Normal file
View file

@ -0,0 +1,13 @@
package zeroconf
import "strings"
func parseSubtypes(service string) (string, []string) {
subtypes := strings.Split(service, ",")
return subtypes[0], subtypes[1:]
}
// trimDot is used to trim the dots from the start or end of a string
func trimDot(s string) string {
return strings.Trim(s, ".")
}