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 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-08-16 20:12:44 +02:00
co-authored by Claude Sonnet 5
parent de6172de17
commit 81b915bfca
8 changed files with 525 additions and 293 deletions
+308 -262
View File
@@ -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 <server-url>/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 <server-url>/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 <server-url>/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 <server-url>/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 {
+134
View File
@@ -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 {
+1 -1
View File
@@ -107,7 +107,7 @@ Open `http://<server>: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.
@@ -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`.
+27 -26
View File
@@ -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://<hostname>: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://<hostname>: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:<port>` 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
+18
View File
@@ -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).
+19 -3
View File
@@ -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" || {
+7 -1
View File
@@ -135,7 +135,13 @@ fi
CONF_FILE="$INSTALL_DIR/aftertouch.conf"
if [ -n "${AFTERTOUCH_LAN_PORT:-}" ] || [ ! -f "$CONF_FILE" ]; then
cat > "$CONF_FILE" <<CONFEOF
# AfterTouch on-device settings. Sourced by /etc/init.d/aftertouch.
# AfterTouch on-device settings. Sourced by /etc/init.d/aftertouch, which
# exports every assignment here into the daemon's own environment -- so any
# env var soundtouch-service reads (see docs: guides/SOUNDTOUCH-SERVICE.md,
# "Configuration Options") can be set by adding a line below and running
# \`/etc/init.d/aftertouch restart\`, e.g.:
# MGMT_USERNAME=admin
# MGMT_PASSWORD=change-me
#
# AFTERTOUCH_LAN_PORT: how AfterTouch is reached from other machines.
# auto (default) redirect a spare Bose port to AfterTouch, but only on