Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 8 additions & 6 deletions internal/api/handlers_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
})
}

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions internal/api/handlers_demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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))
Expand Down
24 changes: 22 additions & 2 deletions pkg/launcher/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions pkg/launcher/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading