// 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 }