All checks were successful
check / check (push) Successful in 3m25s
The admin bootstrap password was printed once, as one line among roughly 45 fx lines, and under docker run -d went to container logs subject to rotation. There was no reset path at all -- no subcommand, no forgot-password flow, no env override -- so recovery meant hand-deleting the users row from webhooker.db, which was documented nowhere. Adds webhooker resetpw [-generate] <username>. The password is read from stdin or generated with the existing crypto/rand helper, never taken from argv where /proc would publish it. It reuses the existing Argon2id hashing rather than reimplementing the parameters, and writes a single UPDATE only after the hash is complete, so no failure can leave an account with no usable password. An unknown username is a hard error and never creates an account. It refuses to run against a DATA_DIR held by a live instance, via the exclusive lock from #201. DATA_DIR and webhooker.db are checked to exist before the lock is acquired, so a mistyped path creates nothing -- neither a directory tree nor a stray lock file. The bootstrap password now appears exactly once, in a distinct banner written straight to a caller-named writer rather than as an fx log line.
473 lines
13 KiB
Go
473 lines
13 KiB
Go
// 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] <username>
|
|
|
|
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,
|
|
}))
|
|
}
|