From 795b77b7093dda009105581f2e3f1642139e7ff2 Mon Sep 17 00:00:00 2001 From: mithral Date: Mon, 12 Oct 2026 01:04:33 -0400 Subject: [PATCH] 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. --- README.md | 2 + cmd/vellum/main.go | 10 ++ go.mod | 4 + go.sum | 24 ++++ internal/arch/arch_test.go | 4 + internal/core/config.go | 17 +++ internal/core/config_test.go | 31 +++++ internal/discovery/discovery.go | 197 +++++++++++++++++++++++++++ internal/discovery/discovery_test.go | 166 ++++++++++++++++++++++ 9 files changed, 455 insertions(+) create mode 100644 internal/discovery/discovery.go create mode 100644 internal/discovery/discovery_test.go diff --git a/README.md b/README.md index 7d012c9..c6bc3cb 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ binary runs with no configuration at all. | `VELLUM_ADMIN_PASSWORD` | *(none)* | Password for that first admin (min 8 chars) | | `VELLUM_COOKIE_SECURE` | `false` | Set `true` when serving over TLS | | `VELLUM_COMICVINE_API_KEY` | *(none)* | Enables comic metadata via ComicVine | +| `VELLUM_DISCOVERY` | `on` | Set `off` to stop advertising over mDNS on the LAN | | `VELLUM_IGDB_CLIENT_ID` | *(none)* | Enables game metadata via IGDB | | `VELLUM_IGDB_CLIENT_SECRET` | *(none)* | IGDB client secret (both required) | | `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | @@ -107,6 +108,7 @@ internal/ natsort/ natural ordering for pages and tracks jellyfin/ Jellyfin protocol adapter (+ dto/) subsonic/ Subsonic protocol adapter (+ dto/) + discovery/ mDNS advertisement on the LAN arch/ import-rule tests; holds no code modules/ ebooks/ EPUB parsing and cover extraction diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 782a07b..d81c267 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -18,6 +18,7 @@ import ( "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/cli" "github.com/mithral/vellum/internal/core" + "github.com/mithral/vellum/internal/discovery" "github.com/mithral/vellum/internal/home" "github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/jellyfin" @@ -183,6 +184,15 @@ func run(cfg core.Config, log *slog.Logger) error { defer cancelJobs() go queue.Run(jobCtx) + // mDNS: advertise on the LAN so clients browse to this server instead of + // being handed a URL. Cancelled with the job queue so the records are + // withdrawn before the process exits. + kinds := make([]string, 0) + for _, mt := range library.SupportedTypes() { + kinds = append(kinds, mt.Kind) + } + go discovery.Start(jobCtx, cfg, version, kinds, log) + srv := &http.Server{ Addr: cfg.Listen, Handler: api.Handler(), diff --git a/go.mod b/go.mod index 01348ce..e66565c 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,21 @@ module github.com/mithral/vellum go 1.26.8 require ( + github.com/grandcat/zeroconf v1.0.0 golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 modernc.org/sqlite v1.60.1 ) require ( + github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/miekg/dns v1.1.27 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/sys v0.49.0 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 409f07e..ae1d7bc 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,53 @@ +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= +github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo= golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg= modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= diff --git a/internal/arch/arch_test.go b/internal/arch/arch_test.go index 8496b60..df5f252 100644 --- a/internal/arch/arch_test.go +++ b/internal/arch/arch_test.go @@ -67,6 +67,10 @@ var layers = map[string]int{ "internal/subsonic": 4, "internal/subsonic/dto": 3, "internal/cli": 4, + // discovery is an adapter in the same sense the protocol handlers are: it + // speaks a foreign wire format (mDNS) on behalf of the process and knows + // nothing about the media model. + "internal/discovery": 4, // 5 — the only place everything is wired together. "cmd/vellum": 5, diff --git a/internal/core/config.go b/internal/core/config.go index da8c4fe..f7ce47f 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -54,6 +54,10 @@ type Config struct { // X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used. TrustedProxies []*net.IPNet + // Discovery advertises the server on the LAN over mDNS so clients find it + // without a typed URL. + Discovery bool + SessionTTL time.Duration ReadTimeout time.Duration WriteTimeout time.Duration @@ -90,6 +94,8 @@ func LoadConfig() (Config, error) { WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second), ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second), + + Discovery: !envOff("VELLUM_DISCOVERY"), } proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES") @@ -209,3 +215,14 @@ func envBool(key string, def bool) bool { } return b } + +// envOff reads a switch that is ON unless it is explicitly turned off. Unlike +// envBool it accepts the on/off spellings operators actually type, because +// strconv.ParseBool rejects "off" and would silently leave the feature on. +func envOff(key string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "off", "no", "false", "0": + return true + } + return false +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 6f67066..acd026a 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -142,3 +142,34 @@ func TestValidateCreatesDirs(t *testing.T) { t.Fatalf("Validate: %v", err) } } + +func TestDiscoveryDefaultAndOff(t *testing.T) { + // Advertising is on by default: a server nobody can find on the LAN is the + // common case this feature exists to remove. + tests := []struct { + value string // empty means unset + want bool + }{ + {value: "", want: true}, + {value: "off", want: false}, + {value: "OFF", want: false}, + {value: "false", want: false}, + {value: "0", want: false}, + {value: "no", want: false}, + {value: "on", want: true}, + {value: "garbage", want: true}, // unrecognised values leave it on + } + for _, tc := range tests { + t.Run("VELLUM_DISCOVERY="+tc.value, func(t *testing.T) { + t.Setenv("VELLUM_DISCOVERY", tc.value) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Discovery != tc.want { + t.Errorf("Discovery = %v, want %v", cfg.Discovery, tc.want) + } + }) + } +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..909178b --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,197 @@ +// 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 +} diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..2c723f4 --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,166 @@ +package discovery + +import ( + "slices" + "testing" + + "github.com/mithral/vellum/internal/core" +) + +// baseConfig is a config with discovery on and a port that parses. +func baseConfig() core.Config { + return core.Config{Discovery: true, Listen: ":7788"} +} + +func TestDefinitionsAdvertisesThreeServiceTypes(t *testing.T) { + defs, err := Definitions(baseConfig(), "1.2.3", []string{"music", "ebooks"}) + if err != nil { + t.Fatalf("Definitions: %v", err) + } + + want := []string{"_vellum._tcp", "_jellyfin._tcp", "_subsonic._tcp"} + got := make([]string, 0, len(defs)) + for _, d := range defs { + got = append(got, d.Service) + if d.Instance != "Vellum" { + t.Errorf("%s Instance = %q, want Vellum", d.Service, d.Instance) + } + } + if !slices.Equal(got, want) { + t.Errorf("service types = %v, want %v", got, want) + } +} + +func TestDefinitionsTXTRecords(t *testing.T) { + cfg := baseConfig() + cfg.PublicURL = "https://media.example.com" + + defs, err := Definitions(cfg, "1.2.3", []string{"music", "ebooks"}) + if err != nil { + t.Fatalf("Definitions: %v", err) + } + + want := map[string]string{ + "api": "v1", + "ver": "1.2.3", + "proto": "vellum,jellyfin,subsonic", + "types": "music,ebooks", + "tls": "true", + "pub": "https://media.example.com", + } + // Every record carries the same TXT: a client resolves one service type and + // then needs to know what else this server answers. + for _, d := range defs { + if len(d.TXT) != len(want) { + t.Fatalf("%s TXT = %v, want %d keys", d.Service, d.TXT, len(want)) + } + for k, v := range want { + if d.TXT[k] != v { + t.Errorf("%s TXT[%q] = %q, want %q", d.Service, k, d.TXT[k], v) + } + } + } +} + +func TestDefinitionsOmitsPubWithoutPublicURL(t *testing.T) { + defs, err := Definitions(baseConfig(), "dev", nil) + if err != nil { + t.Fatalf("Definitions: %v", err) + } + + d := defs[0] + if _, ok := d.TXT["pub"]; ok { + t.Errorf("TXT has pub=%q, want it absent when VELLUM_PUBLIC_URL is unset", d.TXT["pub"]) + } + if d.TXT["tls"] != "false" { + t.Errorf("tls = %q, want false with no https origin", d.TXT["tls"]) + } + if d.TXT["types"] != "" { + t.Errorf("types = %q, want empty with no media kinds", d.TXT["types"]) + } +} + +func TestDefinitionsUsesListenPort(t *testing.T) { + cfg := baseConfig() + cfg.Listen = "127.0.0.1:9999" + + defs, err := Definitions(cfg, "dev", nil) + if err != nil { + t.Fatalf("Definitions: %v", err) + } + for _, d := range defs { + if d.Port != 9999 { + t.Errorf("%s Port = %d, want 9999", d.Service, d.Port) + } + } +} + +func TestDefinitionsDisabled(t *testing.T) { + cfg := baseConfig() + cfg.Discovery = false + + defs, err := Definitions(cfg, "dev", []string{"music"}) + if err != nil { + t.Fatalf("Definitions: %v", err) + } + if defs != nil { + t.Errorf("Definitions = %v, want nil when VELLUM_DISCOVERY is off", defs) + } +} + +func TestDefinitionsSkipsLoopbackListen(t *testing.T) { + // Nothing on the LAN can reach a loopback bind, which is the normal shape + // behind a reverse proxy. Advertising it would put a port in the browse + // list that refuses every client that dials it. + for _, listen := range []string{"127.0.0.1:7788", "[::1]:7788"} { + cfg := baseConfig() + cfg.Listen = listen + + defs, err := Definitions(cfg, "dev", []string{"music"}) + if err != nil { + t.Fatalf("Definitions(%s): %v", listen, err) + } + if defs != nil { + t.Errorf("Definitions(%s) = %v, want nil: loopback is unreachable from the LAN", listen, defs) + } + } + + // The other side of the boundary: a wildcard bind is reachable, so it must + // still advertise. + for _, listen := range []string{":7788", "0.0.0.0:7788"} { + cfg := baseConfig() + cfg.Listen = listen + + defs, err := Definitions(cfg, "dev", []string{"music"}) + if err != nil { + t.Fatalf("Definitions(%s): %v", listen, err) + } + if len(defs) == 0 { + t.Errorf("Definitions(%s) advertised nothing; a wildcard bind is reachable", listen) + } + } +} + +func TestDefinitionsRejectsListenWithoutPort(t *testing.T) { + cfg := baseConfig() + cfg.Listen = "7788" // no colon: not a host:port pair + + // Advertising port 0 produces a record no client can connect to, so this is + // an error rather than a silently empty advertisement. + if _, err := Definitions(cfg, "dev", nil); err == nil { + t.Fatal("expected an error for a listen address with no port") + } + + cfg.Listen = ":0" // kernel-assigned port, unknowable at registration time + if _, err := Definitions(cfg, "dev", nil); err == nil { + t.Fatal("expected an error for a kernel-assigned listen port") + } +} + +func TestTXTRecordsAreSorted(t *testing.T) { + got := txtRecords(map[string]string{"ver": "1", "api": "v1", "tls": "false"}) + want := []string{"api=v1", "tls=false", "ver=1"} + if !slices.Equal(got, want) { + t.Errorf("txtRecords = %v, want %v", got, want) + } +}