760b4a7c6c
#321 — `minstrel admin reset-password [-user admin] [-password X]`: loads config, updates BOTH password_hash (bcrypt) and subsonic_password (plaintext, for Subsonic t+s) so neither auth path is left stale; generates+prints a strong password when -password is omitted. Recovers a locked-out operator without DB surgery. Subcommand dispatch added to main.go (os.Args switch before flag-parse; server path untouched) plus a `minstrel migrate` subcommand exposing db.Migrate standalone. #339 — integration tests no longer truncate the dev DB: - deploy/initdb creates minstrel_test on a fresh compose volume; `make test-integration` ensures it idempotently and points MINSTREL_TEST_DATABASE_URL at minstrel_test. - docker-compose.yml + README updated. CI integration job (test-go.yml): the prior workflow only ran `go test -short -race` with no DB, so the entire integration suite silently t.Skip'd — "CI green" never covered API/db/scanner. New `integration` job runs the full `go test -race` against an ephemeral Postgres service, using the act_runner shared-daemon pattern: no published ports, discover the service container by job-name filter via the docker socket, reach it by bridge IP, hard exactly-one assertion + dev-compose-name reject (a wrong target would truncate real data), TCP-wait, `minstrel migrate`, then test. Fast `test` job kept as the quick gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
3.9 KiB
Go
130 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
|
|
"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"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
|
)
|
|
|
|
// runMigrate applies the embedded migrations and exits. The server also
|
|
// auto-migrates on startup (main.go); this standalone path exists for CI
|
|
// (provision a fresh DB before the integration suite) and operators who
|
|
// want to migrate without starting the server.
|
|
func runMigrate(args []string) error {
|
|
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
|
|
if err != nil {
|
|
return fmt.Errorf("init logger: %w", err)
|
|
}
|
|
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
|
|
return fmt.Errorf("migrate: %w", err)
|
|
}
|
|
fmt.Println("minstrel: migrations applied")
|
|
return nil
|
|
}
|
|
|
|
// runAdmin dispatches `minstrel admin <subcommand>`.
|
|
func runAdmin(args []string) error {
|
|
if len(args) == 0 {
|
|
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
|
|
}
|
|
switch args[0] {
|
|
case "reset-password":
|
|
return adminResetPassword(args[1:])
|
|
default:
|
|
return fmt.Errorf("unknown admin subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// adminResetPassword resets a user's credentials. It updates BOTH
|
|
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
|
// (plaintext, required for Subsonic t+s token verification) so neither
|
|
// auth path is left stale. Recovers a locked-out operator when the
|
|
// bootstrap password was missed or the DB volume was recreated (Fable
|
|
// #321) without DB surgery.
|
|
func adminResetPassword(args []string) error {
|
|
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
|
username := fs.String("user", "admin", "username to reset")
|
|
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, err := config.Load(*configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
if cfg.Database.URL == "" {
|
|
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
pool, err := db.Open(ctx, cfg.Database.URL)
|
|
if err != nil {
|
|
return fmt.Errorf("open db: %w", err)
|
|
}
|
|
defer pool.Close()
|
|
q := dbq.New(pool)
|
|
|
|
user, err := q.GetUserByUsername(ctx, *username)
|
|
if err != nil {
|
|
return fmt.Errorf("look up user %q: %w", *username, err)
|
|
}
|
|
|
|
pw := *password
|
|
generated := false
|
|
if pw == "" {
|
|
b := make([]byte, 18)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return fmt.Errorf("generate password: %w", err)
|
|
}
|
|
pw = base64.RawURLEncoding.EncodeToString(b)
|
|
generated = true
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
|
|
ID: user.ID,
|
|
PasswordHash: string(hash),
|
|
}); err != nil {
|
|
return fmt.Errorf("update password_hash: %w", err)
|
|
}
|
|
sp := pw
|
|
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
|
|
ID: user.ID,
|
|
SubsonicPassword: &sp,
|
|
}); err != nil {
|
|
return fmt.Errorf("update subsonic_password: %w", err)
|
|
}
|
|
|
|
if generated {
|
|
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
|
} else {
|
|
fmt.Printf("minstrel: password for %q reset.\n", *username)
|
|
}
|
|
return nil
|
|
}
|