From c4cccea77520d33f9dc84d4210d6d10a33093369 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 22:14:33 -0400 Subject: [PATCH] refactor(server): remove bootstrap admin path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap-admin-from-env-vars flow was a holdover from before self-registration could create the first admin. Now that handleRegister + CreateUserFirstAdminRace promotes the first user to register on an empty users table (verified shipped in v2026.05.08.2 / #376), the bootstrap path is just a second source of truth that confuses operators (and leaves an "admin" account in the DB that nobody asked for). Removes: - internal/auth/bootstrap.go + its test - The auth.Bootstrap call from cmd/minstrel/main.go - AuthConfig + AdminBootstrapConfig structs from config - MINSTREL_AUTH_ADMIN_USERNAME / _PASSWORD env reads + their test - The auth: block from config.example.yaml - Bootstrap-related comments in docker-compose.yml + README.md The README quickstart now points operators at /register on first start instead of "watch the logs for a one-time password." Existing instances keep their bootstrap-admin row in the DB; operators who want it gone can register a new admin via /register, promote them in /admin/users, then delete the old bootstrap user (last-admin guard will require the new admin to be promoted first). No migration needed. Recovery story for forgotten admin passwords now hinges on Fable #321 (admin password reset CLI) — currently the only path back in if no other admin exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 +- cmd/minstrel/main.go | 5 -- config.example.yaml | 10 --- docker-compose.yml | 3 - internal/auth/bootstrap.go | 100 ---------------------- internal/auth/bootstrap_test.go | 143 -------------------------------- internal/config/config.go | 24 ------ internal/config/config_test.go | 16 ---- 8 files changed, 1 insertion(+), 303 deletions(-) delete mode 100644 internal/auth/bootstrap.go delete mode 100644 internal/auth/bootstrap_test.go diff --git a/README.md b/README.md index 81faa71f..3366afd4 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ volumes: docker compose up -d ``` -Watch `docker compose logs minstrel` on first start — a one-time admin password is printed to stderr. Sign in at `http://localhost:4533` with username `admin` and that password, then change it under Settings. +After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration). For the full configuration surface, see [`config.example.yaml`](./config.example.yaml). @@ -59,7 +59,6 @@ Most operators only need the env vars in the quickstart above. A few extras wort - `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews. - `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts. - `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`). -- `MINSTREL_AUTH_ADMIN_USERNAME` / `MINSTREL_AUTH_ADMIN_PASSWORD` — bootstrap admin credentials. Username defaults to `admin`; leave the password unset to have the server generate one and print it to stderr on first start. ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting. diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 9bf00598..11a0e375 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -11,7 +11,6 @@ import ( "syscall" "time" - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db" @@ -67,10 +66,6 @@ func run() error { } defer pool.Close() - if err := auth.Bootstrap(ctx, pool, cfg.Auth.AdminBootstrap, logger); err != nil { - return fmt.Errorf("bootstrap admin: %w", err) - } - scanner := library.New(pool, logger, cfg.Library.ScanPaths) contact := cfg.Library.ContactEmail diff --git a/config.example.yaml b/config.example.yaml index 42568fae..ba919ded 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -22,16 +22,6 @@ log: level: "info" # debug | info | warn | error format: "text" # text | json -auth: - # One-shot admin bootstrap. Applied only when the users table is empty. - # Username defaults to "admin" when unset — you can omit this section - # entirely for a working bootstrap. Leave password blank to have the server - # generate one and log it to stderr on first start. - # Env: MINSTREL_AUTH_ADMIN_USERNAME, MINSTREL_AUTH_ADMIN_PASSWORD - admin_bootstrap: - username: "admin" - password: "" - library: # Filesystem roots to scan for music. Each path is walked recursively. # Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style) diff --git a/docker-compose.yml b/docker-compose.yml index 469ae663..a22a72a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,9 +39,6 @@ services: MINSTREL_LIBRARY_SCAN_PATHS: /music MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true" MINSTREL_STORAGE_DATA_DIR: /app/data - # MINSTREL_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap - # generates one and prints it to stderr on first boot. Watch the logs - # of the minstrel service the first time you bring the stack up. networks: - minstrel ports: diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go deleted file mode 100644 index 2b764831..00000000 --- a/internal/auth/bootstrap.go +++ /dev/null @@ -1,100 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/base64" - "fmt" - "log/slog" - "os" - - "github.com/jackc/pgx/v5/pgxpool" - "golang.org/x/crypto/bcrypt" - - "git.fabledsword.com/bvandeusen/minstrel/internal/config" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// Bootstrap creates the initial admin user when the users table is empty AND -// the config supplies a bootstrap username. A blank password means "generate a -// random one and print it once on stderr" — suitable for first-run setups -// where the operator is watching the logs. -func Bootstrap(ctx context.Context, pool *pgxpool.Pool, cfg config.AdminBootstrapConfig, logger *slog.Logger) error { - if cfg.Username == "" { - return nil - } - - q := dbq.New(pool) - count, err := q.CountUsers(ctx) - if err != nil { - return fmt.Errorf("auth: count users: %w", err) - } - if count > 0 { - return nil - } - - password := cfg.Password - passwordGenerated := false - if password == "" { - p, err := randomToken(18) - if err != nil { - return fmt.Errorf("auth: generate password: %w", err) - } - password = p - passwordGenerated = true - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("auth: hash password: %w", err) - } - - apiToken, err := randomToken(32) - if err != nil { - return fmt.Errorf("auth: generate api token: %w", err) - } - - user, err := q.CreateUser(ctx, dbq.CreateUserParams{ - Username: cfg.Username, - PasswordHash: string(hash), - ApiToken: apiToken, - IsAdmin: true, - DisplayName: nil, - }) - if err != nil { - return fmt.Errorf("auth: insert admin: %w", err) - } - - // Mirror the bootstrap password into subsonic_password so Subsonic clients - // (Feishin, Symfonium, …) can authenticate via t+s without the operator - // having to run a separate SQL update after first boot. This is plaintext - // at rest — the cost of Subsonic's legacy auth — and matches what servers - // like Navidrome do. Operators who don't want a plaintext copy can clear - // it later with SetSubsonicPassword(nil). - subPass := password - if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{ - ID: user.ID, - SubsonicPassword: &subPass, - }); err != nil { - return fmt.Errorf("auth: set subsonic password: %w", err) - } - - logger.Info("admin bootstrapped", "username", cfg.Username, "user_id", user.ID.String()) - if passwordGenerated { - fmt.Fprintf(os.Stderr, "\n--- minstrel admin bootstrap (shown once) ---\nusername: %s\npassword: %s\napi_token: %s\n(password works for both the native UI and Subsonic clients)\n---\n\n", - cfg.Username, password, apiToken) - } else { - fmt.Fprintf(os.Stderr, "\n--- minstrel admin api token (shown once) ---\nusername: %s\napi_token: %s\n(your configured password now also works for Subsonic clients)\n---\n\n", - cfg.Username, apiToken) - } - return nil -} - -// randomToken returns a URL-safe base64 string backed by n random bytes. -func randomToken(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b), nil -} diff --git a/internal/auth/bootstrap_test.go b/internal/auth/bootstrap_test.go deleted file mode 100644 index 21a37b82..00000000 --- a/internal/auth/bootstrap_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package auth - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - - "github.com/jackc/pgx/v5/pgxpool" - "golang.org/x/crypto/bcrypt" - - "git.fabledsword.com/bvandeusen/minstrel/internal/config" - "git.fabledsword.com/bvandeusen/minstrel/internal/db" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// TestBootstrap_Integration exercises the admin-bootstrap flow against a real -// Postgres. Gated on MINSTREL_TEST_DATABASE_URL, skipped in -short mode. -func TestBootstrap_Integration(t *testing.T) { - if testing.Short() { - t.Skip("skipping bootstrap integration in -short mode") - } - dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") - if dsn == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set; skipping") - } - ctx := context.Background() - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - - if err := db.Migrate(dsn, logger); err != nil { - t.Fatalf("Migrate: %v", err) - } - - pool, err := pgxpool.New(ctx, dsn) - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - - // Bootstrap requires an empty users table to test the first-run path. - // When the test DB is shared with the operator's dev environment, the - // TRUNCATE wipes their real admin login. Save any non-test users now and - // reinsert them after the test finishes so the operator can log back in. - type savedUser struct { - username, passwordHash, apiToken string - isAdmin, listenbrainzEnabled bool - subsonicPassword, listenbrainzToken *string - } - var saved []savedUser - rows, err := pool.Query(ctx, ` - SELECT username, password_hash, api_token, is_admin, - subsonic_password, listenbrainz_token, listenbrainz_enabled - FROM users - WHERE username NOT LIKE 'test-%'`) - if err != nil { - t.Fatalf("save users: %v", err) - } - for rows.Next() { - var s savedUser - if err := rows.Scan(&s.username, &s.passwordHash, &s.apiToken, &s.isAdmin, - &s.subsonicPassword, &s.listenbrainzToken, &s.listenbrainzEnabled); err != nil { - rows.Close() - t.Fatalf("scan: %v", err) - } - saved = append(saved, s) - } - rows.Close() - t.Cleanup(func() { - // Remove anything the test created, then restore non-test users. - if _, err := pool.Exec(context.Background(), "TRUNCATE users RESTART IDENTITY CASCADE"); err != nil { - t.Logf("cleanup truncate: %v", err) - return - } - for _, s := range saved { - if _, err := pool.Exec(context.Background(), ` - INSERT INTO users (username, password_hash, api_token, is_admin, - subsonic_password, listenbrainz_token, listenbrainz_enabled) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - s.username, s.passwordHash, s.apiToken, s.isAdmin, - s.subsonicPassword, s.listenbrainzToken, s.listenbrainzEnabled); err != nil { - t.Logf("restore %q: %v", s.username, err) - } - } - }) - - if _, err := pool.Exec(ctx, "TRUNCATE users RESTART IDENTITY CASCADE"); err != nil { - t.Fatalf("truncate: %v", err) - } - - const testAdmin = "test-admin" - cfg := config.AdminBootstrapConfig{Username: testAdmin, Password: "hunter2"} - if err := Bootstrap(ctx, pool, cfg, logger); err != nil { - t.Fatalf("Bootstrap: %v", err) - } - - q := dbq.New(pool) - user, err := q.GetUserByUsername(ctx, testAdmin) - if err != nil { - t.Fatalf("GetUserByUsername: %v", err) - } - if !user.IsAdmin { - t.Error("bootstrapped user not marked admin") - } - if user.ApiToken == "" { - t.Error("api_token empty") - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("hunter2")); err != nil { - t.Errorf("bcrypt compare: %v", err) - } - if user.SubsonicPassword == nil || *user.SubsonicPassword != "hunter2" { - t.Errorf("subsonic_password = %v, want \"hunter2\"", user.SubsonicPassword) - } - - byToken, err := q.GetUserByAPIToken(ctx, user.ApiToken) - if err != nil { - t.Fatalf("GetUserByAPIToken: %v", err) - } - if byToken.Username != testAdmin { - t.Errorf("token lookup returned %q", byToken.Username) - } - - // Second call must be a no-op because users table is non-empty. - if err := Bootstrap(ctx, pool, cfg, logger); err != nil { - t.Fatalf("Bootstrap (second call): %v", err) - } - count, err := q.CountUsers(ctx) - if err != nil { - t.Fatalf("CountUsers: %v", err) - } - if count != 1 { - t.Errorf("expected exactly 1 user after idempotent bootstrap, got %d", count) - } -} - -// TestBootstrap_NoUsernameSkips verifies the no-config path without needing -// a database connection. -func TestBootstrap_NoUsernameSkips(t *testing.T) { - err := Bootstrap(context.Background(), nil, config.AdminBootstrapConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) - if err != nil { - t.Errorf("empty config should return nil, got %v", err) - } -} diff --git a/internal/config/config.go b/internal/config/config.go index 28120601..4320fc20 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,6 @@ type Config struct { Storage StorageConfig `yaml:"storage"` Database DatabaseConfig `yaml:"database"` Log LogConfig `yaml:"log"` - Auth AuthConfig `yaml:"auth"` Library LibraryConfig `yaml:"library"` Subsonic SubsonicConfig `yaml:"subsonic"` Events EventsConfig `yaml:"events"` @@ -56,18 +55,6 @@ type LogConfig struct { Format string `yaml:"format"` } -type AuthConfig struct { - AdminBootstrap AdminBootstrapConfig `yaml:"admin_bootstrap"` -} - -// AdminBootstrapConfig drives the one-shot admin creation on an empty users -// table. Username is required to enable the flow; leaving Password blank asks -// the server to generate a random one and log it to stderr. -type AdminBootstrapConfig struct { - Username string `yaml:"username"` - Password string `yaml:"password"` -} - type LibraryConfig struct { ScanPaths []string `yaml:"scan_paths"` ScanOnStartup bool `yaml:"scan_on_startup"` @@ -115,11 +102,6 @@ func Default() Config { AppName: "Minstrel", Description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration.", }, - Auth: AuthConfig{ - AdminBootstrap: AdminBootstrapConfig{ - Username: "admin", - }, - }, Events: EventsConfig{ SessionTimeoutMinutes: 30, SkipMaxCompletionRatio: 0.5, @@ -182,12 +164,6 @@ func applyEnv(cfg *Config) { if v, ok := os.LookupEnv("MINSTREL_BRANDING_DESCRIPTION"); ok && v != "" { cfg.Branding.Description = v } - if v, ok := os.LookupEnv("MINSTREL_AUTH_ADMIN_USERNAME"); ok { - cfg.Auth.AdminBootstrap.Username = v - } - if v, ok := os.LookupEnv("MINSTREL_AUTH_ADMIN_PASSWORD"); ok { - cfg.Auth.AdminBootstrap.Password = v - } if v, ok := os.LookupEnv("MINSTREL_LIBRARY_SCAN_PATHS"); ok { cfg.Library.ScanPaths = splitPaths(v) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3bfd8741..7768f3c9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -79,22 +79,6 @@ func TestEnvOverrides(t *testing.T) { } } -func TestAdminBootstrapEnvOverrides(t *testing.T) { - t.Setenv("MINSTREL_AUTH_ADMIN_USERNAME", "root") - t.Setenv("MINSTREL_AUTH_ADMIN_PASSWORD", "sekret") - - cfg, err := Load("") - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Auth.AdminBootstrap.Username != "root" { - t.Errorf("admin username = %q", cfg.Auth.AdminBootstrap.Username) - } - if cfg.Auth.AdminBootstrap.Password != "sekret" { - t.Errorf("admin password = %q", cfg.Auth.AdminBootstrap.Password) - } -} - func TestLibraryEnvOverrides(t *testing.T) { t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third") t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")