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.
375 lines
8.4 KiB
Go
375 lines
8.4 KiB
Go
// Package database provides SQLite persistence for webhooks, events, and users.
|
|
package database
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"go.uber.org/fx"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
dataDirPerm = 0750
|
|
randomPasswordLen = 16
|
|
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
|
|
|
|
Config *config.Config
|
|
Logger *logger.Logger
|
|
}
|
|
|
|
// Database manages the main SQLite connection and schema migrations.
|
|
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.
|
|
func New(
|
|
lc fx.Lifecycle,
|
|
params DatabaseParams,
|
|
) (*Database, error) {
|
|
d := &Database{
|
|
params: ¶ms,
|
|
log: params.Logger.Get(),
|
|
}
|
|
|
|
lc.Append(fx.Hook{
|
|
OnStart: func(_ context.Context) error {
|
|
return d.connect()
|
|
},
|
|
OnStop: func(_ context.Context) error {
|
|
return d.close()
|
|
},
|
|
})
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// DB returns the underlying GORM database handle.
|
|
func (d *Database) DB() *gorm.DB {
|
|
return d.db
|
|
}
|
|
|
|
// GetOrCreateSessionKey retrieves the session encryption key from the
|
|
// settings table. If no key exists, a cryptographically secure random
|
|
// 32-byte key is generated, base64-encoded, and stored for future use.
|
|
func (d *Database) GetOrCreateSessionKey() (string, error) {
|
|
var setting Setting
|
|
|
|
result := d.db.Where(
|
|
&Setting{Key: "session_key"},
|
|
).First(&setting)
|
|
if result.Error == nil {
|
|
return setting.Value, nil
|
|
}
|
|
|
|
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
return "", fmt.Errorf(
|
|
"failed to query session key: %w",
|
|
result.Error,
|
|
)
|
|
}
|
|
|
|
// Generate a new cryptographically secure 32-byte key
|
|
keyBytes := make([]byte, sessionKeyLen)
|
|
|
|
_, err := rand.Read(keyBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to generate session key: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(keyBytes)
|
|
|
|
setting = Setting{
|
|
Key: "session_key",
|
|
Value: encoded,
|
|
}
|
|
|
|
err = d.db.Create(&setting).Error
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to store session key: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
d.log.Info(
|
|
"generated new session key and stored in database",
|
|
)
|
|
|
|
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 {
|
|
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(
|
|
"creating data directory %s: %w",
|
|
dataDir,
|
|
err,
|
|
)
|
|
}
|
|
|
|
// Construct the main application database path inside DATA_DIR.
|
|
dbPath := filepath.Join(dataDir, MainDBFileName)
|
|
dbURL := fmt.Sprintf(
|
|
"file:%s?cache=shared&mode=rwc",
|
|
dbPath,
|
|
)
|
|
|
|
// Open the database with the pure Go SQLite driver
|
|
sqlDB, err := sql.Open("sqlite", dbURL)
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to open database",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// Then use it with GORM
|
|
db, err := gorm.Open(sqlite.Dialector{
|
|
Conn: sqlDB,
|
|
}, &gorm.Config{
|
|
// Never leave this at GORM's default. See internal/gormlog.
|
|
Logger: gormlog.New(d.log),
|
|
})
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to connect to database",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
d.db = db
|
|
d.log.Info("connected to database", "path", dbPath)
|
|
|
|
// Run migrations
|
|
return d.migrate()
|
|
}
|
|
|
|
func (d *Database) migrate() error {
|
|
// Run GORM auto-migrations
|
|
err := d.Migrate()
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to run database migrations",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
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
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to count users",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
if userCount == 0 {
|
|
return d.createAdminUser()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Database) createAdminUser() error {
|
|
d.log.Info("no users found, creating admin user")
|
|
|
|
// Generate random password
|
|
password, err := GenerateRandomPassword(
|
|
randomPasswordLen,
|
|
)
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to generate random password",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// Hash the password
|
|
hashedPassword, err := HashPassword(password)
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to hash password",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// Create admin user
|
|
adminUser := &User{
|
|
Username: "admin",
|
|
Password: hashedPassword,
|
|
}
|
|
|
|
err = d.db.Create(adminUser).Error
|
|
if err != nil {
|
|
d.log.Error(
|
|
"failed to create admin user",
|
|
"error", err,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// 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()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sqlDB.Close()
|
|
}
|
|
|
|
return nil
|
|
}
|