diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e1e5d65..663f4a8 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -114,6 +114,24 @@ func TestServerInfoPublic(t *testing.T) { } } +// TestServerIDInResponses covers that the stable per-install identity is advertised +// on /server AND handed back at pairing (exchange), so a client can key its +// per-server state the moment it pairs without a second request. +func TestServerIDInResponses(t *testing.T) { + e := newTestEnvWith(t, func(c *config.Config) { c.ServerID = "srv-test-id" }) + + _, body := e.do(t, "GET", "/api/v1/server", "", "") + if !strings.Contains(body, `"server_id":"srv-test-id"`) { + t.Fatalf("/server missing server_id: %s", body) + } + + _, ptok, _ := e.redeemCode(t, e.authCode) + status, exBody := e.exchangeToken(t, ptok, "device") + if status != 200 || !strings.Contains(exBody, `"server_id":"srv-test-id"`) { + t.Fatalf("exchange missing server_id: %d %s", status, exBody) + } +} + func TestUnauthenticatedRejected(t *testing.T) { e := newTestEnv(t) if resp, _ := e.do(t, "GET", "/api/v1/libraries", "", ""); resp.StatusCode != 401 { diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 6379b89..a09567b 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -22,9 +22,10 @@ const pairingTTL = 10 * time.Minute // layer can build on it. func (a *API) handleServerInfo(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ - "name": "AudioSilo", - "version": Version, - "api": "v1", + "name": "AudioSilo", + "server_id": a.cfg.ServerID, // stable per-install identity; clients key per-server state on it + "version": Version, + "api": "v1", "capabilities": map[string]bool{ "admin_ui": true, // baked-in admin console at /admin "web_player": web.HasPlayer(a.cfg.WebDir), // web player served at /web (when web_dir is populated) @@ -144,8 +145,9 @@ func (a *API) handleExchange(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, http.StatusOK, map[string]any{ - "token": session, - "user": full, + "token": session, + "user": full, + "server_id": a.cfg.ServerID, // so the client keys its per-server state at pairing time }) } @@ -181,7 +183,7 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "could not load account") return } - writeJSON(w, http.StatusOK, map[string]any{"token": session, "user": full}) + writeJSON(w, http.StatusOK, map[string]any{"token": session, "user": full, "server_id": a.cfg.ServerID}) } // handlePair issues a fresh pairing QR for the already-authenticated user, e.g. diff --git a/internal/api/handlers_demo.go b/internal/api/handlers_demo.go index 6ef442c..b40c27d 100644 --- a/internal/api/handlers_demo.go +++ b/internal/api/handlers_demo.go @@ -93,9 +93,10 @@ func (a *API) handleDemoSession(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ - "token": session, - "user": u, - "pairing": payload, + "token": session, + "user": u, + "pairing": payload, + "server_id": a.cfg.ServerID, }) } diff --git a/internal/config/config.go b/internal/config/config.go index 3e43cb7..baaceaa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -108,6 +108,12 @@ type Config struct { // serialized; it is supplied on the command line / environment. DataDir string `yaml:"-"` + // ServerID is a stable, per-install identity minted once (see launcher + // bootstrap) and persisted here in config.yaml so it survives a database + // rebuild. Clients key their per-server state (downloads, progress, cache) + // on it, so it must never change for the life of the install. + ServerID string `yaml:"server_id"` + Bind string `yaml:"bind"` // host:port to listen on PublicURL string `yaml:"public_url"` // externally reachable base URL, used in QR payloads TLS TLSConfig `yaml:"tls"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 47ac8c7..f6e0153 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -40,6 +40,7 @@ func TestLoadSaveRoundTrip(t *testing.T) { cfg.Bind = "127.0.0.1:9999" cfg.Libraries = []Library{{Name: "Books", Root: "/srv/books"}} + cfg.ServerID = "srv-abc123" // the launcher mints this; it must persist verbatim if err := cfg.Save(); err != nil { t.Fatalf("save: %v", err) } @@ -54,6 +55,9 @@ func TestLoadSaveRoundTrip(t *testing.T) { if got.Bind != "127.0.0.1:9999" || len(got.Libraries) != 1 || got.Libraries[0].Name != "Books" { t.Fatalf("round-trip mismatch: %+v", got) } + if got.ServerID != "srv-abc123" { + t.Fatalf("server id must survive Save/Load, got %q", got.ServerID) + } // Secrets-adjacent config is written owner-only. info, err := os.Stat(Path(dir)) diff --git a/pkg/launcher/app.go b/pkg/launcher/app.go index e406dac..8cc8f5a 100644 --- a/pkg/launcher/app.go +++ b/pkg/launcher/app.go @@ -101,6 +101,11 @@ func Run(ctx context.Context, opts Options) error { return fmt.Errorf("invalid configuration after applying overrides: %w", err) } + // Mint a stable per-install identity the first time (also self-heals an existing + // install that predates server_id). Persisted to config.yaml below so it survives + // a database rebuild; clients key their per-server state on it. + mintedServerID := ensureServerID(cfg) + db, err := store.Open(ctx, filepath.Join(abs, "audiosilo.db"), store.WithLogger(log)) if err != nil { return err @@ -113,8 +118,9 @@ func Run(ctx context.Context, opts Options) error { ffmpeg, ffprobe := resolveTools(ctx, abs, opts, log) scanner := library.NewScanner(cat, ffprobe, log) - // Persist a default config the first time (when none existed yet). - if firstRun { + // Persist a default config the first time (when none existed yet), or when we + // just minted a server_id for an install that predates it. + if firstRun || mintedServerID { if err := cfg.Save(); err != nil { return err } @@ -411,6 +417,20 @@ func demoReaper(ctx context.Context, authSvc *auth.Service, idleTTL time.Duratio } } +// ensureServerID mints a stable per-install identity into cfg the first time (and +// self-heals an install that predates server_id), returning whether it minted one so +// the caller persists config.yaml. The id lives in config (not the rebuildable +// database) so it survives a rescan/rebuild; clients key their per-server state on it, +// so it must never change. URL-safe so it can be both a route segment and a directory +// name on the client. +func ensureServerID(cfg *config.Config) bool { + if cfg.ServerID != "" { + return false + } + cfg.ServerID = randomSecret(16) + return true +} + // randomSecret returns a URL-safe random string carrying nBytes of entropy (the // encoded string is longer than nBytes characters). func randomSecret(nBytes int) string { diff --git a/pkg/launcher/app_test.go b/pkg/launcher/app_test.go index de800db..c163aeb 100644 --- a/pkg/launcher/app_test.go +++ b/pkg/launcher/app_test.go @@ -91,6 +91,26 @@ func TestRandomSecret(t *testing.T) { } } +// TestEnsureServerID covers minting once and self-healing: an empty ServerID is +// filled and reported minted; an existing one is preserved and reported not-minted +// (so the caller doesn't needlessly rewrite config, and the id never changes). +func TestEnsureServerID(t *testing.T) { + cfg := config.Default(t.TempDir()) + if !ensureServerID(cfg) || cfg.ServerID == "" { + t.Fatalf("first call must mint a non-empty id, got %q", cfg.ServerID) + } + id := cfg.ServerID + if strings.ContainsAny(id, "+/=") { + t.Fatalf("server id %q must be URL-safe (it becomes a route segment + dir name)", id) + } + if ensureServerID(cfg) { + t.Fatal("second call must not re-mint an existing id") + } + if cfg.ServerID != id { + t.Fatalf("existing server id must be preserved, got %q want %q", cfg.ServerID, id) + } +} + // TestResolveTool covers the bundled-ffmpeg lookup: empty/explicit paths pass // through; a bare name resolves to a tool sitting next to the executable, else // falls back to the bare name (PATH lookup happens later).