63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
380 lines
13 KiB
Go
380 lines
13 KiB
Go
// 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
|
|
// <data>/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
|
|
}
|