278 lines
5.8 KiB
Go
278 lines
5.8 KiB
Go
// ABOUTME: mDNS service discovery for Sendspin Protocol
|
|
// ABOUTME: Handles both advertisement (server-initiated) and browsing (client-initiated)
|
|
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/mdns"
|
|
)
|
|
|
|
// silentLogger discards hashicorp/mdns internal logs (e.g. "[INFO] mdns: Closing client")
|
|
var silentLogger = log.New(io.Discard, "", 0)
|
|
|
|
type Config struct {
|
|
ServiceName string
|
|
Port int
|
|
ServerMode bool // If true, advertise as _sendspin-server._tcp, otherwise _sendspin._tcp
|
|
}
|
|
|
|
type Manager struct {
|
|
config Config
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
servers chan *ServerInfo
|
|
clients chan *ClientInfo
|
|
}
|
|
|
|
type ServerInfo struct {
|
|
Name string
|
|
Host string
|
|
Port int
|
|
}
|
|
|
|
// ClientInfo describes a discovered client (player) advertised via
|
|
// _sendspin._tcp.local.
|
|
type ClientInfo struct {
|
|
Instance string // fully-qualified mDNS instance name (stable dedupe key)
|
|
Name string // friendly name from TXT "name=" or falls back to Instance
|
|
Host string // IPv4 address as a string
|
|
Port int
|
|
Path string // WebSocket path from TXT "path=" (default "/sendspin")
|
|
}
|
|
|
|
// clientInfoFromEntry converts an mdns.ServiceEntry into a ClientInfo.
|
|
// Returns nil when the entry lacks a usable IPv4 address or port, or when
|
|
// the entry does not belong to the _sendspin._tcp service (hashicorp/mdns
|
|
// is promiscuous and forwards any multicast response received during the
|
|
// query window, not just matches to the queried service).
|
|
func clientInfoFromEntry(entry *mdns.ServiceEntry) *ClientInfo {
|
|
if entry == nil || entry.AddrV4 == nil || entry.Port == 0 {
|
|
return nil
|
|
}
|
|
if !strings.Contains(entry.Name, "._sendspin._tcp.") {
|
|
return nil
|
|
}
|
|
txt := parseTXT(entry.InfoFields)
|
|
|
|
path := txt["path"]
|
|
if path == "" {
|
|
path = "/sendspin"
|
|
}
|
|
name := txt["name"]
|
|
if name == "" {
|
|
name = entry.Name
|
|
}
|
|
|
|
return &ClientInfo{
|
|
Instance: entry.Name,
|
|
Name: name,
|
|
Host: entry.AddrV4.String(),
|
|
Port: entry.Port,
|
|
Path: path,
|
|
}
|
|
}
|
|
|
|
func NewManager(config Config) *Manager {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
return &Manager{
|
|
config: config,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
servers: make(chan *ServerInfo, 10),
|
|
clients: make(chan *ClientInfo, 10),
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Advertise() error {
|
|
ips, err := getLocalIPs()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get local IPs: %w", err)
|
|
}
|
|
|
|
serviceType := "_sendspin._tcp"
|
|
if m.config.ServerMode {
|
|
serviceType = "_sendspin-server._tcp"
|
|
}
|
|
|
|
service, err := mdns.NewMDNSService(
|
|
m.config.ServiceName,
|
|
serviceType,
|
|
"",
|
|
"",
|
|
m.config.Port,
|
|
ips,
|
|
[]string{"path=/sendspin"},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create service: %w", err)
|
|
}
|
|
|
|
server, err := mdns.NewServer(&mdns.Config{Zone: service, Logger: silentLogger})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create mdns server: %w", err)
|
|
}
|
|
|
|
log.Printf("Advertising mDNS service: %s on port %d (type: %s)", m.config.ServiceName, m.config.Port, serviceType)
|
|
|
|
go func() {
|
|
<-m.ctx.Done()
|
|
server.Shutdown()
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Browse() error {
|
|
go m.browseLoop()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) browseLoop() {
|
|
for {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
entries := make(chan *mdns.ServiceEntry, 10)
|
|
|
|
go func() {
|
|
for entry := range entries {
|
|
// hashicorp/mdns is promiscuous: any multicast response arriving
|
|
// on the socket during the query window is forwarded, regardless
|
|
// of whether it matches Service. Filter by instance-name suffix
|
|
// so we don't dial Google Cast, ADB, etc.
|
|
if !strings.Contains(entry.Name, "._sendspin-server._tcp.") {
|
|
continue
|
|
}
|
|
if entry.AddrV4 == nil || entry.Port == 0 {
|
|
continue
|
|
}
|
|
|
|
server := &ServerInfo{
|
|
Name: entry.Name,
|
|
Host: entry.AddrV4.String(),
|
|
Port: entry.Port,
|
|
}
|
|
|
|
log.Printf("Discovered server: %s at %s:%d", server.Name, server.Host, server.Port)
|
|
|
|
select {
|
|
case m.servers <- server:
|
|
case <-m.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
params := &mdns.QueryParam{
|
|
Service: "_sendspin-server._tcp",
|
|
Domain: "local",
|
|
Timeout: 3 * time.Second,
|
|
Entries: entries,
|
|
Logger: silentLogger,
|
|
}
|
|
|
|
mdns.Query(params)
|
|
close(entries)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Servers() <-chan *ServerInfo {
|
|
return m.servers
|
|
}
|
|
|
|
func (m *Manager) Stop() {
|
|
m.cancel()
|
|
}
|
|
|
|
// BrowseClients searches for Sendspin clients advertising _sendspin._tcp.
|
|
func (m *Manager) BrowseClients() error {
|
|
go m.browseClientsLoop()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) Clients() <-chan *ClientInfo {
|
|
return m.clients
|
|
}
|
|
|
|
func (m *Manager) browseClientsLoop() {
|
|
for {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
entries := make(chan *mdns.ServiceEntry, 10)
|
|
|
|
go func() {
|
|
for entry := range entries {
|
|
info := clientInfoFromEntry(entry)
|
|
if info == nil {
|
|
continue
|
|
}
|
|
log.Printf("Discovered client: %s at %s:%d%s",
|
|
info.Name, info.Host, info.Port, info.Path)
|
|
select {
|
|
case m.clients <- info:
|
|
case <-m.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
params := &mdns.QueryParam{
|
|
Service: "_sendspin._tcp",
|
|
Domain: "local",
|
|
Timeout: 3 * time.Second,
|
|
Entries: entries,
|
|
Logger: silentLogger,
|
|
}
|
|
|
|
if err := mdns.Query(params); err != nil {
|
|
log.Printf("mdns query error: %v", err)
|
|
}
|
|
close(entries)
|
|
}
|
|
}
|
|
|
|
func getLocalIPs() ([]net.IP, error) {
|
|
var ips []net.IP
|
|
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
|
if ipnet.IP.To4() != nil {
|
|
ips = append(ips, ipnet.IP)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ips, nil
|
|
}
|