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>
170 lines
5.1 KiB
Go
170 lines
5.1 KiB
Go
// Package db provides database initialization, migrations, and connection management
|
|
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/config"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
"github.com/golang-migrate/migrate/v4"
|
|
"github.com/golang-migrate/migrate/v4/database/sqlite"
|
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// DB wraps the database connection and provides migration support
|
|
type DB struct {
|
|
conn *sql.DB
|
|
logger *logging.Logger
|
|
config config.DatabaseConfig
|
|
}
|
|
|
|
// New creates a new database connection with the given configuration
|
|
func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) {
|
|
// Ensure the database directory exists (SQLite will not create it), and
|
|
// migrate a legacy database from the data-root into the db/ subdirectory so
|
|
// existing installs keep their data after the path change.
|
|
if dir := filepath.Dir(cfg.Path); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, fmt.Errorf("creating database directory: %w", err)
|
|
}
|
|
migrateLegacyDBLocation(cfg.Path, logger)
|
|
}
|
|
|
|
// A restore staged by the API is applied here, before the pool is opened —
|
|
// the only point at which swapping the file is safe.
|
|
applyPendingRestore(cfg.Path, logger)
|
|
|
|
logger.Info("Database", "Opening database at %s", cfg.Path)
|
|
|
|
// Build connection string with pragmas. modernc.org/sqlite applies these
|
|
// _pragma directives on every connection the pool opens, so WAL mode,
|
|
// foreign-key enforcement, and the busy timeout hold for all connections.
|
|
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=busy_timeout(5000)", cfg.Path)
|
|
|
|
conn, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening database: %w", err)
|
|
}
|
|
|
|
// Configure connection pool
|
|
conn.SetMaxOpenConns(cfg.MaxOpenConns)
|
|
conn.SetMaxIdleConns(cfg.MaxIdleConns)
|
|
|
|
// Verify connection
|
|
if err := conn.Ping(); err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("pinging database: %w", err)
|
|
}
|
|
|
|
logger.Info("Database", "Database connection established (WAL mode, foreign keys enabled)")
|
|
|
|
return &DB{
|
|
conn: conn,
|
|
logger: logger,
|
|
config: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// migrateLegacyDBLocation moves a database (and its -wal/-shm sidecars) from the
|
|
// old data-root location into the new db/ subdirectory, once, when the new file
|
|
// does not yet exist. It is best-effort: failures are logged, not fatal.
|
|
func migrateLegacyDBLocation(newPath string, logger *logging.Logger) {
|
|
if _, err := os.Stat(newPath); err == nil {
|
|
return // new database already present
|
|
}
|
|
dbDir := filepath.Dir(newPath)
|
|
legacy := filepath.Join(filepath.Dir(dbDir), filepath.Base(newPath))
|
|
if _, err := os.Stat(legacy); err != nil {
|
|
return // nothing to migrate
|
|
}
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
from, to := legacy+suffix, newPath+suffix
|
|
if _, err := os.Stat(from); err != nil {
|
|
continue
|
|
}
|
|
if err := os.Rename(from, to); err != nil {
|
|
logger.Warn("Database", "Could not move legacy database file %s: %v", from, err)
|
|
}
|
|
}
|
|
logger.Info("Database", "Migrated existing database into %s", dbDir)
|
|
}
|
|
|
|
// Conn returns the underlying sql.DB connection
|
|
func (db *DB) Conn() *sql.DB {
|
|
return db.conn
|
|
}
|
|
|
|
// Path returns the database file path.
|
|
func (db *DB) Path() string {
|
|
return db.config.Path
|
|
}
|
|
|
|
// Close closes the database connection
|
|
func (db *DB) Close() error {
|
|
db.logger.Info("Database", "Closing database connection")
|
|
return db.conn.Close()
|
|
}
|
|
|
|
// Migrate runs database migrations
|
|
func (db *DB) Migrate() error {
|
|
db.logger.Info("Database", "Running database migrations...")
|
|
|
|
// Create migration source from embedded files
|
|
source, err := iofs.New(migrationsFS, "migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("creating migration source: %w", err)
|
|
}
|
|
|
|
// Create migration driver
|
|
driver, err := sqlite.WithInstance(db.conn, &sqlite.Config{})
|
|
if err != nil {
|
|
return fmt.Errorf("creating migration driver: %w", err)
|
|
}
|
|
|
|
// Create migrator
|
|
m, err := migrate.NewWithInstance("iofs", source, "sqlite", driver)
|
|
if err != nil {
|
|
return fmt.Errorf("creating migrator: %w", err)
|
|
}
|
|
|
|
// Run migrations
|
|
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
|
return fmt.Errorf("running migrations: %w", err)
|
|
}
|
|
|
|
version, dirty, err := m.Version()
|
|
if err != nil && err != migrate.ErrNilVersion {
|
|
return fmt.Errorf("getting migration version: %w", err)
|
|
}
|
|
|
|
if dirty {
|
|
db.logger.Warn("Database", "Migration state is dirty at version %d", version)
|
|
} else {
|
|
db.logger.Info("Database", "Database schema at version %d", version)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Backup creates a backup of the database to the specified path
|
|
func (db *DB) Backup(destPath string) error {
|
|
db.logger.Info("Database", "Creating backup to %s", destPath)
|
|
|
|
// Use SQLite backup API via VACUUM INTO
|
|
_, err := db.conn.Exec(fmt.Sprintf("VACUUM INTO '%s'", destPath))
|
|
if err != nil {
|
|
return fmt.Errorf("creating backup: %w", err)
|
|
}
|
|
|
|
db.logger.Info("Database", "Backup completed successfully")
|
|
return nil
|
|
}
|