Add a webhooker resetpw subcommand and a bootstrap banner (closes #208) (#239)
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.
This commit was merged in pull request #239.
This commit is contained in:
2026-08-20 08:01:42 +02:00
parent fcead5d401
commit 9969694a47
10 changed files with 1431 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
@@ -16,6 +17,7 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite" // Pure Go SQLite driver
"sneak.berlin/go/webhooker/internal/banner"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger"
@@ -27,6 +29,20 @@ const (
sessionKeyLen = 32
)
// MainDBFileName is the main application database inside DATA_DIR. It
// is exported so that an entry point acting on a data directory
// outside the fx graph can test for a deployment's existence without
// spelling the name a second time.
const MainDBFileName = "webhooker.db"
// BootstrapPasswordNote is what the first-boot banner tells the
// operator to do about the password it just printed. It names the
// recovery command, because the moment that line scrolls away is
// exactly when the operator needs to know one exists.
const BootstrapPasswordNote = "Save this password now: it is shown " +
"only here, and only once.\nIf it is lost, run `webhooker " +
"resetpw admin` on a stopped deployment."
//nolint:revive // DatabaseParams is a standard fx naming convention.
type DatabaseParams struct {
fx.In
@@ -40,6 +56,39 @@ type Database struct {
db *gorm.DB
log *slog.Logger
params *DatabaseParams
// bannerOut receives the first-boot credentials banner. Nil means
// os.Stdout, resolved at write time rather than at construction so
// that a caller which redirects the variable still captures it.
bannerOut io.Writer
}
// Open connects to the main database in dataDir and migrates it,
// without the fx lifecycle and without seeding an admin account.
//
// It is for entry points that act on an existing deployment's data
// directory from outside the server graph — `webhooker resetpw`. Such a
// caller must already hold the DATA_DIR lock (see internal/datadir),
// and must Close the result.
//
// It does not create the admin account: seeding belongs to a server
// start, and a maintenance command that silently invented an account
// would answer "no such user" by creating one.
func Open(dataDir string, log *slog.Logger) (*Database, error) {
d := &Database{log: log}
err := d.connectTo(dataDir)
if err != nil {
return nil, err
}
return d, nil
}
// Close closes the underlying connection. It is the exported form of
// the fx stop hook, for callers that built the Database with Open.
func (d *Database) Close() error {
return d.close()
}
// New creates a Database that connects on fx start and disconnects on stop.
@@ -122,10 +171,22 @@ func (d *Database) GetOrCreateSessionKey() (string, error) {
return encoded, nil
}
// connect opens the configured data directory and, this being a
// server start, seeds the admin account when the deployment has none.
func (d *Database) connect() error {
// Ensure the data directory exists before opening the database.
dataDir := d.params.Config.DataDir
err := d.connectTo(d.params.Config.DataDir)
if err != nil {
return err
}
return d.ensureAdminUser()
}
// connectTo opens and migrates the main database in dataDir. It seeds
// nothing: whether an empty deployment gets an admin account is the
// caller's decision.
func (d *Database) connectTo(dataDir string) error {
// Ensure the data directory exists before opening the database.
err := os.MkdirAll(dataDir, dataDirPerm)
if err != nil {
return fmt.Errorf(
@@ -136,7 +197,7 @@ func (d *Database) connect() error {
}
// Construct the main application database path inside DATA_DIR.
dbPath := filepath.Join(dataDir, "webhooker.db")
dbPath := filepath.Join(dataDir, MainDBFileName)
dbURL := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc",
dbPath,
@@ -190,10 +251,16 @@ func (d *Database) migrate() error {
d.log.Info("database migrations completed")
return nil
}
// ensureAdminUser creates the bootstrap admin account when the
// deployment has no users at all.
func (d *Database) ensureAdminUser() error {
// Check if admin user exists
var userCount int64
err = d.db.Model(&User{}).Count(&userCount).Error
err := d.db.Model(&User{}).Count(&userCount).Error
if err != nil {
d.log.Error(
"failed to count users",
@@ -253,16 +320,46 @@ func (d *Database) createAdminUser() error {
return err
}
d.log.Info("admin user created",
"username", "admin",
"password", password,
"message",
"SAVE THIS PASSWORD - it will not be shown again!",
// The plaintext leaves this process here and nowhere else. It is
// deliberately not a log field: as one INFO record among the fx
// graph's own output it read as one more startup line, which is
// how deployments lost it. See internal/banner.
err = banner.Credentials(
d.banner(),
"WEBHOOKER FIRST BOOT: an admin account has been created.",
adminUser.Username,
password,
BootstrapPasswordNote,
)
if err != nil {
// Fail the start. The account is already committed, so the
// next boot seeds nothing and prints nothing: continuing here
// would hand the operator a running service whose only
// password was never shown. `webhooker resetpw` recovers it.
d.log.Error(
"failed to print the admin credentials banner",
"error", err,
)
return err
}
d.log.Info("admin user created", "username", adminUser.Username)
return nil
}
// banner returns where the credentials banner is written. os.Stdout is
// resolved here rather than stored, so that a test which redirects the
// variable captures the banner.
func (d *Database) banner() io.Writer {
if d.bannerOut != nil {
return d.bannerOut
}
return os.Stdout
}
func (d *Database) close() error {
if d.db != nil {
sqlDB, err := d.db.DB()