diff --git a/server/cmd/server/cmd/reset_admin.go b/server/cmd/server/cmd/reset_admin.go new file mode 100644 index 0000000..5f75792 --- /dev/null +++ b/server/cmd/server/cmd/reset_admin.go @@ -0,0 +1,178 @@ +// Package cmd 命令行入口。 +package cmd + +import ( + "crypto/rand" + "fmt" + "log" + "math/big" + "os" + "strings" + + "github.com/openteam/server/internal/config" + "github.com/openteam/server/internal/pkg/crypto" + "github.com/openteam/server/internal/store" + "github.com/spf13/cobra" + "golang.org/x/term" + "gorm.io/gorm" +) + +// pwAlphabet 密码随机串字符集(去掉 0/O/1/l/I 等易混淆字符)。 +const pwAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" + +var ( + resetUser string + resetPassword string +) + +var resetAdminPasswordCmd = &cobra.Command{ + Use: "reset-admin-password", + Short: "重置管理员密码", + Long: `重置管理员密码(忘记密码时用,需在可加载配置的目录下执行)。 + +交互式引导(默认): + go run ./cmd/server reset-admin-password + +非交互式(用于脚本): + go run ./cmd/server reset-admin-password --user admin --password "新密码"`, + Run: func(cmd *cobra.Command, args []string) { + runResetAdminPassword() + }, +} + +func init() { + rootCmd.AddCommand(resetAdminPasswordCmd) + resetAdminPasswordCmd.Flags().StringVar(&resetUser, "user", "", "目标管理员用户名(留空则交互选择)") + resetAdminPasswordCmd.Flags().StringVar(&resetPassword, "password", "", "新密码,至少 8 位(留空则交互输入)") +} + +func runResetAdminPassword() { + // --- 阶段 1:加载配置与数据库 --- + cfg, err := config.Load() + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN) + if err != nil { + log.Fatalf("连接数据库失败: %v", err) + } + + // --- 阶段 2:列出管理员 --- + var admins []struct { + ID uint64 + Username string + } + db.Model(&store.User{}).Select("id, username").Where("role = ?", store.RoleAdmin).Find(&admins) + if len(admins) == 0 { + log.Fatalf("系统中没有管理员用户") + } + + // --- 阶段 3:选择目标管理员 --- + target := targetAdmin(db, admins) + + // --- 阶段 4:获取新密码 --- + var pw string + if resetPassword != "" { + pw = resetPassword + } else { + pw = promptPassword() + } + if pw == "" { + log.Fatalf("密码不能为空") + } + if len(pw) < 8 || len(pw) > 72 { + log.Fatalf("密码长度必须在 8 到 72 位之间") + } + + // --- 阶段 5:加密并更新 --- + hasher := crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen) + hash, err := hasher.HashPassword(pw) + if err != nil { + log.Fatalf("加密密码失败: %v", err) + } + if err := db.Model(&store.User{}).Where("id = ?", target.ID).Update("password_hash", hash).Error; err != nil { + log.Fatalf("更新密码失败: %v", err) + } + + fmt.Printf("✅ 已重置管理员 %q 的密码!\n", target.Username) +} + +// targetAdmin 解析 --user 或交互选择目标管理员。 +func targetAdmin(db *gorm.DB, admins []struct { + ID uint64 + Username string +}) *store.User { + if resetUser != "" { + for i := range admins { + if admins[i].Username == resetUser { + return &store.User{ID: admins[i].ID, Username: admins[i].Username} + } + } + log.Fatalf("管理员 %q 不存在", resetUser) + } + if len(admins) == 1 { + return &store.User{ID: admins[0].ID, Username: admins[0].Username} + } + return selectAdmin(admins) +} + +// selectAdmin 交互式选择管理员用户。 +func selectAdmin(admins []struct { + ID uint64 + Username string +}) *store.User { + fmt.Println("检测到多个管理员用户,请选择要重置密码的目标:") + for i, a := range admins { + fmt.Printf(" [%d] %s\n", i+1, a.Username) + } + fmt.Printf("请输入编号 (1-%d): ", len(admins)) + var n int + if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > len(admins) { + log.Fatalf("无效输入") + } + return &store.User{ID: admins[n-1].ID, Username: admins[n-1].Username} +} + +// promptPassword 交互式输入密码,并提供两种方式选择。 +func promptPassword() string { + fmt.Println("请选择密码设置方式:") + fmt.Println(" [1] 手动输入密码") + fmt.Println(" [2] 自动生成随机密码(16 位,不含易混淆字符)") + fmt.Printf("请输入编号 (1-2): ") + + var n int + if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > 2 { + log.Fatalf("无效输入,请输入 1 或 2") + } + if n == 2 { + pw, err := randomPassword(16) + if err != nil { + log.Fatalf("生成随机密码失败: %v", err) + } + fmt.Printf("🔑 生成的随机密码:%s\n", pw) + fmt.Println("(请立即保存,不会再次显示)") + return pw + } + + // 手动输入:静默输入(不回显) + fmt.Print("请输入新密码(至少 8 位):") + raw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + log.Fatalf("读取密码失败: %v", err) + } + return strings.TrimSpace(string(raw)) +} + +// randomPassword 用 crypto/rand 从 pwAlphabet 生成 n 位随机密码。 +func randomPassword(n int) (string, error) { + buf := make([]byte, n) + for i := range buf { + k, err := rand.Int(rand.Reader, big.NewInt(int64(len(pwAlphabet)))) + if err != nil { + return "", err + } + buf[i] = pwAlphabet[k.Int64()] + } + return string(buf), nil +} diff --git a/server/cmd/server/cmd/root.go b/server/cmd/server/cmd/root.go new file mode 100644 index 0000000..8d80c71 --- /dev/null +++ b/server/cmd/server/cmd/root.go @@ -0,0 +1,33 @@ +// Package cmd 命令行入口(Cobra 子命令分发)。 +package cmd + +import ( + "github.com/spf13/cobra" +) + +// DefaultRun 无子命令时的默认行为(由 main 注入服务启动函数)。 +var DefaultRun func() + +// Execute 启动根命令。 +func Execute() { + cobra.CheckErr(rootCmd.Execute()) +} + +var rootCmd = &cobra.Command{ + Use: "openteam", + Short: "openteam · LLM API 中转站", + Long: `openteam 大模型中转站 API 服务。 + +直接运行(无子命令)启动 HTTP 服务: + go run ./cmd/server + +子命令: + reset-admin-password 重置管理员密码(交互式,无参数时自动提示)`, + Run: func(c *cobra.Command, args []string) { + if DefaultRun != nil { + DefaultRun() + } else { + _ = c.Help() + } + }, +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index d6fe230..3ee6544 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -13,19 +13,22 @@ import ( "syscall" "time" + "github.com/openteam/server/cmd/server/cmd" "github.com/openteam/server/internal/api" "github.com/openteam/server/internal/app" "github.com/openteam/server/internal/config" "github.com/openteam/server/internal/proxy" ) -func main() { - // 子命令分发:`openteam reset-admin-password ...`(无子命令时启动服务) - if len(os.Args) > 1 && os.Args[1] == "reset-admin-password" { - runResetAdminPassword(os.Args[2:]) - return - } +func init() { + cmd.DefaultRun = serve +} +func main() { + cmd.Execute() +} + +func serve() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) diff --git a/server/cmd/server/reset_admin.go b/server/cmd/server/reset_admin.go deleted file mode 100644 index 5c9cf4b..0000000 --- a/server/cmd/server/reset_admin.go +++ /dev/null @@ -1,81 +0,0 @@ -// 子命令 reset-admin-password:重置管理员密码(忘记密码时用,需在 server/ 目录或配置可加载处执行)。 -// 用法:go run ./cmd/server reset-admin-password [-user admin] [-password 新密码] -package main - -import ( - "crypto/rand" - "flag" - "fmt" - "log" - "math/big" - - "github.com/openteam/server/internal/config" - "github.com/openteam/server/internal/pkg/crypto" - "github.com/openteam/server/internal/store" -) - -// pwAlphabet 密码随机串字符集(去掉 0/O/1/l/I 等易混淆字符)。 -const pwAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" - -func runResetAdminPassword(args []string) { - fs := flag.NewFlagSet("reset-admin-password", flag.ExitOnError) - user := fs.String("user", "admin", "目标管理员用户名") - password := fs.String("password", "", "新密码(至少 8 位;留空则随机生成并打印一次)") - _ = fs.Parse(args) - - cfg, err := config.Load() - if err != nil { - log.Fatalf("config: %v", err) - } - db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN) - if err != nil { - log.Fatalf("db: %v", err) - } - - var u store.User - if err := db.Where("username = ? AND role = ?", *user, store.RoleAdmin).First(&u).Error; err != nil { - log.Fatalf("admin user %q not found", *user) - } - - pw := *password - generated := false - if pw == "" { - pw, err = randomPassword(16) - if err != nil { - log.Fatalf("generate password: %v", err) - } - generated = true - } - if len(pw) < 8 || len(pw) > 72 { - log.Fatal("password length must be between 8 and 72") - } - - hasher := crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen) - hash, err := hasher.HashPassword(pw) - if err != nil { - log.Fatalf("hash password: %v", err) - } - if err := db.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil { - log.Fatalf("update password: %v", err) - } - - if generated { - fmt.Printf("已重置 %q 的密码:%s\n", u.Username, pw) - fmt.Println("(请立即保存,不会再次显示)") - } else { - fmt.Printf("已重置 %q 的密码。\n", u.Username) - } -} - -// randomPassword 用 crypto/rand 从 pwAlphabet 生成 n 位随机密码。 -func randomPassword(n int) (string, error) { - buf := make([]byte, n) - for i := range buf { - k, err := rand.Int(rand.Reader, big.NewInt(int64(len(pwAlphabet)))) - if err != nil { - return "", err - } - buf[i] = pwAlphabet[k.Int64()] - } - return string(buf), nil -} diff --git a/server/go.mod b/server/go.mod index b9e45c5..dfb01ca 100644 --- a/server/go.mod +++ b/server/go.mod @@ -7,9 +7,11 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/go-webauthn/webauthn v0.17.4 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/tiktoken-go/tokenizer v0.8.1 golang.org/x/crypto v0.55.0 + golang.org/x/term v0.45.0 gorm.io/driver/postgres v1.6.2 gorm.io/gorm v1.31.2 ) @@ -35,6 +37,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect diff --git a/server/go.sum b/server/go.sum index e338004..5d29637 100644 --- a/server/go.sum +++ b/server/go.sum @@ -6,6 +6,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -60,6 +61,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -106,6 +109,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -114,6 +118,9 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= @@ -159,6 +166,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=