Files
GoMFT/cmd/gomftctl/main.go
T

773 lines
24 KiB
Go

package main
import (
"context"
"encoding/base64"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/starfleetcptn/gomft/internal/config"
"github.com/starfleetcptn/gomft/internal/db"
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/keyrotation"
"golang.org/x/crypto/bcrypt"
)
func main() {
// Create root command
rootCmd := &cobra.Command{
Use: "gomftctl",
Short: "GoMFT Control Tool - Command line utilities for GoMFT",
Long: `GoMFT Control Tool (gomftctl) provides command line utilities for managing
your GoMFT installation, including database migrations, security key rotation,
and other administrative functions.`,
}
// Add commands
rootCmd.AddCommand(createMigrateCmd())
rootCmd.AddCommand(createKeyRotationCmd())
rootCmd.AddCommand(createVersionCmd())
rootCmd.AddCommand(createBackupCmd())
rootCmd.AddCommand(createUserCmd())
rootCmd.AddCommand(createEncryptionKeyRotationCmd())
// Execute the root command
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
// createMigrateCmd creates the migrate command for provider data migration
func createMigrateCmd() *cobra.Command {
var dryRun, validationOnly, force, debugMode, autoFill bool
var backupDir string
migrateCmd := &cobra.Command{
Use: "migrate-providers",
Short: "Migrate provider data to the new storage provider model",
Long: `Migrate provider data extracts unique provider configurations from existing
transfer configs and creates dedicated storage provider records.
This command should be run when upgrading from older versions of GoMFT that
stored provider configuration directly in transfer configs.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set backup directory if not provided
if backupDir == "" {
backupDir = cfg.BackupDir
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create migration options
options := db.MigrateProviderDataOptions{
DryRun: dryRun,
ValidationOnly: validationOnly,
Force: force,
BackupDir: backupDir,
DebugMode: debugMode,
AutoFill: autoFill,
}
// Run migration
fmt.Println("Starting provider data migration...")
stats, err := database.MigrateProviderData(options)
if err != nil {
fmt.Println("\nMigration failed with error:")
fmt.Printf("Error: %v\n", err)
// Add more detailed error information
fmt.Println("\nDetailed error information:")
fmt.Println("===========================")
// Unwrap nested errors if possible
var currentErr error = err
depth := 1
for currentErr != nil {
fmt.Printf("%d. %v\n", depth, currentErr)
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
currentErr = unwrapped.Unwrap()
depth++
} else {
break
}
}
// Print database connection information (without sensitive details)
fmt.Println("\nDatabase information:")
fmt.Printf("- Database path: %s\n", dbPath)
fmt.Printf("- Migration options: dryRun=%v, validationOnly=%v, force=%v\n",
options.DryRun, options.ValidationOnly, options.Force)
log.Fatalf("Migration failed. See details above.")
}
// Print report
fmt.Println(db.FormatMigrationReport(stats))
},
}
// Add flags
migrateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate migration without making changes")
migrateCmd.Flags().BoolVar(&validationOnly, "validate-only", false, "Only validate if migration is possible without making changes")
migrateCmd.Flags().BoolVar(&force, "force", false, "Force migration even if validation fails")
migrateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
migrateCmd.Flags().BoolVar(&debugMode, "debug", false, "Enable debug mode with more detailed error messages")
migrateCmd.Flags().BoolVar(&autoFill, "auto-fill", false, "Automatically fill missing required fields with placeholder values")
return migrateCmd
}
// createKeyRotationCmd creates the key rotation command
func createKeyRotationCmd() *cobra.Command {
var keyType string
var writeToEnv bool
keyRotationCmd := &cobra.Command{
Use: "rotate-key",
Short: "Rotate security keys used by GoMFT",
Long: `Rotate security keys generates new cryptographic keys for GoMFT.
Available key types:
- jwt: JSON Web Token signing key
- totp: TOTP encryption key
- encryption: General encryption key used for sensitive data
This command will generate a new key and provide instructions for updating
your configuration. The application must be restarted for changes to take effect.`,
Run: func(cmd *cobra.Command, args []string) {
// Validate key type
validTypes := map[string]string{
"jwt": "JWT_SECRET",
"totp": "TOTP_ENCRYPTION_KEY",
"encryption": "GOMFT_ENCRYPTION_KEY",
}
envVar, valid := validTypes[keyType]
if !valid {
log.Fatalf("Invalid key type: %s. Valid types are: jwt, totp, encryption", keyType)
}
// Load configuration
_, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Generate a new key
newKey, err := generateSecureKey()
if err != nil {
log.Fatalf("Failed to generate secure key: %v", err)
}
fmt.Printf("Generated new %s key: %s\n\n", keyType, newKey)
if writeToEnv {
// Read current .env file
envPath := ".env"
envContent, err := os.ReadFile(envPath)
if err != nil {
log.Fatalf("Failed to read .env file: %v", err)
}
// Update .env file with new key
updatedEnv, updated := updateEnvVar(string(envContent), envVar, newKey)
if !updated {
// If the variable wasn't found, append it
updatedEnv = updatedEnv + fmt.Sprintf("\n%s=%s\n", envVar, newKey)
}
// Write updated content back to .env file
if err := os.WriteFile(envPath, []byte(updatedEnv), 0644); err != nil {
log.Fatalf("Failed to write updated .env file: %v", err)
}
fmt.Printf("Updated %s in .env file\n", envVar)
fmt.Println("Please restart the GoMFT application for changes to take effect.")
} else {
// Print instructions for manual update
fmt.Println("To use this key, update your .env file with:")
fmt.Printf("%s=%s\n\n", envVar, newKey)
fmt.Println("Then restart the GoMFT application for changes to take effect.")
}
},
}
// Add flags
keyRotationCmd.Flags().StringVar(&keyType, "type", "", "Type of key to rotate (jwt, totp, encryption)")
keyRotationCmd.Flags().BoolVar(&writeToEnv, "write", false, "Write the new key directly to .env file")
keyRotationCmd.MarkFlagRequired("type")
return keyRotationCmd
}
// createVersionCmd creates the version command
func createVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Display version information",
Run: func(cmd *cobra.Command, args []string) {
// Import the version from the components package
fmt.Println("GoMFT Control Tool")
fmt.Println("Version: Same as GoMFT application")
fmt.Println("Visit https://github.com/starfleetcptn/gomft for more information")
},
}
}
// createBackupCmd creates the backup command
func createBackupCmd() *cobra.Command {
var outputDir string
backupCmd := &cobra.Command{
Use: "backup",
Short: "Create a backup of the GoMFT database",
Long: `Create a backup of the GoMFT database and configuration.
The backup includes the SQLite database file and the .env configuration file.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set output directory if not provided
if outputDir == "" {
outputDir = cfg.BackupDir
}
// Ensure output directory exists
if err := os.MkdirAll(outputDir, 0755); err != nil {
log.Fatalf("Failed to create backup directory: %v", err)
}
// Create timestamp for backup filename
timestamp := fmt.Sprintf("%s", filepath.Base(os.Args[0]))
// Create backup
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
backupPath := filepath.Join(outputDir, fmt.Sprintf("gomft-backup-%s.db", timestamp))
// Copy database file
if err := copyFile(dbPath, backupPath); err != nil {
log.Fatalf("Failed to create database backup: %v", err)
}
// Copy .env file if it exists
envPath := ".env"
backupEnvPath := filepath.Join(outputDir, fmt.Sprintf("gomft-env-backup-%s.env", timestamp))
if _, err := os.Stat(envPath); err == nil {
if err := copyFile(envPath, backupEnvPath); err != nil {
log.Fatalf("Failed to backup .env file: %v", err)
}
fmt.Printf("Configuration backed up to: %s\n", backupEnvPath)
}
fmt.Printf("Database backed up to: %s\n", backupPath)
},
}
// Add flags
backupCmd.Flags().StringVar(&outputDir, "output-dir", "", "Directory to store backup files (defaults to config backup_dir)")
return backupCmd
}
// createUserCmd creates the user management command
func createUserCmd() *cobra.Command {
userCmd := &cobra.Command{
Use: "user",
Short: "User management commands",
Long: `Commands for managing GoMFT users, including creating, updating, and listing users.`,
}
// Add subcommands
userCmd.AddCommand(createUserCreateCmd())
userCmd.AddCommand(createUserResetPasswordCmd())
userCmd.AddCommand(createUserListCmd())
return userCmd
}
// createUserCreateCmd creates the user create command
func createUserCreateCmd() *cobra.Command {
var email, password string
var isAdmin bool
createCmd := &cobra.Command{
Use: "create",
Short: "Create a new user",
Long: `Create a new user with the specified email and password.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Create user by first generating password hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
// Create user object
user := &db.User{
Email: email,
PasswordHash: string(hashedPassword),
LastPasswordChange: time.Now(),
}
// Set admin status if requested
if isAdmin {
user.SetIsAdmin(true)
}
// Save user to database
if err := database.CreateUser(user); err != nil {
log.Fatalf("Failed to create user: %v", err)
}
fmt.Printf("User created successfully:\n")
fmt.Printf(" ID: %d\n", user.ID)
fmt.Printf(" Email: %s\n", user.Email)
fmt.Printf(" Admin: %t\n", user.GetIsAdmin())
},
}
// Add flags
createCmd.Flags().StringVar(&email, "email", "", "User email address")
createCmd.Flags().StringVar(&password, "password", "", "User password")
createCmd.Flags().BoolVar(&isAdmin, "admin", false, "Grant admin privileges to the user")
createCmd.MarkFlagRequired("email")
createCmd.MarkFlagRequired("password")
return createCmd
}
// createUserResetPasswordCmd creates the user reset-password command
func createUserResetPasswordCmd() *cobra.Command {
var email, newPassword string
resetCmd := &cobra.Command{
Use: "reset-password",
Short: "Reset a user's password",
Long: `Reset the password for a user with the specified email address.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Find user by email
var user db.User
if err := database.Where("email = ?", email).First(&user).Error; err != nil {
log.Fatalf("Failed to find user with email %s: %v", email, err)
}
// Generate new password hash
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("Failed to hash password: %v", err)
}
// Update user password
user.PasswordHash = string(hashedPassword)
user.LastPasswordChange = time.Now()
// Save user to database
if err := database.Save(&user).Error; err != nil {
log.Fatalf("Failed to update user: %v", err)
}
fmt.Printf("Password reset successfully for user: %s\n", email)
},
}
// Add flags
resetCmd.Flags().StringVar(&email, "email", "", "User email address")
resetCmd.Flags().StringVar(&newPassword, "password", "", "New password")
resetCmd.MarkFlagRequired("email")
resetCmd.MarkFlagRequired("password")
return resetCmd
}
// createUserListCmd creates the user list command
func createUserListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all users",
Long: `List all users in the GoMFT system.`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Get all users
var users []db.User
if err := database.Find(&users).Error; err != nil {
log.Fatalf("Failed to get users: %v", err)
}
// Print users
fmt.Println("GoMFT Users:")
fmt.Println("ID\tEmail\tAdmin\tLast Updated")
fmt.Println("--------------------------------------------------")
for _, user := range users {
lastUpdated := "Never"
if !user.UpdatedAt.IsZero() {
lastUpdated = user.UpdatedAt.Format("2006-01-02 15:04:05")
}
fmt.Printf("%d\t%s\t%t\t%s\n", user.ID, user.Email, user.GetIsAdmin(), lastUpdated)
}
},
}
}
// createEncryptionKeyRotationCmd creates the encryption key rotation command
func createEncryptionKeyRotationCmd() *cobra.Command {
var dryRun bool
var batchSize, maxErrors int
var backupDir string
var skipBackup bool
var oldKeyEnvVar string
var modelsFlag string
rotateCmd := &cobra.Command{
Use: "rotate-encryption-key",
Short: "Rotate encryption keys for sensitive data",
Long: `Rotate encryption keys for sensitive data stored in the database.
This command will:
1. Create a backup of your database (unless --skip-backup is specified)
2. Re-encrypt all sensitive data with a new encryption key
3. Provide instructions for updating your configuration
The application must be stopped before running this command to prevent data corruption.
`,
Run: func(cmd *cobra.Command, args []string) {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Set backup directory if not provided
if backupDir == "" {
backupDir = cfg.BackupDir
}
// Create backup if needed
if !skipBackup {
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
backupPath := filepath.Join(backupDir, fmt.Sprintf("gomft_backup_before_key_rotation_%s.db",
time.Now().Format("20060102_150405")))
fmt.Printf("Creating database backup at %s...\n", backupPath)
if err := copyFile(dbPath, backupPath); err != nil {
log.Fatalf("Failed to create backup: %v", err)
}
fmt.Println("Backup created successfully.")
} else {
fmt.Println("Skipping database backup as requested.")
}
// Initialize database
dbPath := filepath.Join(cfg.DataDir, "gomft.db")
database, err := db.Initialize(dbPath)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Setup old encryption service
if oldKeyEnvVar == "" {
oldKeyEnvVar = encryption.DefaultKeyEnvVar
}
// Get the current encryption service
oldService, err := encryption.GetGlobalEncryptionService()
if err != nil {
log.Fatalf("Failed to get current encryption service: %v", err)
}
// Generate new key
newKey, err := encryption.GenerateKey(encryption.AES256KeySize)
if err != nil {
log.Fatalf("Failed to generate new encryption key: %v", err)
}
// Create new key manager for the new key
newKeyManager := &keyManager{key: newKey}
// Setup new encryption service with the new key
newService, err := encryption.NewEncryptionService(newKeyManager)
if err != nil {
log.Fatalf("Failed to create new encryption service: %v", err)
}
// Create rotation options
options := keyrotation.RotationOptions{
DryRun: dryRun,
BatchSize: batchSize,
MaxErrors: maxErrors,
Timeout: 24 * time.Hour,
ProgressCallback: func(modelName string, processed, total int) {
fmt.Printf("\rProcessing %s: %d/%d records (%.1f%%)",
modelName, processed, total, float64(processed)/float64(total)*100)
},
}
// Create rotation utility
rotationUtil, err := keyrotation.NewRotationUtility(
database.DB, // Use the underlying gorm.DB
oldService,
newService,
nil, // No auditor needed, keyrotation will use the global one
options,
)
if err != nil {
log.Fatalf("Failed to create rotation utility: %v", err)
}
// Find models with encrypted fields
var models []interface{}
if modelsFlag == "auto" {
fmt.Println("Automatically detecting models with encrypted fields...")
models, err = rotationUtil.FindModelsWithEncryptedFields()
if err != nil {
log.Fatalf("Failed to find models with encrypted fields: %v", err)
}
if len(models) == 0 {
log.Fatalf("No models with encrypted fields found")
}
} else if modelsFlag != "" {
// TODO: Support manual model specification
log.Fatalf("Manual model specification not yet implemented, use --models=auto")
} else {
log.Fatalf("No models specified, use --models=auto to automatically detect models")
}
// Create migration plan
fmt.Println("Creating encryption migration plan...")
plan, err := rotationUtil.CreateEncryptionMigrationPlan(models)
if err != nil {
log.Fatalf("Failed to create migration plan: %v", err)
}
// Print plan
fmt.Println("\nEncryption Migration Plan:")
fmt.Printf("Total models: %d\n", len(plan.ModelPlans))
fmt.Printf("Total records: %d\n", plan.EstimatedRecords)
fmt.Printf("Estimated duration: %s\n", plan.EstimatedDuration.Round(time.Second))
fmt.Println("\nModels to process:")
for name, modelPlan := range plan.ModelPlans {
fmt.Printf("- %s: %d records, %d encrypted fields\n",
name, modelPlan.RecordCount, len(modelPlan.EncryptedFields))
}
// Confirm if not in dry run mode
if !dryRun {
fmt.Println("\nWARNING: This operation will re-encrypt all sensitive data with a new key.")
fmt.Println("Make sure the application is stopped before proceeding.")
fmt.Print("\nDo you want to continue? [y/N]: ")
var response string
fmt.Scanln(&response)
if strings.ToLower(response) != "y" {
fmt.Println("Operation cancelled.")
return
}
}
// Perform key rotation
fmt.Println("\nStarting key rotation...")
startTime := time.Now()
stats, err := rotationUtil.RotateKeysForModels(context.Background(), models)
if err != nil {
fmt.Println("\nKey rotation failed with error:")
fmt.Printf("Error: %v\n", err)
// Add more detailed error information
fmt.Println("\nDetailed error information:")
fmt.Println("===========================")
// Unwrap nested errors if possible
var currentErr error = err
depth := 1
for currentErr != nil {
fmt.Printf("%d. %v\n", depth, currentErr)
if unwrapped, ok := currentErr.(interface{ Unwrap() error }); ok {
currentErr = unwrapped.Unwrap()
depth++
} else {
break
}
}
// Print rotation configuration details
fmt.Println("\nRotation configuration:")
fmt.Printf("- Dry run: %v\n", dryRun)
fmt.Printf("- Batch size: %d\n", batchSize)
fmt.Printf("- Max errors: %d\n", maxErrors)
fmt.Printf("- Models: %s\n", modelsFlag)
fmt.Printf("- Old key env var: %s\n", oldKeyEnvVar)
log.Fatalf("Key rotation failed. See details above.")
}
duration := time.Since(startTime).Round(time.Second)
// Print results
fmt.Println("\nKey rotation completed successfully!")
fmt.Printf("Total records processed: %d/%d\n", stats.ProcessedRecords, stats.TotalRecords)
fmt.Printf("Failed records: %d\n", stats.FailedRecords)
fmt.Printf("Duration: %s\n", duration)
if len(stats.Errors) > 0 {
fmt.Printf("\nErrors (%d):\n", len(stats.Errors))
for i, err := range stats.Errors {
if i >= 10 {
fmt.Printf("... and %d more errors\n", len(stats.Errors)-10)
break
}
fmt.Printf("- %s\n", err)
}
}
// Print next steps
if !dryRun {
fmt.Println("\nNext steps:")
fmt.Println("1. Update your environment variable or .env file with the new encryption key:")
fmt.Printf(" %s=%s\n", oldKeyEnvVar, base64.StdEncoding.EncodeToString(newKey))
fmt.Println("2. Restart your GoMFT application")
fmt.Println("\nIMPORTANT: Keep a backup of both the old and new keys until you verify everything works correctly.")
} else {
fmt.Println("\nDry run completed. No changes were made to the database.")
fmt.Println("Run without --dry-run to perform the actual key rotation.")
}
},
}
// Add flags
rotateCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Simulate key rotation without making changes")
rotateCmd.Flags().IntVar(&batchSize, "batch-size", 100, "Number of records to process in each batch")
rotateCmd.Flags().IntVar(&maxErrors, "max-errors", 50, "Maximum number of errors before aborting")
rotateCmd.Flags().StringVar(&backupDir, "backup-dir", "", "Directory to store backup data (defaults to config backup_dir)")
rotateCmd.Flags().BoolVar(&skipBackup, "skip-backup", false, "Skip database backup (not recommended)")
rotateCmd.Flags().StringVar(&oldKeyEnvVar, "old-key-env", "", "Environment variable containing the old encryption key (defaults to GOMFT_ENCRYPTION_KEY)")
rotateCmd.Flags().StringVar(&modelsFlag, "models", "auto", "Models to process (use 'auto' for automatic detection)")
return rotateCmd
}
// keyManager is a simple implementation of the encryption.KeyManager interface
// that uses a fixed key for the new encryption service
type keyManager struct {
key []byte
}
func (km *keyManager) Initialize() error {
// Already initialized with the key
return nil
}
func (km *keyManager) GetPrimaryKey() ([]byte, error) {
return km.key, nil
}
func (km *keyManager) GetEnvironmentVariableName() string {
return "TEMP_KEY_MANAGER"
}
func (km *keyManager) StoreKeyEnvironment(key []byte) error {
// Not needed for this implementation
return nil
}
// Helper functions
// generateSecureKey creates a cryptographically secure random key encoded as base64
func generateSecureKey() (string, error) {
return config.GenerateSecureKey()
}
// updateEnvVar updates an environment variable in the .env file content
func updateEnvVar(content, key, value string) (string, bool) {
lines := strings.Split(content, "\n")
prefix := key + "="
updated := false
for i, line := range lines {
if strings.HasPrefix(line, prefix) {
lines[i] = prefix + value
updated = true
break
}
}
return strings.Join(lines, "\n"), updated
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}