diff --git a/README.md b/README.md index 34562c0..76becaa 100644 --- a/README.md +++ b/README.md @@ -70,16 +70,40 @@ The default directory for configuration files is `~/.config/go-librespot`. On ma The full configuration schema is available [here](/config_schema.json), only the main options are detailed below. -### Zeroconf mode +### Zeroconf Mode and mDNS Backend Selection -This is the default mode. It uses mDNS auto discovery to allow Spotify clients inside the same network to connect to -go-librespot. This is also known as Spotify Connect. +Zeroconf mode enables mDNS auto discovery, allowing Spotify clients inside the same network to connect to go-librespot. This is also known as Spotify Connect. -An example configuration (not required) looks like this: +**Backend selection:** +go-librespot supports two different backends for mDNS service registration: + +- **builtin**: (default) Uses the built-in mDNS responder provided by go-librespot itself. +- **avahi**: Uses the system's avahi-daemon (via D-Bus) for mDNS service registration. + +You can configure which backend to use via the `zeroconf_backend` setting in your configuration file: + +```yaml +zeroconf_backend: avahi # Options: "builtin" (default), "avahi" +``` + +Or via the command line: + +```shell +go-librespot -c zeroconf_backend=avahi +``` + +#### Which backend should I use? + +- Use **avahi** if you want to integrate with an existing Avahi daemon, e.g. on embedded systems, to avoid port conflicts, or to centralize mDNS advertisements with system service management (e.g., using `systemd`). + - Compatible with Avahi 0.6.x and later (tested with 0.7 and 0.8). +- Use **builtin** if you do **not** have Avahi running and want go-librespot to manage its own mDNS advertisements (no extra dependencies required). + +#### Example minimal Zeroconf configuration ```yaml -zeroconf_enabled: false # Whether to keep the device discoverable at all times, even if authenticated via other means -zeroconf_port: 0 # The port to use for Zeroconf, 0 for random +zeroconf_enabled: true # Whether to keep the device discoverable at all times, even if authenticated via other means +zeroconf_port: 0 # The port to use for Zeroconf, 0 for random +zeroconf_backend: avahi credentials: type: zeroconf zeroconf: diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 214accf..b70adb5 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -253,7 +253,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co } // start zeroconf server and dispatch - z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise) + z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise, app.cfg.ZeroconfBackend == "avahi") if err != nil { return fmt.Errorf("failed initializing zeroconf: %w", err) } @@ -410,6 +410,7 @@ type Config struct { ExternalVolume bool `koanf:"external_volume"` ZeroconfEnabled bool `koanf:"zeroconf_enabled"` ZeroconfPort int `koanf:"zeroconf_port"` + ZeroconfBackend string `koanf:"zeroconf_backend"` DisableAutoplay bool `koanf:"disable_autoplay"` ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"` MprisEnabled bool `koanf:"mpris_enabled"` @@ -492,6 +493,8 @@ func loadConfig(cfg *Config) error { "credentials.type": "zeroconf", + "zeroconf_backend": "builtin", + "server.address": "localhost", "server.image_size": "default", }, "."), nil) diff --git a/config_schema.json b/config_schema.json index 1be85af..a015943 100644 --- a/config_schema.json +++ b/config_schema.json @@ -170,6 +170,15 @@ "description": "The port to use for the Zeroconf service (empty for random)", "default": 0 }, + "zeroconf_backend": { + "type": "string", + "description": "The mDNS backend to use for Zeroconf service registration. 'builtin' uses the built-in mDNS responder, 'avahi' uses an existing avahi-daemon via D-Bus", + "enum": [ + "builtin", + "avahi" + ], + "default": "builtin" + }, "zeroconf_interfaces_to_advertise": { "type": "array", "description": "List of network interfaces that will be advertised through zeroconf (empty to advertise all present interfaces)", diff --git a/zeroconf/backend.go b/zeroconf/backend.go new file mode 100644 index 0000000..cc8e13a --- /dev/null +++ b/zeroconf/backend.go @@ -0,0 +1,16 @@ +package zeroconf + +// ServiceRegistrar handles mDNS service registration. +// Implementations can use different backends like built-in mDNS or avahi-daemon. +type ServiceRegistrar interface { + // Register publishes the service via mDNS. + // name: service instance name (e.g., "go-librespot") + // serviceType: service type (e.g., "_spotify-connect._tcp") + // domain: domain to register in (e.g., "local.") + // port: TCP port the service is listening on + // txt: TXT record key=value pairs + Register(name, serviceType, domain string, port int, txt []string) error + + // Shutdown stops advertising the service and releases resources. + Shutdown() +} diff --git a/zeroconf/backend_avahi.go b/zeroconf/backend_avahi.go new file mode 100644 index 0000000..d3cfb0b --- /dev/null +++ b/zeroconf/backend_avahi.go @@ -0,0 +1,142 @@ +package zeroconf + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +const ( + avahiService = "org.freedesktop.Avahi" + avahiServerPath = "/" + avahiServerIface = "org.freedesktop.Avahi.Server" + avahiEntryGroupIface = "org.freedesktop.Avahi.EntryGroup" + + // Avahi constants + avahiIfUnspec = int32(-1) // AVAHI_IF_UNSPEC - use all interfaces + avahiProtoUnspec = int32(-1) // AVAHI_PROTO_UNSPEC - use both IPv4 and IPv6 +) + +// AvahiRegistrar implements ServiceRegistrar using avahi-daemon via D-Bus. +// This allows go-librespot to share the mDNS responder with other services +// on the system instead of running its own. +// +// Compatibility: Requires avahi-daemon 0.6.x or later (uses stable D-Bus API). +// Tested with avahi 0.7 and 0.8. +type AvahiRegistrar struct { + conn *dbus.Conn + entryGroup dbus.BusObject + version string +} + +// NewAvahiRegistrar creates a new avahi-daemon service registrar. +// It connects to the system D-Bus and prepares to register services via avahi. +func NewAvahiRegistrar() (*AvahiRegistrar, error) { + conn, err := dbus.SystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + // Verify avahi-daemon is available by calling GetHostName (available in all versions) + server := conn.Object(avahiService, avahiServerPath) + var hostname string + err = server.Call(avahiServerIface+".GetHostName", 0).Store(&hostname) + if err != nil { + conn.Close() + return nil, fmt.Errorf("failed to connect to avahi-daemon (is it running?): %w", err) + } + + // Try to get version for logging (optional, may fail on older versions) + version := getAvahiVersion(server) + + return &AvahiRegistrar{conn: conn, version: version}, nil +} + +// getAvahiVersion attempts to retrieve the avahi-daemon version. +// Returns "unknown" if version cannot be determined. +func getAvahiVersion(server dbus.BusObject) string { + // Try GetVersionString first (available in avahi 0.8+) + var versionStr string + if err := server.Call(avahiServerIface+".GetVersionString", 0).Store(&versionStr); err == nil { + return versionStr + } + + // Try GetAPIVersion (returns a single uint32) + var apiVersion uint32 + if err := server.Call(avahiServerIface+".GetAPIVersion", 0).Store(&apiVersion); err == nil { + return fmt.Sprintf("API v%d", apiVersion) + } + + return "unknown" +} + +// Version returns the avahi-daemon version string. +func (a *AvahiRegistrar) Version() string { + return a.version +} + +// Register publishes the service via avahi-daemon. +func (a *AvahiRegistrar) Register(name, serviceType, domain string, port int, txt []string) error { + server := a.conn.Object(avahiService, avahiServerPath) + + // Create a new entry group for our service + var groupPath dbus.ObjectPath + err := server.Call(avahiServerIface+".EntryGroupNew", 0).Store(&groupPath) + if err != nil { + return fmt.Errorf("failed to create entry group: %w", err) + } + + a.entryGroup = a.conn.Object(avahiService, groupPath) + + // Convert TXT records to [][]byte format required by avahi + txtBytes := make([][]byte, len(txt)) + for i, t := range txt { + txtBytes[i] = []byte(t) + } + + // AddService signature: iiussssqaay + // interface (i): network interface index, -1 for all + // protocol (i): IP protocol, -1 for both IPv4/IPv6 + // flags (u): publish flags, 0 for default + // name (s): service instance name + // type (s): service type (e.g., "_spotify-connect._tcp") + // domain (s): domain to publish in (e.g., "local") + // host (s): hostname, empty for default + // port (q): port number (uint16) + // txt (aay): TXT record data as array of byte arrays + err = a.entryGroup.Call(avahiEntryGroupIface+".AddService", 0, + avahiIfUnspec, // interface + avahiProtoUnspec, // protocol + uint32(0), // flags + name, // service name + serviceType, // service type + domain, // domain + "", // host (empty = use default hostname) + uint16(port), // port + txtBytes, // TXT records + ).Err + if err != nil { + return fmt.Errorf("failed to add service: %w", err) + } + + // Commit the entry group to publish the service + err = a.entryGroup.Call(avahiEntryGroupIface+".Commit", 0).Err + if err != nil { + return fmt.Errorf("failed to commit entry group: %w", err) + } + + return nil +} + +// Shutdown removes the service from avahi and releases resources. +func (a *AvahiRegistrar) Shutdown() { + if a.entryGroup != nil { + // Free the entry group (this also unpublishes the service) + _ = a.entryGroup.Call(avahiEntryGroupIface+".Free", 0).Err + a.entryGroup = nil + } + if a.conn != nil { + _ = a.conn.Close() + a.conn = nil + } +} diff --git a/zeroconf/backend_builtin.go b/zeroconf/backend_builtin.go new file mode 100644 index 0000000..b773a42 --- /dev/null +++ b/zeroconf/backend_builtin.go @@ -0,0 +1,34 @@ +package zeroconf + +import ( + "net" + + "github.com/grandcat/zeroconf" +) + +// BuiltinRegistrar implements ServiceRegistrar using the grandcat/zeroconf library, +// which provides a pure-Go mDNS responder. +type BuiltinRegistrar struct { + server *zeroconf.Server + ifaces []net.Interface +} + +// NewBuiltinRegistrar creates a new built-in mDNS service registrar. +// If ifaces is empty, the service will be advertised on all interfaces. +func NewBuiltinRegistrar(ifaces []net.Interface) *BuiltinRegistrar { + return &BuiltinRegistrar{ifaces: ifaces} +} + +// Register publishes the service using the built-in mDNS responder. +func (b *BuiltinRegistrar) Register(name, serviceType, domain string, port int, txt []string) error { + var err error + b.server, err = zeroconf.Register(name, serviceType, domain, port, txt, b.ifaces) + return err +} + +// Shutdown stops the mDNS responder. +func (b *BuiltinRegistrar) Shutdown() { + if b.server != nil { + b.server.Shutdown() + } +} diff --git a/zeroconf/zeroconf.go b/zeroconf/zeroconf.go index 408476e..fe47f1e 100644 --- a/zeroconf/zeroconf.go +++ b/zeroconf/zeroconf.go @@ -16,7 +16,6 @@ import ( librespot "github.com/devgianlu/go-librespot" "github.com/devgianlu/go-librespot/dh" devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices" - "github.com/grandcat/zeroconf" ) type Zeroconf struct { @@ -26,8 +25,8 @@ type Zeroconf struct { deviceId string deviceType devicespb.DeviceType - listener net.Listener - server *zeroconf.Server + listener net.Listener + registrar ServiceRegistrar dh *dh.DiffieHellman @@ -46,7 +45,7 @@ type NewUserRequest struct { result chan bool } -func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, deviceType devicespb.DeviceType, interfacesToAdvertise []string) (_ *Zeroconf, err error) { +func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, deviceType devicespb.DeviceType, interfacesToAdvertise []string, useAvahi bool) (_ *Zeroconf, err error) { z := &Zeroconf{log: log, deviceId: deviceId, deviceName: deviceName, deviceType: deviceType} z.reqsChan = make(chan NewUserRequest) @@ -63,20 +62,38 @@ func NewZeroconf(log librespot.Logger, port int, deviceName, deviceId string, de listenPort := z.listener.Addr().(*net.TCPAddr).Port log.Infof("zeroconf server listening on port %d", listenPort) - var ifaces []net.Interface - for _, ifaceName := range interfacesToAdvertise { - liface, err := net.InterfaceByName(ifaceName) + // Select the mDNS backend based on configuration + if useAvahi { + avahiReg, err := NewAvahiRegistrar() if err != nil { - return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err) + _ = z.listener.Close() + return nil, fmt.Errorf("failed initializing avahi registrar: %w", err) + } + z.registrar = avahiReg + log.Infof("using avahi-daemon %s for mDNS service registration", avahiReg.Version()) + } else { + var ifaces []net.Interface + for _, ifaceName := range interfacesToAdvertise { + liface, err := net.InterfaceByName(ifaceName) + if err != nil { + _ = z.listener.Close() + return nil, fmt.Errorf("failed to get info for network interface %s: %w", ifaceName, err) + } + + ifaces = append(ifaces, *liface) + log.Infof("advertising on network interface %s", ifaceName) } - ifaces = append(ifaces, *liface) - log.Info(fmt.Sprintf("advertising on network interface %s", ifaceName)) + z.registrar = NewBuiltinRegistrar(ifaces) + log.Infof("using built-in mDNS responder") } - z.server, err = zeroconf.Register(deviceName, "_spotify-connect._tcp", "local.", listenPort, []string{"CPath=/", "VERSION=1.0", "Stack=SP"}, ifaces) + // Register the Spotify Connect service + err = z.registrar.Register(deviceName, "_spotify-connect._tcp", "local.", listenPort, []string{"CPath=/", "VERSION=1.0", "Stack=SP"}) if err != nil { - return nil, fmt.Errorf("failed registering zeroconf server: %w", err) + z.registrar.Shutdown() + _ = z.listener.Close() + return nil, fmt.Errorf("failed registering zeroconf service: %w", err) } return z, nil @@ -91,7 +108,7 @@ func (z *Zeroconf) SetCurrentUser(username string) { // Close stops the zeroconf responder and HTTP listener, // but does not close the last opened session. func (z *Zeroconf) Close() { - z.server.Shutdown() + z.registrar.Shutdown() _ = z.listener.Close() } @@ -246,7 +263,7 @@ func (z *Zeroconf) handleAddUser(writer http.ResponseWriter, request *http.Reque type HandleNewRequestFunc func(req NewUserRequest) bool func (z *Zeroconf) Serve(handler HandleNewRequestFunc) error { - defer z.server.Shutdown() + defer z.registrar.Shutdown() mux := http.NewServeMux() mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {