Vellum/internal/discovery/discovery.go
mithral 795b77b709 Advertise Vellum over mDNS so clients find it without a typed URL
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.
2026-10-12 01:04:33 -04:00

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
}