Add gomftctl CLI tool and encryption key rotation utilities

This commit is contained in:
StarFleetCPTN
2025-04-19 13:22:40 -07:00
parent 495b844c00
commit b9acf47530
17 changed files with 1754 additions and 125 deletions
+9 -3
View File
@@ -69,11 +69,16 @@ COPY . .
# Generate template files from .templ files
RUN templ generate
# Compile the application with version information
# Compile the main application with version information
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
-o /app/gomft
# Compile the command line tool with version information
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags "-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT}" \
-o /app/gomftctl ./cmd/gomftctl
# Install rclone with appropriate architecture
RUN apk add --no-cache curl unzip && \
if [ "$TARGETARCH" = "arm64" ]; then \
@@ -112,8 +117,9 @@ RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
RUN addgroup -g ${GID} ${USERNAME} && \
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
# Copy the binary from the builder stage
# Copy the binaries from the builder stage
COPY --from=builder /app/gomft /app/
COPY --from=builder /app/gomftctl /app/
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
# Copy components
@@ -130,7 +136,7 @@ RUN mkdir -p /app/data /app/backups
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
# Set executable permissions
RUN chmod +x /app/gomft
RUN chmod +x /app/gomft /app/gomftctl
# Set ownership of application files
RUN chown -R ${USERNAME}:${USERNAME} /app
+51 -2
View File
@@ -15,6 +15,20 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
</a>
</p>
## 📚 Documentation
Comprehensive documentation is available at [https://starfleetcptn.github.io/GoMFT/](https://starfleetcptn.github.io/GoMFT/).
The documentation includes:
- [Getting Started Guide](https://starfleetcptn.github.io/GoMFT/docs/getting-started)
- [Installation Instructions](https://starfleetcptn.github.io/GoMFT/docs/installation)
- [Configuration Reference](https://starfleetcptn.github.io/GoMFT/docs/configuration)
- [User Guide](https://starfleetcptn.github.io/GoMFT/docs/user-guide)
- [Storage Provider Setup](https://starfleetcptn.github.io/GoMFT/docs/storage-providers)
- [Advanced Features](https://starfleetcptn.github.io/GoMFT/docs/advanced)
- [Troubleshooting](https://starfleetcptn.github.io/GoMFT/docs/troubleshooting)
- [API Reference](https://starfleetcptn.github.io/GoMFT/docs/api)
> [!WARNING]
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
@@ -86,6 +100,7 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
- Optimized for both high-volume small files and large file transfers
- Maximizes bandwidth utilization for cloud storage providers
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
- **Command Line Tools**: Administrative tasks can be performed via the `gomftctl` CLI tool
- **File Pattern Matching**: Support for file patterns to filter files during transfers
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
- **Archive Function**: Option to archive transferred files for backup and compliance
@@ -137,9 +152,10 @@ go install github.com/a-h/templ/cmd/templ@latest
templ generate
```
4. Build the application:
4. Build the application and CLI tools:
```bash
go build -o gomft
go build -o gomftctl ./cmd/gomftctl
```
### Docker Installation
@@ -256,7 +272,40 @@ docker-compose up -d
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
Full documentation is available at [https://starfleetcptn.github.io/GoMFT/](https://starfleetcptn.github.io/GoMFT/).
## Command Line Tools
GoMFT includes a command line tool called `gomftctl` for administrative tasks:
```bash
# View available commands
./gomftctl --help
# Migrate provider data
./gomftctl migrate-providers
# Rotate security keys
./gomftctl rotate-key --type jwt
# Manage users
./gomftctl user create --email admin@example.com --password secure_password --admin
./gomftctl user list
# Backup database
./gomftctl backup
```
See the [Admin Tools documentation](https://starfleetcptn.github.io/GoMFT/docs/advanced/admin-tools) for more details.
## Contributing
Contributions are welcome! Please see our [Contributing Guide](https://starfleetcptn.github.io/GoMFT/docs/contributing) for details on how to get started.
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
We also welcome documentation improvements. The documentation source is available in the `docs/` directory.
## License
+772
View File
@@ -0,0 +1,772 @@
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
}
-7
View File
@@ -7,13 +7,6 @@ title: Admin Tools
GoMFT provides a comprehensive set of administrative tools for system management, monitoring, and maintenance. These tools help administrators maintain the system, troubleshoot issues, and ensure optimal performance.
## Accessing Admin Tools
Admin tools are available to users with administrator privileges:
1. Log in with an administrator account
2. Navigate to **Admin Tools** in the sidebar menu
## Log Viewer
The Admin Tools panel includes an integrated log viewer with the following features:
+278
View File
@@ -0,0 +1,278 @@
---
sidebar_position: 8
title: Command Line Tools
---
GoMFT provides a command line tool called `gomftctl` that allows administrators to perform various management tasks without using the web interface. This tool is particularly useful for automation, scripting, and performing administrative tasks in environments where the web UI is not accessible.
## Installation
The `gomftctl` tool is included with your GoMFT installation. You can find it in the root directory of your GoMFT installation.
If you need to build it manually, you can do so with:
```bash
cd /path/to/gomft
go build -o gomftctl ./cmd/gomftctl
```
### Using with Docker
If you're running GoMFT in a Docker container, the `gomftctl` tool is already included in the container. You can run it using the `docker exec` command:
```bash
# Replace gomft-container with your actual container name
docker exec -it gomft-container /app/gomftctl [command] [options]
```
For example, to view the version information:
```bash
docker exec -it gomft-container /app/gomftctl version
```
For commands that require stopping the application first (like key rotation), you'll need to:
1. Stop the container
2. Run the command in a new container using the same volumes
3. Restart the original container
```bash
# Stop the container
docker stop gomft-container
# Run a command using the same volumes
docker run --rm -v gomft_data:/app/data -v gomft_backups:/app/backups gomft/gomft:latest /app/gomftctl [command] [options]
# Restart the container
docker start gomft-container
```
## Available Commands
The `gomftctl` tool provides the following commands:
### Provider Data Migration
Migrate provider data from older versions of GoMFT to the new storage provider model:
```bash
./gomftctl migrate-providers [--dry-run] [--validate-only] [--force] [--backup-dir PATH] [--debug] [--auto-fill]
```
Options:
- `--dry-run`: Simulate migration without making changes
- `--validate-only`: Only validate if migration is possible without making changes
- `--force`: Force migration even if validation fails
- `--backup-dir`: Directory to store backup data (defaults to config backup_dir)
- `--debug`: Enable debug mode with more detailed error messages
- `--auto-fill`: Automatically fill missing required fields with placeholder values
### Security Key Rotation
Generate new security keys for the application:
```bash
./gomftctl rotate-key --type [jwt|totp|encryption] [--write]
```
Options:
- `--type`: Type of key to rotate (required)
- `jwt`: JSON Web Token signing key
- `totp`: TOTP encryption key
- `encryption`: General encryption key used for sensitive data
- `--write`: Write the new key directly to .env file (otherwise just displays the key)
### Encryption Key Rotation
Rotate encryption keys for sensitive data stored in the database:
```bash
./gomftctl rotate-encryption-key [--dry-run] [--batch-size SIZE] [--max-errors NUM] [--backup-dir PATH] [--skip-backup] [--old-key-env VAR] [--models MODE]
```
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
**Important**: The application must be stopped before running this command to prevent data corruption.
Options:
- `--dry-run`: Simulate key rotation without making changes
- `--batch-size`: Number of records to process in each batch (default 100)
- `--max-errors`: Maximum number of errors before aborting (default 50)
- `--backup-dir`: Directory to store backup data (defaults to config backup_dir)
- `--skip-backup`: Skip database backup (not recommended)
- `--old-key-env`: Environment variable containing the old encryption key (defaults to GOMFT_ENCRYPTION_KEY)
- `--models`: Models to process (use 'auto' for automatic detection, default 'auto')
### Database Backup
Create a backup of the GoMFT database and configuration:
```bash
./gomftctl backup [--output-dir PATH]
```
Options:
- `--output-dir`: Directory to store backup files (defaults to config backup_dir)
### User Management
Commands for managing GoMFT users:
#### Create a new user
```bash
./gomftctl user create --email EMAIL --password PASSWORD [--admin]
```
Options:
- `--email`: User email address (required)
- `--password`: User password (required)
- `--admin`: Grant admin privileges to the user
#### Reset a user's password
```bash
./gomftctl user reset-password --email EMAIL --password PASSWORD
```
Options:
- `--email`: User email address (required)
- `--password`: New password (required)
#### List all users
```bash
./gomftctl user list
```
### Version Information
Display version information:
```bash
./gomftctl version
```
## Examples
### Migrating Provider Data
To migrate provider data with a dry run first:
```bash
# First do a dry run to see what would happen
./gomftctl migrate-providers --dry-run
# Then run the actual migration
./gomftctl migrate-providers
```
If you encounter errors due to missing required fields, you can use the auto-fill option:
```bash
# Migrate with auto-fill to handle missing required fields
./gomftctl migrate-providers --auto-fill
# For more detailed error information, add the debug flag
./gomftctl migrate-providers --auto-fill --debug
```
When using `--auto-fill`, the system will:
1. Automatically supply placeholder values for missing required fields
2. Mark providers with "[AUTO-FILLED]" in their names
3. Log warnings about which fields were auto-filled
4. Allow you to update the correct values after migration
### Rotating JWT Secret Key
To rotate the JWT secret key and update the .env file:
```bash
./gomftctl rotate-key --type jwt --write
```
### Rotating Encryption Key for Sensitive Data
To rotate the encryption key used for sensitive data in the database:
```bash
# First stop the GoMFT application
systemctl stop gomft
# Run a dry run to see what would be affected
./gomftctl rotate-encryption-key --dry-run
# Perform the actual key rotation
./gomftctl rotate-encryption-key
# Update your environment variable or .env file with the new key
# Then restart the application
systemctl start gomft
```
#### With Docker
To rotate encryption keys when running GoMFT in Docker:
```bash
# Stop the container
docker stop gomft-container
# Run a dry run to see what would be affected
docker run --rm -v gomft_data:/app/data -v gomft_backups:/app/backups gomft/gomft:latest /app/gomftctl rotate-encryption-key --dry-run
# Perform the actual key rotation
docker run --rm -v gomft_data:/app/data -v gomft_backups:/app/backups gomft/gomft:latest /app/gomftctl rotate-encryption-key
# Update your environment variables in your docker-compose.yml or run command
# Then restart the container
docker start gomft-container
```
### Creating an Admin User
To create a new admin user:
```bash
./gomftctl user create --email admin@example.com --password secure_password --admin
```
### Backing Up the Database
To create a backup of the database:
```bash
./gomftctl backup --output-dir /path/to/backup/directory
```
## Using in Scripts
The `gomftctl` tool is designed to be used in scripts and automation. For example, you could create a cron job to backup the database daily:
```bash
# Add to crontab
0 2 * * * /path/to/gomft/gomftctl backup --output-dir /path/to/backup/directory
```
Or you could create a script to rotate security keys periodically:
```bash
#!/bin/bash
# Stop the GoMFT service
systemctl stop gomft
# Rotate all security keys
/path/to/gomft/gomftctl rotate-key --type jwt --write
/path/to/gomft/gomftctl rotate-key --type totp --write
/path/to/gomft/gomftctl rotate-key --type encryption --write
# Rotate encryption key for sensitive data in the database
/path/to/gomft/gomftctl rotate-encryption-key
# Restart the GoMFT service to apply changes
systemctl restart gomft
```
+2 -1
View File
@@ -38,13 +38,14 @@ const sidebars: SidebarsConfig = {
type: 'category',
label: 'Advanced Features',
items: [
'advanced/admin-tools',
'advanced/command-line-tool',
'advanced/notifications-overview',
'advanced/gotify-notifications',
'advanced/ntfy-notifications',
'advanced/pushbullet-notifications',
'advanced/pushover-notifications',
'advanced/webhook-notifications',
'advanced/admin-tools'
],
},
{
+3
View File
@@ -37,6 +37,7 @@ require (
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -48,6 +49,8 @@ require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
+8
View File
@@ -10,6 +10,7 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -58,6 +59,8 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -90,6 +93,11 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+4 -4
View File
@@ -37,12 +37,12 @@ type EmailConfig struct {
func Load() (*Config, error) {
// Generate secure encryption keys
defaultTOTPKey, err := generateSecureKey()
defaultTOTPKey, err := GenerateSecureKey()
if err != nil {
return nil, err
}
defaultGOMFTKey, err := generateSecureKey()
defaultGOMFTKey, err := GenerateSecureKey()
if err != nil {
return nil, err
}
@@ -200,8 +200,8 @@ func Load() (*Config, error) {
return cfg, nil
}
// generateSecureKey creates a cryptographically secure random key encoded as base64
func generateSecureKey() (string, error) {
// GenerateSecureKey creates a cryptographically secure random key encoded as base64
func GenerateSecureKey() (string, error) {
// Generate 32 bytes of random data (256 bits)
bytes := make([]byte, 32)
_, err := rand.Read(bytes)
+179 -2
View File
@@ -1,12 +1,15 @@
package db
import (
"context"
"fmt"
"log"
"regexp"
"strings"
"time"
"github.com/starfleetcptn/gomft/internal/encryption"
"gorm.io/gorm"
)
// ProviderConfig represents a unique provider configuration extracted from TransferConfigs
@@ -134,6 +137,8 @@ type MigrateProviderDataOptions struct {
ValidationOnly bool // If true, only perform validation without migration
Force bool // If true, ignore validation errors and proceed with migration
BackupDir string // Directory to store backups in
DebugMode bool // If true, provide more detailed error information
AutoFill bool // If true, automatically fill missing required fields with placeholder values
}
// ExtractUniqueProviderConfigs extracts all unique provider configurations from existing TransferConfig records
@@ -399,11 +404,56 @@ func (db *DB) CreateStorageProviderRecords(uniqueConfigs map[string]*ProviderCon
provider.EncryptedClientSecret = encryptedClientSecret
}
// Check if we should attempt to auto-fill missing required fields
autoFill := false
if tx.Statement != nil && tx.Statement.Context != nil {
if autoFillVal := tx.Statement.Context.Value("auto_fill"); autoFillVal != nil {
if af, ok := autoFillVal.(bool); ok {
autoFill = af
}
}
}
// Create the provider record
if err := tx.Create(provider).Error; err != nil {
sanitizedErrMsg := sanitizeErrorMessage(err.Error())
// Check if we should try to auto-fill missing fields
if autoFill {
// Try to determine if it's a missing required field error
errStr := err.Error()
if strings.Contains(strings.ToLower(errStr), "not null") ||
strings.Contains(strings.ToLower(errStr), "required") ||
strings.Contains(strings.ToLower(errStr), "cannot be null") {
// Try to auto-fill missing fields based on provider type
filled := autoFillMissingFields(provider, providerConfig)
if filled {
// Try again with auto-filled fields
if retryErr := tx.Create(provider).Error; retryErr == nil {
// Success! Add a warning to the log
log.Printf("WARNING: Auto-filled missing required fields for provider %s. Please update this provider with correct values.", provider.Name)
// Continue with normal flow
goto successLabel
} else {
// Still failed, proceed with normal error handling
err = retryErr
}
}
}
}
// Get debug mode flag from context
debugMode := false
if tx.Statement != nil && tx.Statement.Context != nil {
if debugVal := tx.Statement.Context.Value("debug_mode"); debugVal != nil {
if debug, ok := debugVal.(bool); ok {
debugMode = debug
}
}
}
sanitizedErrMsg := sanitizeErrorMessage(err.Error(), debugMode)
return rollback(fmt.Errorf("failed to create provider record: %s", sanitizedErrMsg))
}
successLabel:
// Store the provider ID in the map
providerIDMap[key] = provider.ID
@@ -424,8 +474,127 @@ func (db *DB) CreateStorageProviderRecords(uniqueConfigs map[string]*ProviderCon
return providerIDMap, nil
}
// autoFillMissingFields attempts to fill missing required fields with placeholder values
// Returns true if fields were filled, false otherwise
func autoFillMissingFields(provider *StorageProvider, config *ProviderConfig) bool {
changesMade := false
// Check and fill common fields that might be required
if provider.Name == "" {
provider.Name = fmt.Sprintf("Auto-filled Provider %s", time.Now().Format("2006-01-02 15:04:05"))
changesMade = true
}
// Fill fields based on provider type
switch provider.Type {
case ProviderTypeSFTP, ProviderTypeFTP, ProviderTypeHetzner:
// Fill host if empty
if provider.Host == "" {
provider.Host = "placeholder.example.com"
changesMade = true
}
// Fill port if zero
if provider.Port == 0 {
if provider.Type == ProviderTypeFTP {
provider.Port = 21
} else {
provider.Port = 22 // Default SFTP port
}
changesMade = true
}
// Fill username if empty
if provider.Username == "" {
provider.Username = "placeholder_user"
changesMade = true
}
case ProviderTypeS3:
// Fill bucket if empty
if provider.Bucket == "" {
provider.Bucket = "placeholder-bucket"
changesMade = true
}
// Fill region if empty
if provider.Region == "" {
provider.Region = "us-east-1"
changesMade = true
}
// Fill access key if empty
if provider.AccessKey == "" {
provider.AccessKey = "PLACEHOLDER_ACCESS_KEY"
changesMade = true
}
// Fill endpoint if empty
if provider.Endpoint == "" {
provider.Endpoint = "https://s3.amazonaws.com"
changesMade = true
}
case ProviderTypeSMB:
// Fill host if empty
if provider.Host == "" {
provider.Host = "placeholder-smb-server"
changesMade = true
}
// Fill share if empty
if provider.Share == "" {
provider.Share = "placeholder-share"
changesMade = true
}
// Fill domain if empty
if provider.Domain == "" {
provider.Domain = "WORKGROUP"
changesMade = true
}
case ProviderTypeOneDrive, ProviderTypeGoogleDrive, ProviderTypeGooglePhoto:
// Fill client ID if empty
if provider.ClientID == "" {
provider.ClientID = "placeholder-client-id"
changesMade = true
}
// Fill drive ID if empty for OneDrive/Google Drive
if provider.DriveID == "" && (provider.Type == ProviderTypeOneDrive || provider.Type == ProviderTypeGoogleDrive) {
provider.DriveID = "placeholder-drive-id"
changesMade = true
}
}
// Set authenticated to false by default if it's nil
if provider.Authenticated == nil {
falseVal := false
provider.Authenticated = &falseVal
changesMade = true
}
// If we made changes, log a warning
if changesMade {
log.Printf("WARNING: Auto-filled missing required fields for provider type %s. Please update with correct values.", provider.Type)
}
return changesMade
}
// sanitizeErrorMessage removes any potential sensitive information from error messages
func sanitizeErrorMessage(errMsg string) string {
func sanitizeErrorMessage(errMsg string, debugMode bool) string {
// In debug mode, we'll provide more information but still sanitize critical parts
if debugMode {
// List of sensitive keywords to check for and redact
sensitiveKeywords := []string{
"password", "secret", "token", "key", "credential", "auth",
}
// Redact sensitive information with placeholders instead of hiding the whole message
sanitizedMsg := errMsg
for _, keyword := range sensitiveKeywords {
// Case insensitive replacement using regex
re := regexp.MustCompile(fmt.Sprintf(`(?i)(%s\s*[:=]\s*['"]*)[^'"\s]+(['"]*\s*)`, keyword))
sanitizedMsg = re.ReplaceAllString(sanitizedMsg, "${1}[REDACTED]${2}")
}
return fmt.Sprintf("DEBUG: %s", sanitizedMsg)
}
// Standard non-debug mode with strict security
// List of sensitive keywords to check for
sensitiveKeywords := []string{
"password", "secret", "token", "key", "credential", "auth",
@@ -719,6 +888,14 @@ func (db *DB) ValidateMigrationIntegrity() (*ValidationResult, error) {
// MigrateProviderData is the main function that performs the complete migration process
func (db *DB) MigrateProviderData(options MigrateProviderDataOptions) (*MigrationStats, error) {
// Store options in context for use by other functions
ctx := context.Background()
ctx = context.WithValue(ctx, "debug_mode", options.DebugMode)
ctx = context.WithValue(ctx, "auto_fill", options.AutoFill)
// Create a new DB session with the context
dbWithContext := db.DB.WithContext(ctx).Session(&gorm.Session{})
// Update our DB wrapper to use this session
db.DB = dbWithContext
// Initialize migration stats
stats := &MigrationStats{
StartTime: time.Now(),
@@ -0,0 +1,90 @@
package migrations
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// UpdateDriveType updates the source_type and dest_type from 'gdrive' to 'drive'
func UpdateDriveType() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "016_update_drive_type",
Migrate: func(tx *gorm.DB) error {
// Check if any tables exist (indicating an existing database)
var count int64
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
return fmt.Errorf("failed to check for existing tables: %v", err)
}
// If tables exist, create a backup
if count > 0 {
// Get the database path
sqlDB, err := tx.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database: %v", err)
}
var seq int
var name, dbPath string
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
return fmt.Errorf("failed to get database path: %v", err)
}
// Get backup directory from environment variable or use default
backupDir := os.Getenv("BACKUP_DIR")
if backupDir == "" {
backupDir = "/app/backups" // Default Docker path
// Check if we're not in Docker
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
backupDir = "backups" // Fallback to local directory
}
}
// Create backup directory if it doesn't exist
if err := os.MkdirAll(backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %v", err)
}
// Create backup file with timestamp in the backup directory
dbFileName := filepath.Base(dbPath)
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
backupFile := filepath.Join(backupDir, backupFileName)
// Read original database
data, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("failed to read database for backup: %v", err)
}
// Write backup
if err := os.WriteFile(backupFile, data, 0600); err != nil {
return fmt.Errorf("failed to create database backup: %v", err)
}
fmt.Printf("Created database backup at: %s\n", backupFile)
}
// Update source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'drive' WHERE source_type = 'gdrive'`).Error; err != nil {
return err
}
// Update dest_type
return tx.Exec(`UPDATE transfer_configs SET dest_type = 'drive' WHERE dest_type = 'gdrive'`).Error
},
Rollback: func(tx *gorm.DB) error {
// Revert source_type
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'drive'`).Error; err != nil {
return err
}
// Revert dest_type
return tx.Exec(`UPDATE transfer_configs SET dest_type = 'gdrive' WHERE dest_type = 'drive'`).Error
},
}
}
+1
View File
@@ -29,6 +29,7 @@ func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
CleanupInvalidBooleans(), // 013
AddStorageProviders(), // 014
AddProviderRefsToTransferConfig(), // 015
UpdateDriveType(), // 016
)
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
@@ -10,27 +10,12 @@ import (
"time"
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/keyrotation"
"github.com/starfleetcptn/gomft/internal/encryption/rotationmodel"
"gorm.io/gorm"
)
// RotationOptions contains configuration for the key rotation process
type RotationOptions struct {
// DryRun performs all operations but doesn't save changes to database
DryRun bool
// BatchSize sets the number of records to process in each batch
BatchSize int
// MaxErrors sets the threshold of errors before aborting
MaxErrors int
// Parallelism controls how many models are processed in parallel
Parallelism int
// Timeout specifies a maximum duration for the entire operation
Timeout time.Duration
// WorkerTimeout specifies maximum duration for a single batch
WorkerTimeout time.Duration
// ProgressCallback receives updates on rotation progress
ProgressCallback func(modelName string, processed, total int)
}
// For backward compatibility
type RotationOptions = rotationmodel.RotationOptions
// RotationUtility provides comprehensive capabilities for rotating encryption keys
// across multiple database models with detailed auditing and progress tracking
@@ -125,19 +110,19 @@ func (r *RotationUtility) runHook(name string, data interface{}) error {
}
// RotateKeysForModels performs key rotation for multiple model types with detailed monitoring
func (r *RotationUtility) RotateKeysForModels(ctx context.Context, models []interface{}) (*keyrotation.RotationStats, error) {
func (r *RotationUtility) RotateKeysForModels(ctx context.Context, models []interface{}) (*rotationmodel.RotationStats, error) {
// Create master context with timeout
masterCtx, cancel := context.WithTimeout(ctx, r.options.Timeout)
defer cancel()
// Track overall stats
overallStats := &keyrotation.RotationStats{
overallStats := &rotationmodel.RotationStats{
StartTime: time.Now(),
Errors: make([]string, 0),
}
// Create key rotator
rotator, err := keyrotation.NewKeyRotator(r.db, r.oldService, r.newService, r.auditor)
// Create key rotator - we'll implement our own version instead of using keyrotation package
rotator, err := NewKeyRotator(r.db, r.oldService, r.newService, r.auditor)
if err != nil {
return overallStats, fmt.Errorf("failed to create key rotator: %w", err)
}
@@ -194,7 +179,7 @@ func (r *RotationUtility) RotateKeysForModels(ctx context.Context, models []inte
// Create a goroutine to handle timeouts
rotationDone := make(chan struct{})
var modelStats *keyrotation.RotationStats
var modelStats *rotationmodel.RotationStats
var rotationErr error
go func() {
@@ -494,19 +479,8 @@ func (r *RotationUtility) estimateMigrationTime(recordCount, fieldCount int) tim
return time.Duration(totalTimeMs) * time.Millisecond
}
// EncryptionMigrationPlan contains the complete plan for migration
type EncryptionMigrationPlan struct {
ModelPlans map[string]*ModelMigrationPlan `json:"model_plans"`
EstimatedDuration time.Duration `json:"estimated_duration"`
EstimatedRecords int `json:"estimated_records"`
RecommendedOptions RotationOptions `json:"recommended_options"`
}
// For backward compatibility
type EncryptionMigrationPlan = rotationmodel.EncryptionMigrationPlan
// ModelMigrationPlan contains migration details for a specific model
type ModelMigrationPlan struct {
ModelName string `json:"model_name"`
RecordCount int `json:"record_count"`
EstimatedTime time.Duration `json:"estimated_time"`
EncryptedFields []string `json:"encrypted_fields"`
BatchSizeRec int `json:"batch_size_recommendation"`
}
// For backward compatibility
type ModelMigrationPlan = rotationmodel.ModelMigrationPlan
+274
View File
@@ -0,0 +1,274 @@
package audit
import (
"errors"
"fmt"
"reflect"
"strings"
"time"
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/rotationmodel"
"gorm.io/gorm"
)
// Common errors
var (
ErrNoOldKey = errors.New("old encryption key not found")
ErrNoNewKey = errors.New("new encryption key not found")
ErrSameKey = errors.New("old and new keys are the same")
ErrNoDataToMigrate = errors.New("no data to migrate")
ErrNilDB = errors.New("database connection is nil")
)
// KeyRotator manages the process of changing encryption keys and re-encrypting data
type KeyRotator struct {
db *gorm.DB
oldService *encryption.EncryptionService
newService *encryption.EncryptionService
auditor *SecurityAuditor
dryRun bool
batchSize int
maxErrors int
}
// NewKeyRotator creates a new KeyRotator
func NewKeyRotator(db *gorm.DB, oldService, newService *encryption.EncryptionService, auditor *SecurityAuditor) (*KeyRotator, error) {
if db == nil {
return nil, ErrNilDB
}
if oldService == nil {
return nil, ErrNoOldKey
}
if newService == nil {
return nil, ErrNoNewKey
}
if oldService == newService {
return nil, ErrSameKey
}
if auditor == nil {
// Use the global auditor if none provided
auditor = GetGlobalAuditor()
}
return &KeyRotator{
db: db,
oldService: oldService,
newService: newService,
auditor: auditor,
dryRun: false,
batchSize: 100,
maxErrors: 50,
}, nil
}
// SetDryRun enables or disables dry run mode
func (r *KeyRotator) SetDryRun(dryRun bool) {
r.dryRun = dryRun
}
// SetBatchSize sets the batch size for processing records
func (r *KeyRotator) SetBatchSize(size int) {
if size > 0 {
r.batchSize = size
}
}
// SetMaxErrors sets the maximum number of errors allowed before aborting
func (r *KeyRotator) SetMaxErrors(max int) {
if max >= 0 {
r.maxErrors = max
}
}
// RotateKeys rotates encryption keys for a specific model type
func (r *KeyRotator) RotateKeys(modelType interface{}, primaryKeyName string) (*rotationmodel.RotationStats, error) {
stats := &rotationmodel.RotationStats{
StartTime: time.Now(),
Errors: make([]string, 0),
}
// Get the model type
modelValue := reflect.ValueOf(modelType)
if modelValue.Kind() == reflect.Ptr {
modelValue = modelValue.Elem()
}
// Skip if the value is not a struct
if modelValue.Kind() != reflect.Struct {
return stats, errors.New("model type must be a struct")
}
modelName := modelValue.Type().Name()
// Count total records
var count int64
if err := r.db.Model(modelType).Count(&count).Error; err != nil {
return stats, fmt.Errorf("failed to count records: %w", err)
}
stats.TotalRecords = int(count)
if count == 0 {
return stats, ErrNoDataToMigrate
}
// Process in batches
offset := 0
for offset < int(count) {
// Get a batch of records
records := reflect.New(reflect.SliceOf(modelValue.Type())).Interface()
if err := r.db.Model(modelType).Offset(offset).Limit(r.batchSize).Find(records).Error; err != nil {
stats.Errors = append(stats.Errors, fmt.Sprintf("failed to fetch batch at offset %d: %v", offset, err))
if len(stats.Errors) >= r.maxErrors {
return stats, fmt.Errorf("too many errors (%d), aborting key rotation", len(stats.Errors))
}
offset += r.batchSize
continue
}
// Process this batch
batchRecords := reflect.ValueOf(records).Elem()
for i := 0; i < batchRecords.Len(); i++ {
record := batchRecords.Index(i)
if record.Kind() == reflect.Ptr {
record = record.Elem()
}
if err := r.rotateKeysForRecord(record, modelName, primaryKeyName); err != nil {
pkValue := getPrimaryKeyValue(record, primaryKeyName)
stats.Errors = append(stats.Errors, fmt.Sprintf("failed to rotate keys for %s with ID %v: %v", modelName, pkValue, err))
stats.FailedRecords++
if len(stats.Errors) >= r.maxErrors {
stats.EndTime = time.Now()
stats.ElapsedTime = stats.EndTime.Sub(stats.StartTime)
return stats, fmt.Errorf("too many errors (%d), aborting key rotation", len(stats.Errors))
}
} else {
stats.ProcessedRecords++
}
}
offset += r.batchSize
}
stats.EndTime = time.Now()
stats.ElapsedTime = stats.EndTime.Sub(stats.StartTime)
return stats, nil
}
// rotateKeysForRecord processes a single record
func (r *KeyRotator) rotateKeysForRecord(record reflect.Value, modelName, primaryKeyName string) error {
if !record.IsValid() || record.Kind() != reflect.Struct {
return errors.New("invalid record")
}
// Check if there are any encrypted fields to migrate
encryptedFieldsFound := false
recordType := record.Type()
// Track changes for audit
pkValue := getPrimaryKeyValue(record, primaryKeyName)
changes := make(map[string]struct{})
// Process each field in the struct
for i := 0; i < recordType.NumField(); i++ {
field := recordType.Field(i)
// Look for encrypted fields
fieldName := field.Name
if strings.HasPrefix(fieldName, "Encrypted") {
// Get the field value
fieldValue := record.Field(i)
if !fieldValue.CanInterface() || !fieldValue.CanSet() {
continue
}
// Get the encrypted value
encryptedValue, ok := fieldValue.Interface().(string)
if !ok || encryptedValue == "" {
continue
}
// If it's not encrypted with our old key, skip it
if !strings.HasPrefix(encryptedValue, encryption.EncryptedPrefix) {
continue
}
encryptedFieldsFound = true
// Try to decrypt with the old key
trimmedValue := strings.TrimPrefix(encryptedValue, encryption.EncryptedPrefix)
plaintext, err := r.oldService.DecryptString(trimmedValue)
if err != nil {
// Skip this field if we can't decrypt it (might be encrypted with a different key)
continue
}
// Re-encrypt with the new key
newEncrypted, err := r.newService.EncryptString(plaintext)
if err != nil {
return fmt.Errorf("failed to re-encrypt field %s: %w", fieldName, err)
}
// Only update if different
newValue := encryption.EncryptedPrefix + newEncrypted
if newValue != encryptedValue {
if !r.dryRun {
fieldValue.SetString(newValue)
}
changes[fieldName] = struct{}{}
}
}
}
// If no encrypted fields were found or modified, return
if !encryptedFieldsFound || len(changes) == 0 {
return nil
}
// Save the changes to the database
if !r.dryRun {
if err := r.db.Save(record.Addr().Interface()).Error; err != nil {
return fmt.Errorf("failed to save record: %w", err)
}
}
// Log the rotation
if r.auditor != nil {
changedFields := make([]string, 0, len(changes))
for field := range changes {
changedFields = append(changedFields, field)
}
description := fmt.Sprintf("Rotated keys for %s (ID: %v) - fields: %s",
modelName, pkValue, strings.Join(changedFields, ", "))
r.auditor.LogKeyRotationEventWithDescription(
"old", "new", true, description, 0,
)
}
return nil
}
// getPrimaryKeyValue gets the value of the primary key field
func getPrimaryKeyValue(record reflect.Value, pkName string) interface{} {
if pkName == "" {
pkName = "ID" // Default primary key name
}
pkField := record.FieldByName(pkName)
if !pkField.IsValid() {
return "<unknown>"
}
return pkField.Interface()
}
+12 -36
View File
@@ -8,7 +8,7 @@ import (
"time"
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/audit"
"github.com/starfleetcptn/gomft/internal/encryption/rotationmodel"
"gorm.io/gorm"
)
@@ -21,31 +21,22 @@ var (
ErrNilDB = errors.New("database connection is nil")
)
// RotationStats represents statistics about the key rotation process
type RotationStats struct {
TotalRecords int `json:"total_records"`
ProcessedRecords int `json:"processed_records"`
SkippedRecords int `json:"skipped_records"`
FailedRecords int `json:"failed_records"`
ElapsedTime time.Duration `json:"elapsed_time"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Errors []string `json:"errors,omitempty"`
}
// For backward compatibility
type RotationStats = rotationmodel.RotationStats
// KeyRotator manages the process of changing encryption keys and re-encrypting data
type KeyRotator struct {
db *gorm.DB
oldService *encryption.EncryptionService
newService *encryption.EncryptionService
auditor *audit.SecurityAuditor
auditor interface{} // Interface for SecurityAuditor to avoid import cycle
dryRun bool
batchSize int
maxErrors int
}
// NewKeyRotator creates a new KeyRotator
func NewKeyRotator(db *gorm.DB, oldService, newService *encryption.EncryptionService, auditor *audit.SecurityAuditor) (*KeyRotator, error) {
func NewKeyRotator(db *gorm.DB, oldService, newService *encryption.EncryptionService, auditor interface{}) (*KeyRotator, error) {
if db == nil {
return nil, ErrNilDB
}
@@ -62,10 +53,7 @@ func NewKeyRotator(db *gorm.DB, oldService, newService *encryption.EncryptionSer
return nil, ErrSameKey
}
if auditor == nil {
// Use the global auditor if none provided
auditor = audit.GetGlobalAuditor()
}
// We don't check for nil auditor here anymore to avoid import cycle
return &KeyRotator{
db: db,
@@ -98,8 +86,8 @@ func (r *KeyRotator) SetMaxErrors(max int) {
}
// RotateKeys rotates encryption keys for a specific model type
func (r *KeyRotator) RotateKeys(modelType interface{}, primaryKeyName string) (*RotationStats, error) {
stats := &RotationStats{
func (r *KeyRotator) RotateKeys(modelType interface{}, primaryKeyName string) (*rotationmodel.RotationStats, error) {
stats := &rotationmodel.RotationStats{
StartTime: time.Now(),
Errors: make([]string, 0),
}
@@ -186,8 +174,8 @@ func (r *KeyRotator) rotateKeysForRecord(record reflect.Value, modelName, primar
encryptedFieldsFound := false
recordType := record.Type()
// Track changes for audit
pkValue := getPrimaryKeyValue(record, primaryKeyName)
// Track changes
_ = getPrimaryKeyValue(record, primaryKeyName) // Kept for future reference but not used directly
changes := make(map[string]struct{})
// Process each field in the struct
@@ -253,20 +241,8 @@ func (r *KeyRotator) rotateKeysForRecord(record reflect.Value, modelName, primar
}
}
// Log the rotation
if r.auditor != nil {
changedFields := make([]string, 0, len(changes))
for field := range changes {
changedFields = append(changedFields, field)
}
description := fmt.Sprintf("Rotated keys for %s (ID: %v) - fields: %s",
modelName, pkValue, strings.Join(changedFields, ", "))
r.auditor.LogKeyRotationEventWithDescription(
"old", "new", true, description, 0,
)
}
// We've removed the direct auditor calls to avoid import cycle
// Logging is now handled by the audit package's implementation
return nil
}
@@ -11,26 +11,12 @@ import (
"github.com/starfleetcptn/gomft/internal/encryption"
"github.com/starfleetcptn/gomft/internal/encryption/audit"
"github.com/starfleetcptn/gomft/internal/encryption/rotationmodel"
"gorm.io/gorm"
)
// RotationOptions contains configuration for the key rotation process
type RotationOptions struct {
// DryRun performs all operations but doesn't save changes to database
DryRun bool
// BatchSize sets the number of records to process in each batch
BatchSize int
// MaxErrors sets the threshold of errors before aborting
MaxErrors int
// Parallelism controls how many models are processed in parallel
Parallelism int
// Timeout specifies a maximum duration for the entire operation
Timeout time.Duration
// WorkerTimeout specifies maximum duration for a single batch
WorkerTimeout time.Duration
// ProgressCallback receives updates on rotation progress
ProgressCallback func(modelName string, processed, total int)
}
// For backward compatibility
type RotationOptions = rotationmodel.RotationOptions
// RotationUtility provides comprehensive capabilities for rotating encryption keys
// across multiple database models with detailed auditing and progress tracking
@@ -487,19 +473,8 @@ func (r *RotationUtility) estimateMigrationTime(recordCount, fieldCount int) tim
return time.Duration(totalTimeMs) * time.Millisecond
}
// EncryptionMigrationPlan contains the complete plan for migration
type EncryptionMigrationPlan struct {
ModelPlans map[string]*ModelMigrationPlan `json:"model_plans"`
EstimatedDuration time.Duration `json:"estimated_duration"`
EstimatedRecords int `json:"estimated_records"`
RecommendedOptions RotationOptions `json:"recommended_options"`
}
// For backward compatibility
type EncryptionMigrationPlan = rotationmodel.EncryptionMigrationPlan
// ModelMigrationPlan contains migration details for a specific model
type ModelMigrationPlan struct {
ModelName string `json:"model_name"`
RecordCount int `json:"record_count"`
EstimatedTime time.Duration `json:"estimated_time"`
EncryptedFields []string `json:"encrypted_fields"`
BatchSizeRec int `json:"batch_size_recommendation"`
}
// For backward compatibility
type ModelMigrationPlan = rotationmodel.ModelMigrationPlan
@@ -0,0 +1,52 @@
package rotationmodel
import (
"time"
)
// RotationStats represents statistics about the key rotation process
type RotationStats struct {
TotalRecords int `json:"total_records"`
ProcessedRecords int `json:"processed_records"`
SkippedRecords int `json:"skipped_records"`
FailedRecords int `json:"failed_records"`
ElapsedTime time.Duration `json:"elapsed_time"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Errors []string `json:"errors,omitempty"`
}
// RotationOptions contains configuration for the key rotation process
type RotationOptions struct {
// DryRun performs all operations but doesn't save changes to database
DryRun bool
// BatchSize sets the number of records to process in each batch
BatchSize int
// MaxErrors sets the threshold of errors before aborting
MaxErrors int
// Parallelism controls how many models are processed in parallel
Parallelism int
// Timeout specifies a maximum duration for the entire operation
Timeout time.Duration
// WorkerTimeout specifies maximum duration for a single batch
WorkerTimeout time.Duration
// ProgressCallback receives updates on rotation progress
ProgressCallback func(modelName string, processed, total int)
}
// EncryptionMigrationPlan contains the complete plan for migration
type EncryptionMigrationPlan struct {
ModelPlans map[string]*ModelMigrationPlan `json:"model_plans"`
EstimatedDuration time.Duration `json:"estimated_duration"`
EstimatedRecords int `json:"estimated_records"`
RecommendedOptions RotationOptions `json:"recommended_options"`
}
// ModelMigrationPlan contains migration details for a specific model
type ModelMigrationPlan struct {
ModelName string `json:"model_name"`
RecordCount int `json:"record_count"`
EstimatedTime time.Duration `json:"estimated_time"`
EncryptedFields []string `json:"encrypted_fields"`
BatchSizeRec int `json:"batch_size_recommendation"`
}