// Package resetpw implements the `webhooker resetpw` subcommand, // which sets an existing account's password from the command line. // // It exists because the bootstrap password is shown exactly once. If it // is lost — the boot's output rotated away, the terminal closed — the // deployment has no other way in: there is no second account, no // forgot-password flow, and no environment override. The only recovery // before this command was deleting the row from webhooker.db with a // SQLite client so the next start would re-seed. // // It operates on a stopped deployment only. The password is read from // standard input or generated, never taken from argv, and it reuses the // service's own Argon2id hashing rather than reimplementing it. package resetpw import ( "bufio" "errors" "flag" "fmt" "io" "io/fs" "log/slog" "os" "path/filepath" "strings" "gorm.io/gorm" "sneak.berlin/go/webhooker/internal/banner" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/datadir" ) // Name is the subcommand's name on the command line. const Name = "resetpw" const ( // generatedPasswordLen is the length of a -generate password. It // is longer than the 16 characters the first boot generates: this // one is typed or pasted once by an operator recovering a // deployment, not carried around. generatedPasswordLen = 24 // minPasswordLen is the shortest password accepted on standard // input. It is a floor against a stray keystroke or a truncated // pipe silently becoming the account's credential, not a password // policy. minPasswordLen = 8 ) // Exit statuses. Usage errors are distinguished from operational ones // so that a script can tell "you called it wrong" from "it refused". const ( exitOK = 0 exitFailure = 1 exitUsage = 2 ) // Sentinel errors, exported so a test can assert on the refusal rather // than on the wording of a message. var ( // ErrNoDataDir reports that DATA_DIR names nothing, or names // something that is not a directory. ErrNoDataDir = errors.New("no data directory") // ErrNoDatabase reports that the data directory holds no main // database, so there is no deployment to reset a password in. ErrNoDatabase = errors.New("no webhooker database") // ErrLiveInstance reports that a running webhooker holds the data // directory. ErrLiveInstance = errors.New( "data directory is held by a running webhooker", ) // ErrNoSuchUser reports that the named account does not exist. ErrNoSuchUser = errors.New("no such user") // ErrEmptyPassword reports that standard input carried nothing. ErrEmptyPassword = errors.New("empty password") // ErrPasswordTooShort reports a password below minPasswordLen. ErrPasswordTooShort = errors.New("password too short") // ErrNotUpdated reports that the update matched no row, which // means the account disappeared between the lookup and the write. ErrNotUpdated = errors.New("password was not updated") ) // usage describes the subcommand. It is written to the same stream as // the error that provoked it. func usage(w io.Writer, flags *flag.FlagSet) { _, _ = fmt.Fprintf(w, `usage: webhooker %s [-generate] Set an existing account's password. The deployment must be stopped: webhooker %s takes the same exclusive DATA_DIR lock the server does and refuses to run while a live instance holds it. The password is read as one line from standard input, or generated with -generate. It is never taken as a command-line argument, which on Linux would publish it in /proc to every account on the host. DATA_DIR selects the deployment exactly as it does for the server (default %s). The directory and its database must already exist; nothing is created. Flags: `, Name, Name, config.DefaultDataDir) flags.PrintDefaults() } // Run executes the subcommand and returns the process exit status. func Run( args []string, stdin io.Reader, stdout, stderr io.Writer, ) int { flags := flag.NewFlagSet("webhooker "+Name, flag.ContinueOnError) flags.SetOutput(stderr) generate := flags.Bool( "generate", false, "generate a random password instead of reading one from "+ "standard input, and print it", ) flags.Usage = func() { usage(stderr, flags) } err := flags.Parse(args) if err != nil { // flag has already reported the error and printed the usage. return exitUsage } if flags.NArg() != 1 { _, _ = fmt.Fprintf( stderr, "webhooker %s: exactly one username is required\n", Name, ) usage(stderr, flags) return exitUsage } err = reset(flags.Arg(0), *generate, stdin, stdout, stderr) if err != nil { _, _ = fmt.Fprintf(stderr, "webhooker %s: %v\n", Name, err) return exitFailure } return exitOK } // reset performs the whole operation against the configured data // directory. func reset( username string, generate bool, stdin io.Reader, stdout, stderr io.Writer, ) error { dir := config.DataDir() err := checkDataDir(dir) if err != nil { return err } lock, err := acquire(dir) if err != nil { return err } // The kernel drops the lock when this process exits, whatever // happens below; releasing explicitly is what makes a long-running // caller — a test — see it freed. A release error tells the // operator nothing they can act on. defer func() { _ = lock.Release() }() db, err := database.Open(dir, cliLogger(stderr)) if err != nil { return err } password, err := setPassword(db, username, generate, stdin, stderr) closeErr := db.Close() if err != nil { return err } if closeErr != nil { return fmt.Errorf("closing the database: %w", closeErr) } return report(stdout, stderr, dir, username, password, generate) } // report tells the operator what happened. // // A generated password is printed in the same banner the first boot // uses, because this is the only time it is ever shown. A password the // operator supplied is not echoed back: they already have it, and // writing it to standard output a second time would put it in another // log for no gain. func report( stdout, stderr io.Writer, dir, username, password string, generate bool, ) error { if generate { write := func(w io.Writer) error { return banner.Credentials( w, "WEBHOOKER PASSWORD RESET: this account's new "+ "password is", username, password, "Save this password now: it is shown only here.\n"+ "The database stores only its Argon2id hash.", ) } err := write(stdout) if err == nil { return nil } // The password is already stored. Standard output failing // here is the difference between a recovered deployment and // one locked out behind a password nobody has ever seen, so // try the other stream before giving up. if write(stderr) == nil { return nil } return err } _, err := fmt.Fprintf( stdout, "password updated for user %q in %s\n", username, dir, ) if err != nil { return fmt.Errorf("writing the result: %w", err) } return nil } // acquire takes the DATA_DIR lock, translating the contended case into // the refusal this command owes the operator. // // Resetting a password underneath a live instance would not corrupt // anything, but the running process keeps serving every session that // authenticated with the old one, so the operator would be told the // password changed while the deployment still behaved as though it had // not. Refusing is also what the lock is for. func acquire(dir string) (*datadir.Lock, error) { lock, err := datadir.Acquire(dir) if err == nil { return lock, nil } if errors.Is(err, datadir.ErrLocked) { return nil, fmt.Errorf( "%w: %s. Stop it and run this again: a password reset "+ "does not reach a running process, whose existing "+ "sessions stay authenticated", ErrLiveInstance, dir, ) } return nil, err } // checkDataDir refuses to act on a path that does not already hold a // deployment. // // This runs before datadir.Acquire on purpose. Acquire calls // os.MkdirAll, so a mistyped DATA_DIR would otherwise be built out — // the directory tree, the lock file, and then an empty migrated // database — and the command would report success against a deployment // that does not exist while the real one stayed locked out. Nothing // here creates anything. func checkDataDir(dir string) error { info, err := os.Stat(dir) switch { case errors.Is(err, fs.ErrNotExist): return fmt.Errorf( "%w: %s (DATA_DIR). This acts on an existing "+ "deployment and creates nothing", ErrNoDataDir, dir, ) case err != nil: return fmt.Errorf("checking data directory %s: %w", dir, err) case !info.IsDir(): return fmt.Errorf( "%w: %s (DATA_DIR) is not a directory", ErrNoDataDir, dir, ) } dbPath := filepath.Join(dir, database.MainDBFileName) _, err = os.Stat(dbPath) switch { case errors.Is(err, fs.ErrNotExist): return fmt.Errorf( "%w: %s does not exist. The admin account is created by "+ "the first server start", ErrNoDatabase, dbPath, ) case err != nil: return fmt.Errorf("checking %s: %w", dbPath, err) } return nil } // setPassword looks the account up, obtains the new password, and // writes its hash. // // The order matters: the account is resolved before an operator is // asked to type anything, and the hash is computed in full before the // single UPDATE that stores it. A failure at any step therefore leaves // the stored credential exactly as it was — there is no half-written // state to recover from. func setPassword( db *database.Database, username string, generate bool, stdin io.Reader, stderr io.Writer, ) (string, error) { var user database.User err := db.DB().Where("username = ?", username).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return "", fmt.Errorf( "%w: %q. This changes an existing account's password "+ "and never creates an account", ErrNoSuchUser, username, ) } if err != nil { return "", fmt.Errorf("looking up user %q: %w", username, err) } password, err := newPassword(generate, stdin, stderr) if err != nil { return "", err } hash, err := database.HashPassword(password) if err != nil { return "", fmt.Errorf("hashing the new password: %w", err) } result := db.DB().Model(&database.User{}). Where("id = ?", user.ID). Update("password", hash) if result.Error != nil { return "", fmt.Errorf( "updating user %q: %w", username, result.Error, ) } if result.RowsAffected != 1 { return "", fmt.Errorf( "%w: %q matched %d rows", ErrNotUpdated, username, result.RowsAffected, ) } return password, nil } // newPassword returns the password to store: generated, or read from // standard input. func newPassword( generate bool, stdin io.Reader, stderr io.Writer, ) (string, error) { if generate { password, err := database.GenerateRandomPassword( generatedPasswordLen, ) if err != nil { return "", fmt.Errorf("generating a password: %w", err) } return password, nil } return readPassword(stdin, stderr) } // readPassword reads the new password as one line from standard input, // minus its line ending. // // Standard input rather than an argument: on Linux argv is readable // through /proc by every account on the host for as long as the process // lives, and a password typed as an argument lands in shell history // besides. // // When standard input is a terminal the input is echoed — no attempt is // made to put the terminal into no-echo mode — so the prompt says so // rather than letting an operator assume otherwise. func readPassword(stdin io.Reader, stderr io.Writer) (string, error) { if f, ok := stdin.(*os.File); ok && isTerminal(f) { _, _ = fmt.Fprintf( stderr, "New password (echoed as you type), then Enter: ", ) } line, err := bufio.NewReader(stdin).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return "", fmt.Errorf( "reading the password from standard input: %w", err, ) } password := strings.TrimRight(line, "\r\n") if password == "" { return "", fmt.Errorf( "%w: standard input carried no password. Pipe one in, "+ "or pass -generate", ErrEmptyPassword, ) } if len(password) < minPasswordLen { return "", fmt.Errorf( "%w: %d bytes, minimum %d", ErrPasswordTooShort, len(password), minPasswordLen, ) } return password, nil } // isTerminal reports whether f is a character device, which is as much // as this needs to know to decide whether to prompt. func isTerminal(f *os.File) bool { info, err := f.Stat() if err != nil { return false } return info.Mode()&os.ModeCharDevice != 0 } // cliLogger builds the logger the database layer writes through while // this command runs. It is deliberately quiet: connecting and migrating // are steps the operator did not ask about, and the one thing they need // to see is the outcome on standard output. func cliLogger(stderr io.Writer) *slog.Logger { return slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{ Level: slog.LevelWarn, })) }