Add backend/internal/discovery/discovery.go
This commit is contained in:
parent
c5aa40f5a0
commit
06c286c82e
1 changed files with 129 additions and 0 deletions
129
backend/internal/discovery/discovery.go
Normal file
129
backend/internal/discovery/discovery.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Package discovery finds Apollo/Artemis hosts on the network via mDNS.
|
||||
// Apollo advertises itself as _nvstream._tcp.local — the same service
|
||||
// type that Moonlight scans for.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grandcat/zeroconf"
|
||||
)
|
||||
|
||||
const nvstreamService = "_nvstream._tcp"
|
||||
|
||||
// Host represents a discovered Apollo/Artemis streaming host.
|
||||
type Host struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Online bool `json:"online"`
|
||||
SeenAt time.Time `json:"seen_at"`
|
||||
}
|
||||
|
||||
// Discoverer watches the local (Tailscale) network for Apollo hosts.
|
||||
type Discoverer struct {
|
||||
mu sync.RWMutex
|
||||
hosts map[string]*Host // keyed by name
|
||||
}
|
||||
|
||||
// New creates a new Discoverer with an empty host list.
|
||||
func New() *Discoverer {
|
||||
return &Discoverer{hosts: make(map[string]*Host)}
|
||||
}
|
||||
|
||||
// Run starts mDNS browsing and blocks until ctx is cancelled.
|
||||
// Discovered hosts are stored and retrievable via Hosts().
|
||||
func (d *Discoverer) Run(ctx context.Context) {
|
||||
for {
|
||||
if err := d.browse(ctx); err != nil && ctx.Err() == nil {
|
||||
log.Printf("discovery: browse error: %v — retrying in 10s", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Discoverer) browse(ctx context.Context) error {
|
||||
resolver, err := zeroconf.NewResolver(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries := make(chan *zeroconf.ServiceEntry)
|
||||
browseCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
for entry := range entries {
|
||||
ip := ""
|
||||
if len(entry.AddrIPv4) > 0 {
|
||||
ip = entry.AddrIPv4[0].String()
|
||||
} else if len(entry.AddrIPv6) > 0 {
|
||||
ip = entry.AddrIPv6[0].String()
|
||||
}
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
h := &Host{
|
||||
Name: entry.ServiceInstanceName(),
|
||||
IP: ip,
|
||||
Port: entry.Port,
|
||||
Online: true,
|
||||
SeenAt: time.Now(),
|
||||
}
|
||||
d.upsert(h)
|
||||
log.Printf("discovery: found host %s at %s:%d", h.Name, h.IP, h.Port)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := resolver.Browse(browseCtx, nvstreamService, "local.", entries); err != nil {
|
||||
return err
|
||||
}
|
||||
<-browseCtx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Discoverer) upsert(h *Host) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.hosts[h.Name] = h
|
||||
}
|
||||
|
||||
// Hosts returns a snapshot of all known hosts.
|
||||
func (d *Discoverer) Hosts() []*Host {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
// Mark hosts as offline if not seen in 30s
|
||||
cutoff := time.Now().Add(-30 * time.Second)
|
||||
out := make([]*Host, 0, len(d.hosts))
|
||||
for _, h := range d.hosts {
|
||||
copy := *h
|
||||
if h.SeenAt.Before(cutoff) {
|
||||
copy.Online = false
|
||||
}
|
||||
out = append(out, ©)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AddManual adds a host by IP/port without mDNS (for hosts behind NAT or
|
||||
// on subnets that don't support multicast).
|
||||
func (d *Discoverer) AddManual(name, ip string, port int) {
|
||||
d.upsert(&Host{
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
Online: true,
|
||||
SeenAt: time.Now(),
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue