// luminary-recover: emergency recovery CLI (reset password, clear IP rules, restart). // Intended for docker exec when admin login is locked out. package main import ( "flag" "fmt" "os" "path/filepath" "syscall" "time" "github.com/glebarez/sqlite" "github.com/rose_cat707/luminary/internal/auth" "github.com/rose_cat707/luminary/internal/model" "github.com/rose_cat707/luminary/internal/settings" "gorm.io/gorm" "gorm.io/gorm/logger" ) func main() { os.Exit(run()) } func run() int { dataDir := flag.String("data-dir", envOr("LUMINARY_DATA", "./data"), "directory containing luminary.db") deprecatedConfig := flag.String("config", "", "deprecated: ignored; use -data-dir / LUMINARY_DATA") username := flag.String("username", "", "admin username (default: admin)") password := flag.String("password", "", "new admin password (default: admin123)") doAll := flag.Bool("all", false, "reset password, clear IP rules and sessions, then restart") doPassword := flag.Bool("reset-password", false, "reset admin password") doIP := flag.Bool("clear-ip", false, "delete all IP rules") doSessions := flag.Bool("clear-sessions", false, "delete all admin sessions") doRestart := flag.Bool("restart", false, "send SIGTERM to PID 1 to restart server (clears login cooldown)") flag.Parse() if *deprecatedConfig != "" { fmt.Fprintln(os.Stderr, "warning: -config is deprecated and ignored; use -data-dir / LUMINARY_DATA") } if !*doAll && !*doPassword && !*doIP && !*doSessions && !*doRestart { fmt.Fprintln(os.Stderr, "usage: luminary-recover --all [--password NEW] [--restart]") fmt.Fprintln(os.Stderr, " luminary-recover --reset-password --clear-ip --clear-sessions [--restart]") flag.PrintDefaults() return 2 } user := *username pass := *password if user == "" || pass == "" { legacyUser, legacyPass := settings.LegacyAdminCredentials() if user == "" { user = legacyUser } if pass == "" { pass = legacyPass } } if *doAll { *doPassword = true *doIP = true *doSessions = true *doRestart = true } dbPath := filepath.Join(*dataDir, "luminary.db") db, err := openDB(dbPath) if err != nil { fmt.Fprintf(os.Stderr, "open database: %v\n", err) return 1 } if *doPassword { if pass == "" { fmt.Fprintln(os.Stderr, "password is required (use --password)") return 1 } if err := resetPassword(db, user, pass); err != nil { fmt.Fprintf(os.Stderr, "reset password: %v\n", err) return 1 } fmt.Printf("admin password reset for user %q\n", user) } if *doIP { res := db.Exec("DELETE FROM ip_rules") if res.Error != nil { fmt.Fprintf(os.Stderr, "clear IP rules: %v\n", res.Error) return 1 } fmt.Printf("cleared %d IP rule(s)\n", res.RowsAffected) } if *doSessions { res := db.Exec("DELETE FROM admin_sessions") if res.Error != nil { fmt.Fprintf(os.Stderr, "clear sessions: %v\n", res.Error) return 1 } fmt.Printf("cleared %d admin session(s)\n", res.RowsAffected) } if *doRestart { fmt.Println("sending SIGTERM to PID 1 (restart server to clear in-memory login cooldown)...") if err := syscall.Kill(1, syscall.SIGTERM); err != nil { fmt.Fprintf(os.Stderr, "restart: %v (you may need to restart the container manually)\n", err) return 1 } } fmt.Println("done") return 0 } func openDB(path string) (*gorm.DB, error) { dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } sqlDB.SetMaxOpenConns(1) return db, nil } func resetPassword(db *gorm.DB, username, password string) error { hash, err := auth.HashPassword(password) if err != nil { return err } var user model.AdminUser err = db.Where("username = ?", username).First(&user).Error if err == gorm.ErrRecordNotFound { return db.Create(&model.AdminUser{ Username: username, PasswordHash: hash, CreatedAt: time.Now(), }).Error } if err != nil { return err } return db.Model(&user).Update("password_hash", hash).Error } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }