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.
This commit is contained in:
mithral 2026-10-12 01:04:33 -04:00
parent 2bdfc6d139
commit 795b77b709
9 changed files with 455 additions and 0 deletions

View file

@ -69,6 +69,7 @@ binary runs with no configuration at all.
| `VELLUM_ADMIN_PASSWORD` | *(none)* | Password for that first admin (min 8 chars) | | `VELLUM_ADMIN_PASSWORD` | *(none)* | Password for that first admin (min 8 chars) |
| `VELLUM_COOKIE_SECURE` | `false` | Set `true` when serving over TLS | | `VELLUM_COOKIE_SECURE` | `false` | Set `true` when serving over TLS |
| `VELLUM_COMICVINE_API_KEY` | *(none)* | Enables comic metadata via ComicVine | | `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_ID` | *(none)* | Enables game metadata via IGDB |
| `VELLUM_IGDB_CLIENT_SECRET` | *(none)* | IGDB client secret (both required) | | `VELLUM_IGDB_CLIENT_SECRET` | *(none)* | IGDB client secret (both required) |
| `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | | `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
@ -107,6 +108,7 @@ internal/
natsort/ natural ordering for pages and tracks natsort/ natural ordering for pages and tracks
jellyfin/ Jellyfin protocol adapter (+ dto/) jellyfin/ Jellyfin protocol adapter (+ dto/)
subsonic/ Subsonic protocol adapter (+ dto/) subsonic/ Subsonic protocol adapter (+ dto/)
discovery/ mDNS advertisement on the LAN
arch/ import-rule tests; holds no code arch/ import-rule tests; holds no code
modules/ modules/
ebooks/ EPUB parsing and cover extraction ebooks/ EPUB parsing and cover extraction

View file

@ -18,6 +18,7 @@ import (
"github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/cli" "github.com/mithral/vellum/internal/cli"
"github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/discovery"
"github.com/mithral/vellum/internal/home" "github.com/mithral/vellum/internal/home"
"github.com/mithral/vellum/internal/httpapi" "github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/internal/jellyfin" "github.com/mithral/vellum/internal/jellyfin"
@ -183,6 +184,15 @@ func run(cfg core.Config, log *slog.Logger) error {
defer cancelJobs() defer cancelJobs()
go queue.Run(jobCtx) 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{ srv := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: api.Handler(), Handler: api.Handler(),

4
go.mod
View file

@ -3,17 +3,21 @@ module github.com/mithral/vellum
go 1.26.8 go 1.26.8
require ( require (
github.com/grandcat/zeroconf v1.0.0
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0 golang.org/x/image v0.47.0
modernc.org/sqlite v1.60.1 modernc.org/sqlite v1.60.1
) )
require ( require (
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // 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/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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 golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.77.1 // indirect modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

24
go.sum
View file

@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= 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 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE= 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 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= 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 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= 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 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= 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 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE= modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=

View file

@ -67,6 +67,10 @@ var layers = map[string]int{
"internal/subsonic": 4, "internal/subsonic": 4,
"internal/subsonic/dto": 3, "internal/subsonic/dto": 3,
"internal/cli": 4, "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. // 5 — the only place everything is wired together.
"cmd/vellum": 5, "cmd/vellum": 5,

View file

@ -54,6 +54,10 @@ type Config struct {
// X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used. // X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used.
TrustedProxies []*net.IPNet 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 SessionTTL time.Duration
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout 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 WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second), IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second),
ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second), ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second),
Discovery: !envOff("VELLUM_DISCOVERY"),
} }
proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES") proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES")
@ -209,3 +215,14 @@ func envBool(key string, def bool) bool {
} }
return b 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
}

View file

@ -142,3 +142,34 @@ func TestValidateCreatesDirs(t *testing.T) {
t.Fatalf("Validate: %v", err) 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)
}
})
}
}

View file

@ -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=<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
}

View file

@ -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)
}
}