// 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 }