Files
gomail/internal/db/bootstrap.go
T
2026-08-09 18:03:09 +01:00

97 lines
3.2 KiB
Go

package db
import (
"fmt"
"log/slog"
"gomail/internal/crypto"
"gomail/internal/dkim"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// Bootstrap creates an initial tenant, domain, and global admin user on first
// run — detected by the absence of any global_admin row. Safe to call on
// every startup; it's a no-op once bootstrapped.
func (db *DB) Bootstrap(hostname, initPassword string, bcryptCost int, mk *crypto.MasterKey) error {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'global_admin'").Scan(&count)
if err != nil {
return fmt.Errorf("checking existing admins: %w", err)
}
if count > 0 {
return nil // already bootstrapped
}
if initPassword == "" {
initPassword = "ChangeMe123!"
slog.Warn("no admin exists and GOMAIL_ADMIN_INIT_PASSWORD not set — using default, CHANGE IMMEDIATELY",
"password", initPassword, "email", "admin@"+hostname)
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
tenantID := uuid.NewString()
if _, err := tx.Exec(
`INSERT INTO tenants (id, name, display_name) VALUES (?, ?, ?)`,
tenantID, "default", "Default Tenant",
); err != nil {
return fmt.Errorf("creating default tenant: %w", err)
}
domainID := uuid.NewString()
dkimSelector := "mail"
// Generate a DKIM key pair so outbound mail can be signed immediately —
// without this, every message this instance sends would be unsigned
// until an admin manually configures one (Phase 8's admin portal will
// add key rotation/regeneration; this just ensures a working default).
var dkimKeyEnc []byte
kp, kpErr := dkim.GenerateKeyPair()
if kpErr != nil {
slog.Warn("failed to generate DKIM key during bootstrap — outbound mail will be unsigned until one is configured", "err", kpErr)
} else {
encrypted, encErr := crypto.Encrypt(mk, domainID, "dkim-key", kp.PrivateKeyPEM)
if encErr != nil {
slog.Warn("failed to encrypt DKIM key during bootstrap", "err", encErr)
} else {
dkimKeyEnc = encrypted
slog.Info("DKIM key generated for default domain — publish this DNS TXT record",
"record_name", dkimSelector+"._domainkey."+hostname,
"record_value", kp.DNSRecordValue)
}
}
if _, err := tx.Exec(
`INSERT INTO domains (id, tenant_id, domain, active, accept_all, dkim_selector, dkim_private_key_enc) VALUES (?, ?, ?, 1, 1, ?, ?)`,
domainID, tenantID, hostname, dkimSelector, dkimKeyEnc,
); err != nil {
return fmt.Errorf("creating default domain: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(initPassword), bcryptCost)
if err != nil {
return fmt.Errorf("hashing admin password: %w", err)
}
adminEmail := "admin@" + hostname
if _, err := tx.Exec(
`INSERT INTO users (id, tenant_id, domain_id, email, password_hash, display_name, role, active)
VALUES (?, ?, ?, ?, ?, ?, 'global_admin', 1)`,
uuid.NewString(), tenantID, domainID, adminEmail, string(hash), "Global Admin",
); err != nil {
return fmt.Errorf("creating admin user: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing bootstrap: %w", err)
}
slog.Info("bootstrap complete", "admin_email", adminEmail, "tenant", "default", "domain", hostname)
return nil
}