// Command adminctl manages admin-backend accounts from the CLI. // // The first administrator must be created here (the web UI has no self-signup): // // adminctl create -username alice // // Requires DB_DSN and ADMIN_SECRET_KEY in the environment (same as the server). // On success it prints the otpauth:// provisioning URI and Base32 secret to the // terminal so the operator can add it to an authenticator app / render a QR. package main import ( "bufio" "context" "encoding/base64" "encoding/hex" "errors" "flag" "fmt" "os" "strings" "golang.org/x/term" "github.com/wangjia/pangolin/server/internal/admin" "github.com/wangjia/pangolin/server/internal/db" "github.com/wangjia/pangolin/server/internal/totp" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "create": if err := runCreate(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } case "-h", "--help", "help": usage() default: usage() os.Exit(2) } } func usage() { fmt.Fprintln(os.Stdout, "adminctl — Pangolin admin account tool") fmt.Fprintln(os.Stdout, "") fmt.Fprintln(os.Stdout, "Usage:") fmt.Fprintln(os.Stdout, " adminctl create -username ") fmt.Fprintln(os.Stdout, "") fmt.Fprintln(os.Stdout, "Env: DB_DSN, ADMIN_SECRET_KEY (32-byte hex/base64)") } func runCreate(args []string) error { var username string fs := flag.NewFlagSet("create", flag.ContinueOnError) fs.StringVar(&username, "username", "", "admin username") if err := fs.Parse(args); err != nil { return err } username = strings.TrimSpace(username) if username == "" { return errors.New("-username is required") } dsn := os.Getenv("DB_DSN") if dsn == "" { return errors.New("DB_DSN is required") } keyStr := os.Getenv("ADMIN_SECRET_KEY") if keyStr == "" { return errors.New("ADMIN_SECRET_KEY is required") } key, err := parseSecretKey(keyStr) if err != nil { return err } password, err := readPassword() if err != nil { return err } if len(password) < 8 { return errors.New("password must be at least 8 characters") } pwHash, err := admin.HashPassword(password) if err != nil { return err } secret, err := totp.GenerateSecret() if err != nil { return err } enc, err := admin.EncryptSecret(key, secret) if err != nil { return err } database, err := db.Open(dsn) if err != nil { return err } defer database.Close() store := admin.NewDBStore(database) id, err := store.CreateAdmin(context.Background(), username, pwHash, enc) if err != nil { return err } uri := totp.ProvisioningURI(secret, username, "Pangolin") fmt.Println() fmt.Printf("✓ 已创建管理员 #%d: %s\n", id, username) fmt.Println() fmt.Println("请在身份验证器中添加以下 TOTP(二维码可由下方 otpauth URI 生成):") fmt.Println(" Secret (Base32):", secret) fmt.Println(" otpauth URI :", uri) fmt.Println() fmt.Println("登录需输入:用户名 + 密码 + 6 位动态验证码。") return nil } // readPassword reads a password from ADMIN_PASSWORD or, if unset, prompts on the // terminal with echo disabled (falling back to a plain stdin line). func readPassword() (string, error) { if v := os.Getenv("ADMIN_PASSWORD"); v != "" { return v, nil } fd := int(os.Stdin.Fd()) if term.IsTerminal(fd) { fmt.Print("Password: ") b, err := term.ReadPassword(fd) fmt.Println() if err != nil { return "", err } return string(b), nil } r := bufio.NewReader(os.Stdin) line, err := r.ReadString('\n') if err != nil && line == "" { return "", err } return strings.TrimRight(line, "\r\n"), nil } // parseSecretKey accepts a 32-byte key as hex or base64 (mirrors the admin // package's own loader so the CLI and server agree on the key format). func parseSecretKey(s string) ([]byte, error) { s = strings.TrimSpace(s) if b, err := hex.DecodeString(s); err == nil && len(b) == 32 { return b, nil } if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 { return b, nil } if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 { return b, nil } return nil, errors.New("ADMIN_SECRET_KEY must decode to exactly 32 bytes (hex or base64)") }