package main import ( "bufio" "context" "errors" "fmt" "log/slog" "os" "strings" "golang.org/x/term" "github.com/dariuszjaniak/warp/server/internal/config" "github.com/dariuszjaniak/warp/server/internal/storage" "github.com/dariuszjaniak/warp/server/internal/userapi" ) // runCreateAccount implements `warp create-account`. // // It talks to the database directly rather than to a running server, so the // first account on a fresh install can be made before anything is listening // and without turning registration on. That is the whole point of it: a // private homeserver with registration off still needs a first admin. func runCreateAccount(ctx context.Context, args []string) error { fs := newFlagSet("create-account") loadConfig := addConfigFlag(fs) username := fs.String("username", "", "localpart of the new user, e.g. alice") password := fs.String("password", "", "password (prompted for if omitted)") passwordFile := fs.String("password-file", "", "read the password from this file instead of prompting") displayName := fs.String("display-name", "", "profile display name (default: none)") admin := fs.Bool("admin", false, "give the account the server administrator flag") if err := fs.Parse(args); err != nil { return err } if *username == "" { return errors.New("create-account: --username is required") } if *password != "" && *passwordFile != "" { return errors.New("create-account: pass either --password or --password-file, not both") } cfg, err := loadConfig() if err != nil { return err } log, err := newLogger(cfg) if err != nil { return err } slog.SetDefault(log) secret, err := resolvePassword(*password, *passwordFile) if err != nil { return err } db, err := openDatabase(ctx, cfg, log) if err != nil { return err } defer func() { _ = db.Close() }() // The schema has to be there before an account can be written. Running // the migrations here rather than erroring out means the documented // first-run sequence works whatever order the operator does it in. if err := db.Migrate(ctx); err != nil { return fmt.Errorf("migrate database: %w", err) } users, err := userapi.New(userapi.Options{ ServerName: cfg.Global.ServerName, Store: db.Users(), Logger: log, }) if err != nil { return err } account, err := users.CreateAccount(ctx, userapi.NewAccountRequest{ Localpart: *username, Password: secret, DisplayName: *displayName, Admin: *admin, }) switch { case errors.Is(err, userapi.ErrUserExists): return fmt.Errorf("%s already exists", users.UserID(userapi.NormaliseLocalpart(*username))) case err != nil: return err } fmt.Printf("created %s\n", users.UserID(account.Localpart)) if account.Admin { fmt.Println(" server administrator: yes") } if secret == "" { fmt.Println(" no password set: this account cannot log in until one is") } return nil } // openDatabase opens the storage driver named in the config. func openDatabase(ctx context.Context, cfg *config.Config, log *slog.Logger) (storage.Database, error) { return storage.Open(ctx, storage.Config{ Driver: cfg.Database.Driver, ConnectionString: cfg.Database.ConnectionString, MaxOpenConns: cfg.Database.MaxOpenConns, MaxIdleConns: cfg.Database.MaxIdleConns, ConnMaxLifetime: cfg.Database.ConnMaxLifetime, }, log) } // resolvePassword settles where the password comes from. // // An interactive prompt is the default because a password on the command // line is a password in the shell history and in the process list of every // other user on the machine. --password is kept for scripts that have // already dealt with that, and --password-file for the ones that have not. func resolvePassword(flagValue, file string) (string, error) { if flagValue != "" { return flagValue, nil } if file != "" { data, err := os.ReadFile(file) if err != nil { return "", fmt.Errorf("read password file: %w", err) } secret := strings.TrimRight(string(data), "\r\n") if secret == "" { return "", fmt.Errorf("password file %s is empty", file) } return secret, nil } return promptForPassword() } // promptForPassword reads a password twice without echoing it. // // When stdin is not a terminal — a pipe in a container entrypoint, say — // one line is read instead, with no confirmation, because there is nobody // there to mistype it. func promptForPassword() (string, error) { if !term.IsTerminal(int(os.Stdin.Fd())) { line, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil && line == "" { return "", fmt.Errorf("read password from standard input: %w", err) } secret := strings.TrimRight(line, "\r\n") if secret == "" { return "", errors.New("no password given on standard input") } return secret, nil } fmt.Print("Password: ") first, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", fmt.Errorf("read password: %w", err) } fmt.Print("Confirm: ") second, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err != nil { return "", fmt.Errorf("read password: %w", err) } if string(first) != string(second) { return "", errors.New("the passwords do not match") } if len(first) == 0 { return "", errors.New("the password must not be empty") } return string(first), nil }