Add a webhooker resetpw subcommand and a bootstrap banner (closes #208)
Some checks failed
check / check (push) Failing after 2m32s
Some checks failed
check / check (push) Failing after 2m32s
The bootstrap admin password was shown exactly once, as one INFO record among the roughly 45 fx lines a boot writes, and there was no reset path at all: no subcommand, no forgot-password flow, no override. Losing that line meant deleting the users row from webhooker.db by hand so the next start would re-seed. - internal/banner renders the one credential shown in the clear as a ruled block written straight to standard output, so it does not read as one more log line. The first boot emits the password there and nowhere else, and the banner names the recovery command. - `webhooker resetpw [-generate] <username>` sets an existing account's password. It reads the password as one line from standard input, or generates one with crypto/rand via the existing GenerateRandomPassword; it is never an argv value, which /proc would publish to every account on the host. Hashing goes through database.HashPassword, so the Argon2id parameters cannot drift. - It refuses to run against a DATA_DIR a live instance holds, by taking the same exclusive flock internal/datadir gives the server, and releases it when it finishes. - It creates nothing. A missing DATA_DIR, a directory with no webhooker.db, and an unknown username are each an error: datadir .Acquire calls os.MkdirAll, so a mistyped path would otherwise be built out and reported as a success. The existence checks therefore run before the lock is taken. - The account is resolved and the hash computed in full before the single UPDATE that stores it, so any failure leaves the stored credential untouched. - database.Open exposes the connect-and-migrate path without fx and without seeding; seeding moves to ensureAdminUser, which only a server start calls. - main gains subcommand dispatch. No arguments still runs the server on the same path, with the DATA_DIR lock taken before the fx graph is built and fx owning the non-zero exit; an unknown subcommand exits 2 rather than starting a server. Tests: reset then log in through the real form POST handler, the generated password verifying against the stored hash, the refusal against a held lock, both create-nothing cases, the unknown user, the unusable passwords, and the first-boot banner carrying a password that opens the account. README documents the bootstrap banner and the recovery command, including the container invocation and what resetpw will not do.
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user