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

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
}