// Package cli implements command-line interface handlers for OrchestrAD package cli import ( "context" "crypto/sha256" "fmt" "path/filepath" "github.com/Grace-Solutions/OrchestrAD/internal/audit" "github.com/Grace-Solutions/OrchestrAD/internal/auth" "github.com/Grace-Solutions/OrchestrAD/internal/config" "github.com/Grace-Solutions/OrchestrAD/internal/crypto" "github.com/Grace-Solutions/OrchestrAD/internal/db" "github.com/Grace-Solutions/OrchestrAD/internal/logging" "github.com/Grace-Solutions/OrchestrAD/internal/repository" "github.com/Grace-Solutions/OrchestrAD/internal/rules/engine" "github.com/Grace-Solutions/OrchestrAD/internal/rules/runner" "github.com/Grace-Solutions/OrchestrAD/internal/scheduler" "github.com/Grace-Solutions/OrchestrAD/internal/server" "github.com/Grace-Solutions/OrchestrAD/internal/services" "github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr" ) // RunInit initializes the application: validates config, initializes DB, runs migrations func RunInit() error { cfg, err := config.Load() if err != nil { return fmt.Errorf("loading config: %w", err) } logger := logging.Init(cfg.Logging) logger.Info("CLI", "Initializing OrchestrAD...") // Initialize database database, err := db.New(cfg.Database, logger) if err != nil { return fmt.Errorf("initializing database: %w", err) } defer database.Close() // Run migrations if err := database.Migrate(); err != nil { return fmt.Errorf("running migrations: %w", err) } if err := auth.EnsureBootstrapAdmin(database.Conn(), logger); err != nil { return fmt.Errorf("bootstrapping admin user: %w", err) } logger.Info("CLI", "Initialization complete") return nil } // RunForeground runs the application. It always goes through the service // framework (service.Run): interactively this starts the server and blocks // until an interrupt; under a service manager (Windows SCM, systemd, launchd) // it speaks the manager's control protocol. Docker and manual `run` invocations // take the interactive path; the installed service launches `run` too, so both // share exactly one startup path. func RunForeground() error { return runService() } // runServer contains the actual server bring-up: config, database, migrations, // bootstrap, services, scheduler, and the HTTP server. It blocks until ctx is // cancelled (by an interrupt when interactive, or by the service manager's stop // request), then returns after a graceful shutdown. func runServer(ctx context.Context) error { cfg, err := config.Load() if err != nil { return fmt.Errorf("loading config: %w", err) } logger := logging.Init(cfg.Logging) logger.Info("CLI", "Starting OrchestrAD...") // Initialize database database, err := db.New(cfg.Database, logger) if err != nil { return fmt.Errorf("initializing database: %w", err) } defer database.Close() // Run migrations if err := database.Migrate(); err != nil { return fmt.Errorf("running migrations: %w", err) } if err := auth.EnsureBootstrapAdmin(database.Conn(), logger); err != nil { return fmt.Errorf("bootstrapping admin user: %w", err) } // Seed the built-in schedules so operators have ready-made cadences. services.EnsureDefaultSchedules(database.Conn(), logger) // Derive a 32-byte AES key from the configured secret key keyHash := sha256.Sum256(cfg.SecretKey) encryptor, err := crypto.NewEncryptor(keyHash[:]) if err != nil { return fmt.Errorf("initializing encryptor: %w", err) } // Say where the key came from, so an operator can see at a glance whether // the install is on a persisted key (portable) or a supplied one. switch cfg.SecretKeySource { case config.SecretKeySourceGenerated: logger.Info("SecretKey", "Generated a new secret key and stored it at %s - back this file up; "+ "stored credentials cannot be decrypted without it", filepath.Join(cfg.DataPath, config.SecretKeyFileName)) case config.SecretKeySourceLegacy: logger.Warn("SecretKey", "Adopted the legacy built-in secret key for this existing database and wrote it to %s. "+ "It is not secret: rotate it by setting ORCHESTRAD_SECRET_KEY (or replacing that file) and re-entering credential passwords", filepath.Join(cfg.DataPath, config.SecretKeyFileName)) default: logger.Info("SecretKey", "Using the secret key from the %s", cfg.SecretKeySource) } // Confirm the stored credential secrets decrypt with this key. A changed // ORCHESTRAD_SECRET_KEY otherwise only shows up later, as an opaque // "decryption failed" inside whatever operation first needs a bind. services.LogSecretKeyCheck(database.Conn(), encryptor, logger) // Build services, repositories, and the execution pipeline connService := services.NewConnectionService(database.Conn(), encryptor, logger) ruleRunner := runner.New(database.Conn(), connService, logger) auditService := audit.NewService(database.Conn()) settingsService := services.NewSettingsService(database.Conn(), logger) // TLS: on by default (self-managed CA + leaf, auto-renewed, exported under // /tls). Disable via the tls.enabled setting or ORCHESTRAD_TLS_ENABLED // to serve plain HTTP behind a TLS-terminating proxy. A UI value wins over // the env var. var tlsManager *tlsmgr.Manager if settingsService.ResolveBool("tls.enabled", "ORCHESTRAD_TLS_ENABLED", true) { tlsManager = tlsmgr.New( filepath.Join(cfg.DataPath, "tls"), tlsmgr.Options{}, func() tlsmgr.Config { return tlsmgr.Config{ Mode: settingsService.ResolveString("tls.mode", "ORCHESTRAD_TLS_MODE", tlsmgr.ModeAuto), PFXPassword: settingsService.ResolveString("tls.pfx_password", "ORCHESTRAD_TLS_PFX_PASSWORD", "orchestrad"), WindowsThumbprint: settingsService.ResolveString("tls.windows_thumbprint", "ORCHESTRAD_TLS_WINDOWS_THUMBPRINT", ""), } }, logger, ) if err := tlsManager.Ensure(); err != nil { return fmt.Errorf("initializing TLS: %w", err) } go tlsManager.Start(ctx) } else { logger.Info("TLS", "TLS disabled; serving plain HTTP (expecting TLS termination upstream)") } deps := server.Dependencies{ Runner: ruleRunner, Engine: engine.NewEngine(logger), ConnService: connService, AuthService: auth.NewService(database.Conn()), RuleService: services.NewRuleService(database.Conn(), logger), CredService: services.NewCredentialService(database.Conn(), encryptor, logger), AuditService: auditService, RuleRepo: repository.NewRuleRepository(database.Conn()), ConnRepo: repository.NewConnectionRepository(database.Conn()), ScheduleRepo: repository.NewScheduleRepository(database.Conn()), RunRepo: repository.NewRuleRunRepository(database.Conn()), UserRepo: repository.NewUserRepository(database.Conn()), APIKeyService: services.NewAPIKeyService(database.Conn(), logger), BackupService: services.NewBackupService(database, backupDir(cfg), maxBackups, logger), SettingsService: settingsService, DashboardService: services.NewDashboardService(database.Conn(), logger), ActivityService: services.NewActivityService(database.Conn(), logger), ConfigService: services.NewConfigService(database.Conn(), logger), TLS: tlsManager, } // Record a service-start audit event so the trail is bootstrapped _ = auditService.LogSuccess(audit.EventServiceStart, "System", "Start", map[string]any{ "mode": "foreground", }) // Start the scheduler so enabled rules fire automatically. It is bound to // the run context so it stops when the server is asked to shut down. sched := scheduler.New(database.Conn(), ruleRunner, logger) if err := sched.Start(ctx); err != nil { return fmt.Errorf("starting scheduler: %w", err) } defer sched.Stop() // Start background database maintenance (history retention + VACUUM) so the // database does not grow forever. Bound to the run context. services.NewMaintenanceService(database.Conn(), cfg.Maintenance, logger).Start(ctx) // Create and run the HTTP server. srv.Run blocks until ctx is cancelled, // which happens on an interactive interrupt or a service stop request. srv := server.New(cfg, database, deps, logger) return srv.Run(ctx) } // RunInitialize idempotently installs and starts the system service (Windows // service, systemd/upstart/sysv unit, or launchd daemon). Safe to re-run. func RunInitialize() error { return initializeService() } // RunRemove idempotently stops and removes the system service. Safe to re-run. func RunRemove() error { return removeService() } // RunInstall is an alias for RunInitialize: it idempotently installs and starts // the service. func RunInstall() error { return initializeService() } // RunUninstall is an alias for RunRemove: it idempotently stops and removes the // service. func RunUninstall() error { return removeService() } // RunStart starts the installed service. func RunStart() error { if err := controlService("start"); err != nil { return err } logging.Info("Service", "Service started") return nil } // RunStop stops the installed service. func RunStop() error { if err := controlService("stop"); err != nil { return err } logging.Info("Service", "Service stopped") return nil } // RunMigrate applies database migrations func RunMigrate() error { cfg, err := config.Load() if err != nil { return fmt.Errorf("loading config: %w", err) } logger := logging.Init(cfg.Logging) logger.Info("CLI", "Running database migrations...") database, err := db.New(cfg.Database, logger) if err != nil { return fmt.Errorf("initializing database: %w", err) } defer database.Close() if err := database.Migrate(); err != nil { return fmt.Errorf("running migrations: %w", err) } logger.Info("CLI", "Migrations complete") return nil } // maxBackups is how many backup files are retained (oldest pruned beyond it). const maxBackups = 3 // backupDir is where backup files live inside the data directory. func backupDir(cfg *config.Config) string { return filepath.Join(cfg.DataPath, "backups") } // RunBackup creates a manual database backup func RunBackup() error { cfg, err := config.Load() if err != nil { return err } logger := logging.Init(cfg.Logging) database, err := db.New(cfg.Database, logger) if err != nil { return err } defer database.Close() svc := services.NewBackupService(database, backupDir(cfg), maxBackups, logger) backup, err := svc.CreateBackup("manual", "cli") if err != nil { return err } logger.Info("Backup", "Created %s (%d bytes)", backup.FilePath, backup.SizeBytes) return nil } // RunRestore restores the database from a backup file. Unlike the API — which // stages a restore for the next start because the server holds an open // connection pool — the CLI runs standalone, so it stages and applies in one // step by opening the database immediately afterwards. func RunRestore(path string) error { cfg, err := config.Load() if err != nil { return err } logger := logging.Init(cfg.Logging) if err := db.ValidateBackup(path); err != nil { return err } if err := db.StageRestore(cfg.Database.Path, path); err != nil { return err } logger.Info("Restore", "Backup validated and staged from %s", path) // db.New applies the staged file before opening the pool. database, err := db.New(cfg.Database, logger) if err != nil { return fmt.Errorf("opening the restored database: %w", err) } defer database.Close() if err := database.Migrate(); err != nil { return fmt.Errorf("migrating the restored database: %w", err) } logger.Info("Restore", "Restore complete") return nil } // RunDoctor validates system configuration and health func RunDoctor() error { cfg, err := config.Load() if err != nil { return fmt.Errorf("config validation failed: %w", err) } logger := logging.Init(cfg.Logging) logger.Info("Doctor", "Running system health checks...") // Check database database, err := db.New(cfg.Database, logger) if err != nil { logger.Error("Doctor", "Database check failed: %v", err) } else { logger.Info("Doctor", "Database: OK") } // Check secret key length if len(cfg.SecretKey) < 32 { logger.Warn("Doctor", "Secret key should be at least 32 bytes") } else { logger.Info("Doctor", "Secret key: OK") } // Check that the secret key actually opens the stored credentials — a // correctly-sized but *different* key passes the length check above and // still breaks every directory bind. if database != nil { keyHash := sha256.Sum256(cfg.SecretKey) if encryptor, encErr := crypto.NewEncryptor(keyHash[:]); encErr == nil { res, checkErr := services.CheckSecretKey(database.Conn(), encryptor) switch { case checkErr != nil: logger.Warn("Doctor", "Credential decryption check failed to run: %v", checkErr) case res.Total == 0: logger.Info("Doctor", "Credential decryption: no stored secrets to check") case res.OK(): logger.Info("Doctor", "Credential decryption: OK (%d secret(s))", res.Total) default: logger.Error("Doctor", "Credential decryption: %d of %d secret(s) cannot be decrypted with the current ORCHESTRAD_SECRET_KEY: %v. "+ "Restore the original key, or re-enter these passwords.", res.Undecryptable, res.Total, res.Failed) } } database.Close() } logger.Info("Doctor", "Health checks complete") return nil }