From 81b915bfca8f8a220d4aca2996a4094bff68b1be Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 16 Aug 2026 19:52:48 +0200 Subject: [PATCH] fix(on-device): stop leaking the speaker's own hostname into BMX/TLS URLs On an on-device install, soundtouch-service defaulted its server URL to os.Hostname() when --server-url wasn't set. Since the service runs on the speaker's own Linux, that returns the speaker's internal variant codename (e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself -- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546). Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network, public-network) so the fallback is chosen deliberately instead of guessed: on-device defaults to localhost, public-network refuses to start rather than guess a public address, and the previous hostname-guessing behavior is kept for private-network/unset installs, now with a startup warning. The on-device init script sets DEPLOYMENT_MODE=on-device automatically and now auto-exports aftertouch.conf into the daemon's environment generally, which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD on-device) without any further code change. Verified end-to-end on real ST20 hardware: service now resolves http://localhost:8000, a re-migrate updates the speaker's own runtime config to match, and TuneIn playback works again. Co-Authored-By: Claude Sonnet 5 --- cmd/soundtouch-service/main.go | 570 ++++++++++-------- cmd/soundtouch-service/main_test.go | 134 ++++ docs/content/docs/guides/MIGRATION-GUIDE.md | 2 +- .../guides/ON-DEVICE-INSTALL-WALKTHROUGH.md | 11 + .../content/docs/guides/SOUNDTOUCH-SERVICE.md | 53 +- scripts/on-device-install/README.md | 18 + scripts/on-device-install/aftertouch | 22 +- scripts/on-device-install/install.sh | 8 +- 8 files changed, 525 insertions(+), 293 deletions(-) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 1cae83f..849593d 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -224,6 +224,258 @@ func logBufferCapacityFromEnv(defaultCap int) int { return v } +// serviceFlags is the full flag/env-var surface for soundtouch-service. +// Extracted to a package-level var (rather than inlined in main()'s +// cli.App literal) so tests can build a real *cli.Context against the +// exact same flags loadConfig reads, instead of hand-duplicating them. +var serviceFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "HTTP port to bind the service to", + Value: "8000", + EnvVars: []string{"PORT"}, + }, + &cli.StringFlag{ + Name: "bind", + Usage: "Network interface to bind to", + EnvVars: []string{"BIND_ADDR"}, + }, + &cli.StringFlag{ + Name: "data-dir", + Usage: "Directory for persistent data", + Value: "data", + EnvVars: []string{"DATA_DIR"}, + }, + &cli.StringFlag{ + Name: "server-url", + Aliases: []string{"s"}, + Usage: "External URL of this service", + EnvVars: []string{"SERVER_URL"}, + }, + &cli.StringFlag{ + Name: "deployment-mode", + Usage: "Where this service runs: on-device, private-network, or public-network " + + "- informs the server-url fallback when --server-url isn't set", + EnvVars: []string{"DEPLOYMENT_MODE"}, + }, + &cli.StringFlag{ + Name: "https-port", + Usage: "HTTPS port to bind the service to", + Value: "8443", + EnvVars: []string{"HTTPS_PORT"}, + }, + &cli.StringFlag{ + Name: "https-server-url", + Aliases: []string{"S"}, + Usage: "External HTTPS URL", + EnvVars: []string{"HTTPS_SERVER_URL"}, + }, + &cli.BoolFlag{ + Name: "redact-logs", + Usage: "Redact sensitive data in proxy logs", + Value: true, + EnvVars: []string{"REDACT_PROXY_LOGS"}, + }, + &cli.BoolFlag{ + Name: "log-bodies", + Usage: "Log full request/response bodies", + EnvVars: []string{"LOG_PROXY_BODY"}, + }, + &cli.BoolFlag{ + Name: "record-interactions", + Usage: "Record HTTP interactions to disk", + Value: true, + EnvVars: []string{"RECORD_INTERACTIONS"}, + }, + &cli.BoolFlag{ + Name: "discovery-enabled", + Usage: "Enable periodic device discovery", + Value: true, + EnvVars: []string{"DISCOVERY_ENABLED"}, + }, + &cli.StringFlag{ + Name: "discovery-interval", + Usage: "Device discovery interval", + Value: "5m", + EnvVars: []string{"DISCOVERY_INTERVAL"}, + }, + &cli.BoolFlag{ + Name: "update-check-enabled", + Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)", + Value: false, + EnvVars: []string{"UPDATE_CHECK_ENABLED"}, + }, + &cli.StringFlag{ + Name: "update-check-interval", + Usage: "Update check interval", + Value: "24h", + EnvVars: []string{"UPDATE_CHECK_INTERVAL"}, + }, + &cli.BoolFlag{ + Name: "dns-discovery", + Usage: "Enable DNS discovery server", + EnvVars: []string{"ENABLE_DNS_DISCOVERY"}, + }, + &cli.StringFlag{ + Name: "dns-upstream", + Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.", + Value: "", + EnvVars: []string{"DNS_UPSTREAM"}, + }, + &cli.StringFlag{ + Name: "dns-bind", + Usage: "Bind address for the DNS discovery server", + Value: ":53", + EnvVars: []string{"DNS_BIND_ADDR"}, + }, + &cli.StringFlag{ + Name: "spotify-client-id", + Usage: "Spotify OAuth client ID", + EnvVars: []string{"SPOTIFY_CLIENT_ID"}, + }, + &cli.StringFlag{ + Name: "spotify-client-secret", + Usage: "Spotify OAuth client secret", + EnvVars: []string{"SPOTIFY_CLIENT_SECRET"}, + }, + &cli.StringFlag{ + Name: "spotify-redirect-uri", + Usage: "Spotify OAuth redirect URI (defaults to /mgmt/spotify/callback)", + EnvVars: []string{"SPOTIFY_REDIRECT_URI"}, + }, + &cli.StringFlag{ + Name: "spotify-token-url", + Usage: "Spotify OAuth token URL (for testing)", + EnvVars: []string{"SPOTIFY_TOKEN_URL"}, + }, + &cli.StringFlag{ + Name: "spotify-api-base", + Usage: "Spotify API base URL (for testing)", + EnvVars: []string{"SPOTIFY_API_BASE"}, + }, + &cli.StringFlag{ + Name: "amazon-client-id", + Usage: "Amazon LWA OAuth client ID", + EnvVars: []string{"AMAZON_CLIENT_ID"}, + }, + &cli.StringFlag{ + Name: "amazon-client-secret", + Usage: "Amazon LWA OAuth client secret", + EnvVars: []string{"AMAZON_CLIENT_SECRET"}, + }, + &cli.StringFlag{ + Name: "amazon-redirect-uri", + Usage: "Amazon LWA OAuth redirect URI (defaults to /mgmt/amazon/callback)", + EnvVars: []string{"AMAZON_REDIRECT_URI"}, + }, + &cli.StringFlag{ + Name: "amazon-token-url", + Usage: "Amazon LWA token URL (for testing)", + EnvVars: []string{"AMAZON_TOKEN_URL"}, + }, + &cli.StringFlag{ + Name: "amazon-profile-url", + Usage: "Amazon LWA profile URL (for testing)", + EnvVars: []string{"AMAZON_PROFILE_URL"}, + }, + &cli.StringFlag{ + Name: "tunein-opml-url", + Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)", + EnvVars: []string{"TUNEIN_OPML_URL"}, + }, + &cli.StringFlag{ + Name: "tunein-api-url", + Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)", + EnvVars: []string{"TUNEIN_API_URL"}, + }, + &cli.StringFlag{ + Name: "tts-provider", + Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect", + EnvVars: []string{"TTS_PROVIDER"}, + }, + &cli.StringFlag{ + Name: "tts-google-api-key", + Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)", + EnvVars: []string{"TTS_GOOGLE_API_KEY"}, + }, + &cli.StringFlag{ + Name: "tts-google-endpoint", + Usage: "Google Cloud TTS synthesize endpoint override (for testing)", + EnvVars: []string{"TTS_GOOGLE_ENDPOINT"}, + }, + &cli.StringFlag{ + Name: "tts-language", + Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud", + EnvVars: []string{"TTS_LANGUAGE"}, + }, + &cli.StringFlag{ + Name: "tts-voice", + Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider", + EnvVars: []string{"TTS_VOICE"}, + }, + &cli.StringFlag{ + Name: "tts-app-key", + Usage: "Bose /speaker app_key used to play TTS notifications on speakers", + EnvVars: []string{"TTS_APP_KEY"}, + }, + &cli.IntFlag{ + Name: "tts-volume", + Usage: "Default TTS playback volume (0-100, 0 = keep current volume)", + Value: 0, + EnvVars: []string{"TTS_VOLUME"}, + }, + &cli.StringFlag{ + Name: "mgmt-username", + Usage: "Management API username for HTTP Basic Auth", + Value: "admin", + EnvVars: []string{"MGMT_USERNAME"}, + }, + &cli.StringFlag{ + Name: "mgmt-password", + Usage: "Management API password for HTTP Basic Auth", + Value: "change_me!", + EnvVars: []string{"MGMT_PASSWORD"}, + }, + &cli.StringFlag{ + Name: "base-url", + Usage: "External base URL for OAuth callbacks behind reverse proxy", + EnvVars: []string{"BASE_URL"}, + }, + &cli.StringSliceFlag{ + Name: "internal-paths", + Usage: "Paths for internal requests (comma-separated or multiple flags)", + EnvVars: []string{"INTERNAL_PATHS"}, + }, + &cli.StringSliceFlag{ + Name: "tls-extra-host", + Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)", + EnvVars: []string{"TLS_EXTRA_HOST"}, + }, + &cli.BoolFlag{ + Name: "migration-enabled", + Usage: "Enable device directory migration from serial to MAC-based structure", + Value: true, + EnvVars: []string{"MIGRATION_ENABLED"}, + }, + &cli.BoolFlag{ + Name: "migration-dry-run", + Usage: "Log what would be migrated without actually doing it", + EnvVars: []string{"MIGRATION_DRY_RUN"}, + }, + &cli.StringFlag{ + Name: "stockholm-dir", + Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)", + EnvVars: []string{"STOCKHOLM_DIR"}, + }, + &cli.StringFlag{ + Name: "stockholm-base-path", + Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.", + Value: "/stockholm", + EnvVars: []string{"STOCKHOLM_BASE_PATH"}, + }, +} + func main() { updateBuildInfo() @@ -250,249 +502,13 @@ func main() { Name: "Tobias Gesellchen, and the Bose-SoundTouch Contributors", }, }, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "port", - Aliases: []string{"p"}, - Usage: "HTTP port to bind the service to", - Value: "8000", - EnvVars: []string{"PORT"}, - }, - &cli.StringFlag{ - Name: "bind", - Usage: "Network interface to bind to", - EnvVars: []string{"BIND_ADDR"}, - }, - &cli.StringFlag{ - Name: "data-dir", - Usage: "Directory for persistent data", - Value: "data", - EnvVars: []string{"DATA_DIR"}, - }, - &cli.StringFlag{ - Name: "server-url", - Aliases: []string{"s"}, - Usage: "External URL of this service", - EnvVars: []string{"SERVER_URL"}, - }, - &cli.StringFlag{ - Name: "https-port", - Usage: "HTTPS port to bind the service to", - Value: "8443", - EnvVars: []string{"HTTPS_PORT"}, - }, - &cli.StringFlag{ - Name: "https-server-url", - Aliases: []string{"S"}, - Usage: "External HTTPS URL", - EnvVars: []string{"HTTPS_SERVER_URL"}, - }, - &cli.BoolFlag{ - Name: "redact-logs", - Usage: "Redact sensitive data in proxy logs", - Value: true, - EnvVars: []string{"REDACT_PROXY_LOGS"}, - }, - &cli.BoolFlag{ - Name: "log-bodies", - Usage: "Log full request/response bodies", - EnvVars: []string{"LOG_PROXY_BODY"}, - }, - &cli.BoolFlag{ - Name: "record-interactions", - Usage: "Record HTTP interactions to disk", - Value: true, - EnvVars: []string{"RECORD_INTERACTIONS"}, - }, - &cli.BoolFlag{ - Name: "discovery-enabled", - Usage: "Enable periodic device discovery", - Value: true, - EnvVars: []string{"DISCOVERY_ENABLED"}, - }, - &cli.StringFlag{ - Name: "discovery-interval", - Usage: "Device discovery interval", - Value: "5m", - EnvVars: []string{"DISCOVERY_INTERVAL"}, - }, - &cli.BoolFlag{ - Name: "update-check-enabled", - Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)", - Value: false, - EnvVars: []string{"UPDATE_CHECK_ENABLED"}, - }, - &cli.StringFlag{ - Name: "update-check-interval", - Usage: "Update check interval", - Value: "24h", - EnvVars: []string{"UPDATE_CHECK_INTERVAL"}, - }, - &cli.BoolFlag{ - Name: "dns-discovery", - Usage: "Enable DNS discovery server", - EnvVars: []string{"ENABLE_DNS_DISCOVERY"}, - }, - &cli.StringFlag{ - Name: "dns-upstream", - Usage: "Upstream DNS server(s) for non-Bose queries (comma-separated). If empty, /etc/resolv.conf is used.", - Value: "", - EnvVars: []string{"DNS_UPSTREAM"}, - }, - &cli.StringFlag{ - Name: "dns-bind", - Usage: "Bind address for the DNS discovery server", - Value: ":53", - EnvVars: []string{"DNS_BIND_ADDR"}, - }, - &cli.StringFlag{ - Name: "spotify-client-id", - Usage: "Spotify OAuth client ID", - EnvVars: []string{"SPOTIFY_CLIENT_ID"}, - }, - &cli.StringFlag{ - Name: "spotify-client-secret", - Usage: "Spotify OAuth client secret", - EnvVars: []string{"SPOTIFY_CLIENT_SECRET"}, - }, - &cli.StringFlag{ - Name: "spotify-redirect-uri", - Usage: "Spotify OAuth redirect URI (defaults to /mgmt/spotify/callback)", - EnvVars: []string{"SPOTIFY_REDIRECT_URI"}, - }, - &cli.StringFlag{ - Name: "spotify-token-url", - Usage: "Spotify OAuth token URL (for testing)", - EnvVars: []string{"SPOTIFY_TOKEN_URL"}, - }, - &cli.StringFlag{ - Name: "spotify-api-base", - Usage: "Spotify API base URL (for testing)", - EnvVars: []string{"SPOTIFY_API_BASE"}, - }, - &cli.StringFlag{ - Name: "amazon-client-id", - Usage: "Amazon LWA OAuth client ID", - EnvVars: []string{"AMAZON_CLIENT_ID"}, - }, - &cli.StringFlag{ - Name: "amazon-client-secret", - Usage: "Amazon LWA OAuth client secret", - EnvVars: []string{"AMAZON_CLIENT_SECRET"}, - }, - &cli.StringFlag{ - Name: "amazon-redirect-uri", - Usage: "Amazon LWA OAuth redirect URI (defaults to /mgmt/amazon/callback)", - EnvVars: []string{"AMAZON_REDIRECT_URI"}, - }, - &cli.StringFlag{ - Name: "amazon-token-url", - Usage: "Amazon LWA token URL (for testing)", - EnvVars: []string{"AMAZON_TOKEN_URL"}, - }, - &cli.StringFlag{ - Name: "amazon-profile-url", - Usage: "Amazon LWA profile URL (for testing)", - EnvVars: []string{"AMAZON_PROFILE_URL"}, - }, - &cli.StringFlag{ - Name: "tunein-opml-url", - Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)", - EnvVars: []string{"TUNEIN_OPML_URL"}, - }, - &cli.StringFlag{ - Name: "tunein-api-url", - Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)", - EnvVars: []string{"TUNEIN_API_URL"}, - }, - &cli.StringFlag{ - Name: "tts-provider", - Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect", - EnvVars: []string{"TTS_PROVIDER"}, - }, - &cli.StringFlag{ - Name: "tts-google-api-key", - Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)", - EnvVars: []string{"TTS_GOOGLE_API_KEY"}, - }, - &cli.StringFlag{ - Name: "tts-google-endpoint", - Usage: "Google Cloud TTS synthesize endpoint override (for testing)", - EnvVars: []string{"TTS_GOOGLE_ENDPOINT"}, - }, - &cli.StringFlag{ - Name: "tts-language", - Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud", - EnvVars: []string{"TTS_LANGUAGE"}, - }, - &cli.StringFlag{ - Name: "tts-voice", - Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider", - EnvVars: []string{"TTS_VOICE"}, - }, - &cli.StringFlag{ - Name: "tts-app-key", - Usage: "Bose /speaker app_key used to play TTS notifications on speakers", - EnvVars: []string{"TTS_APP_KEY"}, - }, - &cli.IntFlag{ - Name: "tts-volume", - Usage: "Default TTS playback volume (0-100, 0 = keep current volume)", - Value: 0, - EnvVars: []string{"TTS_VOLUME"}, - }, - &cli.StringFlag{ - Name: "mgmt-username", - Usage: "Management API username for HTTP Basic Auth", - Value: "admin", - EnvVars: []string{"MGMT_USERNAME"}, - }, - &cli.StringFlag{ - Name: "mgmt-password", - Usage: "Management API password for HTTP Basic Auth", - Value: "change_me!", - EnvVars: []string{"MGMT_PASSWORD"}, - }, - &cli.StringFlag{ - Name: "base-url", - Usage: "External base URL for OAuth callbacks behind reverse proxy", - EnvVars: []string{"BASE_URL"}, - }, - &cli.StringSliceFlag{ - Name: "internal-paths", - Usage: "Paths for internal requests (comma-separated or multiple flags)", - EnvVars: []string{"INTERNAL_PATHS"}, - }, - &cli.StringSliceFlag{ - Name: "tls-extra-host", - Usage: "Additional DNS name or IP to include in the server TLS certificate SAN list (repeatable)", - EnvVars: []string{"TLS_EXTRA_HOST"}, - }, - &cli.BoolFlag{ - Name: "migration-enabled", - Usage: "Enable device directory migration from serial to MAC-based structure", - Value: true, - EnvVars: []string{"MIGRATION_ENABLED"}, - }, - &cli.BoolFlag{ - Name: "migration-dry-run", - Usage: "Log what would be migrated without actually doing it", - EnvVars: []string{"MIGRATION_DRY_RUN"}, - }, - &cli.StringFlag{ - Name: "stockholm-dir", - Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)", - EnvVars: []string{"STOCKHOLM_DIR"}, - }, - &cli.StringFlag{ - Name: "stockholm-base-path", - Usage: "URL prefix under which the Stockholm UI is served (e.g. /stockholm). Empty serves at root.", - Value: "/stockholm", - EnvVars: []string{"STOCKHOLM_BASE_PATH"}, - }, - }, + Flags: serviceFlags, Action: func(c *cli.Context) error { - config := loadConfig(c) + config, err := loadConfig(c) + if err != nil { + return err + } + ds := initDataStore(config.dataDir) // Detect a genuinely fresh data dir by the ABSENCE of settings.json, @@ -517,13 +533,11 @@ func main() { persisted = createDefaultSettings(ds, config) } - // Recalculate domains if settings changed - hostname, _ := os.Hostname() - if hostname == "" { - hostname = "localhost" - } - - config.domains = getDomains(config.serverURL, config.httpsServerURL, hostname, config.tlsExtraHosts) + // Recalculate domains if settings changed. Reuses the same mode-aware + // fallback host loadConfig already resolved, rather than a raw + // os.Hostname() call, so an on-device install doesn't leak its + // unresolvable variant codename back in here (see issue #546). + config.domains = getDomains(config.serverURL, config.httpsServerURL, config.hostname, config.tlsExtraHosts) cm := initCertificateManager(config.dataDir, config.hostname) sm := setup.NewManager(config.serverURL, ds, cm) @@ -752,7 +766,32 @@ type serviceConfig struct { stockholmBasePath string } -func loadConfig(c *cli.Context) serviceConfig { +// resolveFallbackHost picks the host used to guess a server URL when +// --server-url/SERVER_URL isn't set, based on where this service runs. +// On-device (running on the speaker's own Linux) is the one case where +// os.Hostname() is guaranteed useless: it returns the speaker's internal +// variant codename (e.g. "spotty", "mojo"), which nothing can resolve, not +// even the speaker itself (see issue #546). warnOnUse reports whether +// falling back to the returned host is risky enough to warrant a startup +// warning. +func resolveFallbackHost(deploymentMode string) (host string, warnOnUse bool) { + switch deploymentMode { + case "on-device": + return "localhost", false + case "public-network": + // Caller must refuse to guess a publicly reachable address. + return "", false + default: // "private-network", "", or any unrecognized value: today's behavior. + h, _ := os.Hostname() + if h == "" { + h = "localhost" + } + + return strings.ToLower(h), true + } +} + +func loadConfig(c *cli.Context) (serviceConfig, error) { port := c.String("port") bindAddr := c.String("bind") @@ -763,16 +802,23 @@ func loadConfig(c *cli.Context) serviceConfig { dataDir := c.String("data-dir") - hostname, _ := os.Hostname() - if hostname == "" { - hostname = "localhost" - } - - hostname = strings.ToLower(hostname) + deploymentMode := c.String("deployment-mode") + fallbackHost, warnOnFallback := resolveFallbackHost(deploymentMode) serverURL := c.String("server-url") if serverURL == "" { - serverURL = "http://" + hostname + ":" + port + if deploymentMode == "public-network" { + return serviceConfig{}, fmt.Errorf( + "--server-url (or SERVER_URL) is required when --deployment-mode=public-network; refusing to guess a public address") + } + + serverURL = "http://" + fallbackHost + ":" + port + + if warnOnFallback { + log.Printf("Warning: --server-url not set; defaulting to %s using this host's own hostname. "+ + "If your SoundTouch speakers can't reach this address, set --server-url/SERVER_URL explicitly, "+ + "or pass --deployment-mode=on-device if this runs on the speaker itself.", sanitizeLog(serverURL)) + } } // Strip a trailing slash so it cannot leak into the BMX registry base or the // margeServerUrl/bmxRegistryUrl pushed to speakers during migration. @@ -787,14 +833,14 @@ func loadConfig(c *cli.Context) serviceConfig { // The HTTPS URL is an override (from the flag/env); when empty it is // derived from serverURL + https port so one setting (Target Domain) - // drives both. httpsDefaultURL is the hostname-based fallback used + // drives both. httpsDefaultURL is the same mode-aware fallback used // before a Target Domain is configured. httpsOverride := c.String("https-server-url") - httpsDefaultURL := "https://" + hostname + ":" + httpsPort + httpsDefaultURL := "https://" + fallbackHost + ":" + httpsPort httpsServerURL := handlers.DeriveHTTPSURL(serverURL, httpsOverride, httpsPort, httpsDefaultURL) tlsExtraHosts := c.StringSlice("tls-extra-host") - domains := getDomains(serverURL, httpsServerURL, hostname, tlsExtraHosts) + domains := getDomains(serverURL, httpsServerURL, fallbackHost, tlsExtraHosts) redact := c.Bool("redact-logs") logBody := c.Bool("log-bodies") @@ -856,7 +902,7 @@ func loadConfig(c *cli.Context) serviceConfig { bindAddr: bindAddr, addr: addr, dataDir: dataDir, - hostname: hostname, + hostname: fallbackHost, serverURL: serverURL, httpsServerURL: httpsServerURL, httpsOverride: httpsOverride, @@ -901,7 +947,7 @@ func loadConfig(c *cli.Context) serviceConfig { migrationDryRun: migrationDryRun, stockholmDir: stockholmDir, stockholmBasePath: stockholmBasePath, - } + }, nil } func getDomains(serverURL, httpsServerURL, hostname string, extraHosts []string) []string { diff --git a/cmd/soundtouch-service/main_test.go b/cmd/soundtouch-service/main_test.go index 490ffcd..4934411 100644 --- a/cmd/soundtouch-service/main_test.go +++ b/cmd/soundtouch-service/main_test.go @@ -1,13 +1,147 @@ package main import ( + "flag" "os" "path/filepath" + "strings" "testing" "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/urfave/cli/v2" ) +// newTestServiceContext builds a real *cli.Context against serviceFlags (the +// exact flags soundtouch-service registers), so loadConfig tests exercise the +// same parsing/env-var wiring production code does, instead of a hand-rolled +// stand-in that could silently drift from it. +func newTestServiceContext(t *testing.T, args ...string) *cli.Context { + t.Helper() + + app := &cli.App{Flags: serviceFlags} + set := flag.NewFlagSet("test", flag.ContinueOnError) + + for _, f := range serviceFlags { + if err := f.Apply(set); err != nil { + t.Fatalf("apply flag %v: %v", f.Names(), err) + } + } + + if err := set.Parse(args); err != nil { + t.Fatalf("parse args %v: %v", args, err) + } + + return cli.NewContext(app, set, nil) +} + +func TestResolveFallbackHost(t *testing.T) { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "localhost" + } + + hostname = strings.ToLower(hostname) + + cases := []struct { + name string + deploymentMode string + wantHost string + wantWarn bool + }{ + {"on-device uses localhost, no warning", "on-device", "localhost", false}, + {"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false}, + {"private-network uses this host's own hostname, with warning", "private-network", hostname, true}, + {"unset/legacy behaves like private-network", "", hostname, true}, + {"unrecognized mode behaves like private-network", "some-typo", hostname, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode) + if gotHost != tc.wantHost { + t.Errorf("host: got %q, want %q", gotHost, tc.wantHost) + } + + if gotWarn != tc.wantWarn { + t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn) + } + }) + } +} + +func TestLoadConfig_DeploymentMode(t *testing.T) { + t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) { + config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000")) + if err != nil { + t.Fatalf("loadConfig: unexpected error: %v", err) + } + + if config.serverURL != "http://localhost:8000" { + t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000") + } + + if config.httpsDefaultURL != "https://localhost:8443" { + t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443") + } + }) + + t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) { + _, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network")) + if err == nil { + t.Fatal("expected an error, got nil") + } + + if !strings.Contains(err.Error(), "public-network") { + t.Errorf("expected error to mention public-network, got: %v", err) + } + }) + + t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) { + config, err := loadConfig(newTestServiceContext(t, + "--deployment-mode=public-network", "--server-url=https://soundtouch.example.com")) + if err != nil { + t.Fatalf("loadConfig: unexpected error: %v", err) + } + + if config.serverURL != "https://soundtouch.example.com" { + t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com") + } + }) + + t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) { + hostname, _ := os.Hostname() + if hostname == "" { + hostname = "localhost" + } + + hostname = strings.ToLower(hostname) + + config, err := loadConfig(newTestServiceContext(t, "--port=8000")) + if err != nil { + t.Fatalf("loadConfig: unexpected error: %v", err) + } + + want := "http://" + hostname + ":8000" + if config.serverURL != want { + t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want) + } + }) + + t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) { + for _, mode := range []string{"", "on-device", "private-network", "public-network"} { + config, err := loadConfig(newTestServiceContext(t, + "--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000")) + if err != nil { + t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err) + } + + if config.serverURL != "http://198.51.100.7:8000" { + t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL) + } + } + }) +} + func TestApplyPersistedSettings(t *testing.T) { tmpDir, err := os.MkdirTemp("", "main-test") if err != nil { diff --git a/docs/content/docs/guides/MIGRATION-GUIDE.md b/docs/content/docs/guides/MIGRATION-GUIDE.md index 5a435e8..b1d01e1 100644 --- a/docs/content/docs/guides/MIGRATION-GUIDE.md +++ b/docs/content/docs/guides/MIGRATION-GUIDE.md @@ -107,7 +107,7 @@ Open `http://:8000` and go to the **Settings** tab. Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`. -> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. +> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand. If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address. diff --git a/docs/content/docs/guides/ON-DEVICE-INSTALL-WALKTHROUGH.md b/docs/content/docs/guides/ON-DEVICE-INSTALL-WALKTHROUGH.md index 0d53a4c..e5fdcde 100644 --- a/docs/content/docs/guides/ON-DEVICE-INSTALL-WALKTHROUGH.md +++ b/docs/content/docs/guides/ON-DEVICE-INSTALL-WALKTHROUGH.md @@ -180,6 +180,17 @@ apply — that warning is about the external-host/cloud scenarios, where `localhost` would resolve on the wrong machine (the service host, not the speaker). Here there is no wrong machine to resolve on. +> **Note:** as of the fix for issue #546, the on-device init script already +> sets `DEPLOYMENT_MODE=on-device`, so a fresh (or reinstalled/updated) +> on-device install's own Target Domain already defaults to +> `http://localhost:8000` automatically — no manual Settings-tab step +> needed for that part. Older installs still default to the speaker's own +> unresolvable Linux hostname (e.g. `http://spotty:8000`) until reinstalled +> with a build that includes the fix, or until the Target Domain is +> corrected by hand. Either way, you still need to run Migrate below — that +> step tells the *speaker* to use this address, which is separate from what +> the service defaults its own identity to. + **Via the Admin UI:** 1. Go to **Settings**, set **Target Domain** to `http://localhost:8000`. diff --git a/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md b/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md index 0d374cc..16e985a 100644 --- a/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md +++ b/docs/content/docs/guides/SOUNDTOUCH-SERVICE.md @@ -156,32 +156,33 @@ The service supports multiple ways to configure its behavior. When multiple sour ### Configuration Options -| Variable | Flag | Description | Default | -|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` | -| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) | -| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` | -| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://:8000` | -| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` | -| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` | -| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` | -| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` | -| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` | -| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` | -| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` | -| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` | -| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` | -| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` | -| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` | -| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` | -| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` | -| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` | -| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` | -| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` | -| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* | -| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* | -| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* | -| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* | +| Variable | Flag | Description | Default | +|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------| +| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` | +| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) | +| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` | +| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://:8000` | +| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) | +| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` | +| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` | +| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` | +| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` | +| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` | +| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` | +| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` | +| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` | +| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` | +| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` | +| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` | +| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` | +| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` | +| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` | +| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` | +| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` | +| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* | +| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* | +| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* | +| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* | ### Configuration Examples diff --git a/scripts/on-device-install/README.md b/scripts/on-device-install/README.md index 75dc1c3..2619fa2 100644 --- a/scripts/on-device-install/README.md +++ b/scripts/on-device-install/README.md @@ -96,6 +96,24 @@ still reaches AfterTouch on `:8000` as before. Change or disable this with rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | AFTERTOUCH_LAN_PORT=none sh ``` +**`aftertouch.conf` isn't limited to `AFTERTOUCH_LAN_PORT`.** The init +script exports every assignment in this file into the daemon's own +environment, so any env var `soundtouch-service` reads (see the +[configuration table](../../docs/content/docs/guides/SOUNDTOUCH-SERVICE.md#configuration-options)) +can be set the same way — for example, to change the admin credentials: + +``` +MGMT_USERNAME=admin +MGMT_PASSWORD=change-me +``` + +Edit `/opt/aftertouch/aftertouch.conf` over SSH, then +`/etc/init.d/aftertouch restart` to apply. `DEPLOYMENT_MODE=on-device` is +already set by the init script itself — it never needs to be added here. +The auto-export behavior described here needs a build including the fix +for issue #546; older installs (before `aftertouch.conf` even existed, or +between then and that fix) need to reinstall/update first. + Which models need this, and how to report one that isn't listed yet, is tracked in [MODEL-SUPPORT-MATRIX.md](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md). diff --git a/scripts/on-device-install/aftertouch b/scripts/on-device-install/aftertouch index bab3b89..05bd3d0 100644 --- a/scripts/on-device-install/aftertouch +++ b/scripts/on-device-install/aftertouch @@ -25,10 +25,19 @@ LOG_TAG="aftertouch" export PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin" -# Optional settings written by install.sh (AFTERTOUCH_LAN_PORT, SERVICE_PORT). +# Optional settings written by install.sh (AFTERTOUCH_LAN_PORT, SERVICE_PORT), +# or added by hand for anything the daemon reads from its environment +# (SERVER_URL, MGMT_USERNAME, MGMT_PASSWORD, DEPLOYMENT_MODE, ...). `set -a` +# auto-exports every assignment while the file is sourced, so any such +# variable actually reaches the daemon -- it's forked from this same shell's +# environment further down via `--startas "/bin/sh" -- -c "... \"$DAEMON\" ..."`. # Sourced before the defaults below so it can override either. -# shellcheck source=/dev/null -[ -r "$CONFFILE" ] && . "$CONFFILE" +if [ -r "$CONFFILE" ]; then + set -a + # shellcheck source=/dev/null + . "$CONFFILE" + set +a +fi # Port the daemon binds locally. Kept in one variable because it appears in # the daemon arguments, the readiness poll and `status` -- three places that @@ -39,6 +48,13 @@ SERVICE_PORT="${SERVICE_PORT:-8000}" # LAN entry port: a port number, "auto" (default), or "none". LAN_PORT_MODE="${AFTERTOUCH_LAN_PORT:-auto}" +# This script only ever runs on the speaker itself, so the deployment mode is +# not a guess -- default it here (overridable via aftertouch.conf, though that +# should never be needed). Exported so soundtouch-service picks it up via +# DEPLOYMENT_MODE without needing a --deployment-mode flag threaded through +# the daemon invocation below. +export DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-on-device}" + # Sanity check executable test -x "$DAEMON" || { diff --git a/scripts/on-device-install/install.sh b/scripts/on-device-install/install.sh index 5709d54..cba116c 100644 --- a/scripts/on-device-install/install.sh +++ b/scripts/on-device-install/install.sh @@ -135,7 +135,13 @@ fi CONF_FILE="$INSTALL_DIR/aftertouch.conf" if [ -n "${AFTERTOUCH_LAN_PORT:-}" ] || [ ! -f "$CONF_FILE" ]; then cat > "$CONF_FILE" <