Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
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>
2026-09-03 13:57:15 -04:00

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
}