internal/discovery registers three service types on the listen port: _vellum._tcp for native clients, and _jellyfin._tcp / _subsonic._tcp because those ecosystems already have clients that browse the LAN. That last part is the point: Infuse, Jellyfin Media Player and Symfonium can find Vellum with no client-side work, because they are already looking for those names. TXT records carry api, ver, proto, types, tls and (when set) pub, so a client that resolves one can tell what it found before connecting. Definitions is a pure function of config and Start only registers what it returns, so what is advertised is testable without a socket. Two behaviours worth knowing: - VELLUM_DISCOVERY=off stops advertising. It accepts off/no/false/0; strconv.ParseBool would have silently ignored "off". - A loopback-only listen address advertises nothing. That is the normal shape behind a reverse proxy, and advertising it would put a port in the LAN browse list that refuses every client that dials it. Verified red-green: defeating the guard fails TestDefinitionsSkipsLoopbackListen. zeroconf is the only new dependency. It logs to the stdlib logger, so a host with no multicast-capable interface prints to stderr outside slog. Not verified: real mDNS on a real LAN. There is no multicast here, so the wire behaviour of zeroconf is untested - only the definitions it is handed.
197 lines
7 KiB
Go
197 lines
7 KiB
Go
// Package discovery advertises Vellum on the local network over mDNS (DNS-SD)
|
|
// so a client finds the server instead of the operator typing its URL.
|
|
//
|
|
// Three service types are registered, all on the HTTP listen port:
|
|
//
|
|
// _vellum._tcp Vellum's own native clients
|
|
// _jellyfin._tcp so Jellyfin apps (Infuse, Jellyfin Media Player) browse to it
|
|
// _subsonic._tcp so Navidrome-class music clients (Symfonium, Ultrasonic) browse to it
|
|
//
|
|
// The last two are the point of the package. Neither costs anything to answer —
|
|
// internal/jellyfin and internal/subsonic already mount at the root, which is
|
|
// where those clients look — but a client that only offers a "browse my network"
|
|
// button will never find a server that stays quiet. All three carry the same TXT
|
|
// records, so a client that resolves one of them can tell what it found:
|
|
//
|
|
// api=v1 the shape of Vellum's own API
|
|
// ver=<version> the build, same string the web UI reports
|
|
// proto=vellum,jellyfin,subsonic every protocol this server answers
|
|
// types=<kinds> the media kinds registered on this instance
|
|
// tls=true|false whether the published origin is https
|
|
// pub=<url> the public origin, present only when VELLUM_PUBLIC_URL is set
|
|
//
|
|
// Definitions and Start are split so the part worth reasoning about — what is
|
|
// advertised and with what TXT — is a pure function of config and needs no
|
|
// network to test. Start only registers what Definitions returns.
|
|
//
|
|
// Advertisement is best-effort by nature. A host with no multicast-capable
|
|
// interface, or a container network that drops 224.0.0.251, still serves HTTP
|
|
// perfectly well, so a failed registration is logged and the server carries on.
|
|
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/grandcat/zeroconf"
|
|
|
|
"github.com/mithral/vellum/internal/core"
|
|
)
|
|
|
|
// Service types advertised on the LAN. The order is the order they are
|
|
// registered in, which is also the order they are logged in.
|
|
var serviceTypes = []string{
|
|
"_vellum._tcp",
|
|
"_jellyfin._tcp",
|
|
"_subsonic._tcp",
|
|
}
|
|
|
|
const (
|
|
// instanceName is what every client shows in its server list.
|
|
instanceName = "Vellum"
|
|
// domain is the mDNS domain; DNS-SD services live in .local.
|
|
domain = "local."
|
|
// apiVersion is the version of Vellum's own API surface.
|
|
apiVersion = "v1"
|
|
// protocols is the full set of protocols this one process answers. It is the
|
|
// same on all three records because the client resolves one type and then
|
|
// wants to know what else the server can do.
|
|
protocols = "vellum,jellyfin,subsonic"
|
|
)
|
|
|
|
// Definition is one mDNS service to register: the instance name, the service
|
|
// type, the port, and the TXT records published with it.
|
|
//
|
|
// It is deliberately plain data with no reference to zeroconf, so what Vellum
|
|
// advertises can be asserted on without a socket, a network, or a timer.
|
|
type Definition struct {
|
|
Instance string
|
|
Service string
|
|
Port int
|
|
TXT map[string]string
|
|
}
|
|
|
|
// Definitions builds the services to advertise from the running config.
|
|
//
|
|
// It returns nil when discovery is disabled, which is the whole of the
|
|
// VELLUM_DISCOVERY=off contract: nothing is registered and nothing is logged as
|
|
// advertised. It also returns nil for a loopback-only listen address, because
|
|
// nothing on the LAN can reach one. An unparseable listen address is an error
|
|
// rather than a silent skip — advertising port 0 would be a service no client
|
|
// can reach.
|
|
func Definitions(cfg core.Config, version string, kinds []string) ([]Definition, error) {
|
|
if !cfg.Discovery {
|
|
return nil, nil
|
|
}
|
|
host, portStr, err := net.SplitHostPort(cfg.Listen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discovery: listen address %q: %w", cfg.Listen, err)
|
|
}
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil || port <= 0 || port > 65535 {
|
|
return nil, fmt.Errorf("discovery: listen address %q has no usable port", cfg.Listen)
|
|
}
|
|
// A loopback bind is the normal shape behind a reverse proxy
|
|
// (VELLUM_LISTEN=127.0.0.1:7788). Advertising it would put a port in the
|
|
// LAN browse list that refuses every client that dials it, which is worse
|
|
// than staying quiet: the published origin is what clients should use, and
|
|
// that is not what mDNS is for.
|
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
return nil, nil
|
|
}
|
|
|
|
txt := map[string]string{
|
|
"api": apiVersion,
|
|
"ver": version,
|
|
"proto": protocols,
|
|
"types": strings.Join(kinds, ","),
|
|
// The published origin, not the cookie setting: a client reading this
|
|
// wants to know whether to dial back with https.
|
|
"tls": strconv.FormatBool(strings.HasPrefix(cfg.PublicURL, "https://")),
|
|
}
|
|
if cfg.PublicURL != "" {
|
|
txt["pub"] = cfg.PublicURL
|
|
}
|
|
|
|
out := make([]Definition, 0, len(serviceTypes))
|
|
for _, st := range serviceTypes {
|
|
out = append(out, Definition{
|
|
Instance: instanceName,
|
|
Service: st,
|
|
Port: port,
|
|
TXT: maps.Clone(txt),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Start registers the services Definitions builds and holds them until ctx is
|
|
// cancelled, at which point each is unregistered with a goodbye packet so peers
|
|
// drop the record immediately rather than waiting out the TTL.
|
|
//
|
|
// It blocks until then. A registration that fails is logged and skipped: mDNS
|
|
// is a convenience, and a host that cannot multicast still serves HTTP.
|
|
func Start(ctx context.Context, cfg core.Config, version string, kinds []string, log *slog.Logger) {
|
|
if !cfg.Discovery {
|
|
log.Info("mdns discovery off; not advertising on the network")
|
|
return
|
|
}
|
|
defs, err := Definitions(cfg, version, kinds)
|
|
if err != nil {
|
|
log.Warn("mdns discovery unavailable", "err", err)
|
|
return
|
|
}
|
|
if len(defs) == 0 {
|
|
// Discovery is on, so the only way to reach here with nothing to
|
|
// register is a loopback-only listen address. Say so, rather than
|
|
// staying quiet and leaving the operator to wonder.
|
|
log.Info("mdns not advertised", "listen", cfg.Listen,
|
|
"reason", "loopback-only listen address is unreachable from the LAN")
|
|
return
|
|
}
|
|
|
|
servers := make([]*zeroconf.Server, 0, len(defs))
|
|
var advertised []string
|
|
for _, d := range defs {
|
|
srv, err := zeroconf.Register(d.Instance, d.Service, domain, d.Port, txtRecords(d.TXT), nil)
|
|
if err != nil {
|
|
log.Warn("mdns registration failed", "service", d.Service, "err", err)
|
|
continue
|
|
}
|
|
servers = append(servers, srv)
|
|
advertised = append(advertised, d.Service)
|
|
}
|
|
if len(advertised) == 0 {
|
|
log.Warn("mdns discovery advertised nothing: no multicast-capable interface")
|
|
return
|
|
}
|
|
log.Info("mdns advertised", "services", advertised, "port", defs[0].Port, "instance", instanceName)
|
|
|
|
<-ctx.Done()
|
|
for _, srv := range servers {
|
|
srv.Shutdown()
|
|
}
|
|
log.Info("mdns unregistered", "services", advertised)
|
|
}
|
|
|
|
// txtRecords renders a TXT map as the key=value list zeroconf takes. Sorted so
|
|
// the record on the wire is deterministic and comparable between runs.
|
|
func txtRecords(txt map[string]string) []string {
|
|
keys := make([]string, 0, len(txt))
|
|
for k := range txt {
|
|
keys = append(keys, k)
|
|
}
|
|
slices.Sort(keys)
|
|
|
|
out := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, k+"="+txt[k])
|
|
}
|
|
return out
|
|
}
|