// 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= the build, same string the web UI reports // proto=vellum,jellyfin,subsonic every protocol this server answers // types= the media kinds registered on this instance // tls=true|false whether the published origin is https // pub= 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 }