// Keywarden - Centralized SSH Key Management and Deployment // Copyright (C) 2026 Patrick Asmus (scriptos) // SPDX-License-Identifier: AGPL-3.0-or-later package database import ( "context" "database/sql" "fmt" "os" "path/filepath" _ "github.com/mattn/go-sqlite3" ) // DB wraps the sql.DB connection type DB struct { *sql.DB } // New creates a new database connection and runs migrations func New(dbPath string) (*DB, error) { // Ensure directory exists dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0700); err != nil { return nil, fmt.Errorf("failed to create database directory: %w", err) } db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000") if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } if err := db.Ping(); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } d := &DB{db} if err := d.migrate(); err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) } return d, nil } // migrate creates all required tables func (d *DB) migrate() error { migrations := []string{ `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS ssh_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, key_type TEXT NOT NULL, bits INTEGER, fingerprint TEXT NOT NULL, public_key TEXT NOT NULL, private_key_enc TEXT NOT NULL, passphrase_enc TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, hostname TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22, username TEXT NOT NULL, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS key_deployments ( id INTEGER PRIMARY KEY AUTOINCREMENT, ssh_key_id INTEGER NOT NULL, server_id INTEGER NOT NULL, deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT NOT NULL DEFAULT 'pending', message TEXT, FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, details TEXT, ip_address TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL )`, `CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, // Migration: add MFA columns to users if not present `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT)`, `CREATE TABLE IF NOT EXISTS server_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS server_group_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, server_id INTEGER NOT NULL, FOREIGN KEY (group_id) REFERENCES server_groups(id) ON DELETE CASCADE, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, UNIQUE(group_id, server_id) )`, `CREATE TABLE IF NOT EXISTS access_assignments ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, ssh_key_id INTEGER NOT NULL, server_id INTEGER DEFAULT 0, group_id INTEGER DEFAULT 0, system_user TEXT NOT NULL, desired_state TEXT NOT NULL DEFAULT 'present', sudo INTEGER NOT NULL DEFAULT 0, create_user INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', last_sync_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS cron_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, ssh_key_id INTEGER NOT NULL, server_id INTEGER DEFAULT 0, group_id INTEGER DEFAULT 0, schedule TEXT NOT NULL DEFAULT 'once', scheduled_at DATETIME NOT NULL, next_run DATETIME NOT NULL, last_run DATETIME, remove_after_min INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE )`, } for _, m := range migrations { if _, err := d.Exec(m); err != nil { return fmt.Errorf("migration failed: %w", err) } } // Conditional migrations (ALTER TABLE) alterMigrations := map[string]string{ "add_mfa_enabled": `ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0`, "add_mfa_secret": `ALTER TABLE users ADD COLUMN mfa_secret TEXT DEFAULT ''`, "add_is_master_key": `ALTER TABLE ssh_keys ADD COLUMN is_master INTEGER NOT NULL DEFAULT 0`, "add_user_theme": `ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto'`, "add_email_notify_login": `ALTER TABLE users ADD COLUMN email_notify_login INTEGER NOT NULL DEFAULT 0`, "add_avatar_base64": `ALTER TABLE users ADD COLUMN avatar_base64 TEXT NOT NULL DEFAULT ''`, "add_cron_auth_key_id": `ALTER TABLE cron_jobs ADD COLUMN auth_key_id INTEGER NOT NULL DEFAULT 0`, "add_initial_password": `ALTER TABLE access_assignments ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`, "add_cron_timezone": `ALTER TABLE cron_jobs ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'`, "add_cron_time_of_day": `ALTER TABLE cron_jobs ADD COLUMN time_of_day TEXT NOT NULL DEFAULT '00:00'`, "add_cron_day_of_week": `ALTER TABLE cron_jobs ADD COLUMN day_of_week INTEGER NOT NULL DEFAULT -1`, "add_cron_day_of_month": `ALTER TABLE cron_jobs ADD COLUMN day_of_month INTEGER NOT NULL DEFAULT 0`, "add_cron_minute_of_hour": `ALTER TABLE cron_jobs ADD COLUMN minute_of_hour INTEGER NOT NULL DEFAULT 0`, "add_cron_target_user_id": `ALTER TABLE cron_jobs ADD COLUMN target_user_id INTEGER NOT NULL DEFAULT 0`, "add_cron_assignment_id": `ALTER TABLE cron_jobs ADD COLUMN assignment_id INTEGER NOT NULL DEFAULT 0`, "add_cron_system_user": `ALTER TABLE cron_jobs ADD COLUMN system_user TEXT NOT NULL DEFAULT ''`, "add_cron_sudo": `ALTER TABLE cron_jobs ADD COLUMN sudo INTEGER NOT NULL DEFAULT 0`, "add_cron_create_user": `ALTER TABLE cron_jobs ADD COLUMN create_user INTEGER NOT NULL DEFAULT 0`, "add_cron_init_password": `ALTER TABLE cron_jobs ADD COLUMN initial_password TEXT NOT NULL DEFAULT ''`, "add_cron_expiry_action": `ALTER TABLE cron_jobs ADD COLUMN expiry_action TEXT NOT NULL DEFAULT 'remove_key'`, "add_must_change_password": `ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`, "add_failed_login_attempts": `ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0`, "add_locked_until": `ALTER TABLE users ADD COLUMN locked_until DATETIME`, "add_last_login_at": `ALTER TABLE users ADD COLUMN last_login_at DATETIME`, } // Invitation tokens table (created via migration to avoid altering initial schema) inviteTableMigration := map[string]string{ "create_invitation_tokens": `CREATE TABLE IF NOT EXISTS invitation_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, expires_at DATETIME NOT NULL, used INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, } for name, sql := range inviteTableMigration { var count int d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count) if count == 0 { if _, err := d.Exec(sql); err != nil { return fmt.Errorf("migration %s failed: %w", name, err) } d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name) } } for name, sql := range alterMigrations { var count int d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count) if count == 0 { d.Exec(sql) // ignore error if column already exists d.Exec(`INSERT INTO _migrations (name) VALUES (?)`, name) } } // Role model migration: promote first admin to owner if no owner exists yet { var migCount int d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'promote_admin_to_owner'`).Scan(&migCount) if migCount == 0 { var ownerCount int d.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'owner'`).Scan(&ownerCount) if ownerCount == 0 { // Find the first admin (by ID) and promote to owner var firstAdminID int64 err := d.QueryRow(`SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1`).Scan(&firstAdminID) if err == nil && firstAdminID > 0 { d.Exec(`UPDATE users SET role = 'owner' WHERE id = ?`, firstAdminID) } } d.Exec(`INSERT INTO _migrations (name) VALUES ('promote_admin_to_owner')`) } } // Migration: backfill initial_owner_id for existing installations { var migCount int d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'backfill_initial_owner_id'`).Scan(&migCount) if migCount == 0 { // Only set if not already present (new installs set it in EnsureAdmin) var existing string err := d.QueryRow(`SELECT value FROM settings WHERE key = 'initial_owner_id'`).Scan(&existing) if err != nil || existing == "" { // Pick the oldest owner (lowest ID) as the initial owner var ownerID int64 err := d.QueryRow(`SELECT id FROM users WHERE role = 'owner' ORDER BY id ASC LIMIT 1`).Scan(&ownerID) if err == nil && ownerID > 0 { d.Exec(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('initial_owner_id', CAST(? AS TEXT), CURRENT_TIMESTAMP)`, ownerID) } } d.Exec(`INSERT INTO _migrations (name) VALUES ('backfill_initial_owner_id')`) } } // Migration: recreate key_deployments with nullable ssh_key_id and key_name column // This is needed so the system master key (which has no ssh_keys row) can be logged. { var migCount int d.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = 'key_deployments_nullable_keyid'`).Scan(&migCount) if migCount == 0 { ctx := context.Background() conn, err := d.DB.Conn(ctx) if err == nil { conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`) conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS key_deployments_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, ssh_key_id INTEGER, server_id INTEGER NOT NULL, deployed_at DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT NOT NULL DEFAULT 'pending', message TEXT, key_name TEXT NOT NULL DEFAULT '', FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE CASCADE, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE )`) conn.ExecContext(ctx, `INSERT INTO key_deployments_new (id, ssh_key_id, server_id, deployed_at, status, message, key_name) SELECT kd.id, kd.ssh_key_id, kd.server_id, kd.deployed_at, kd.status, kd.message, COALESCE(sk.name, '') FROM key_deployments kd LEFT JOIN ssh_keys sk ON kd.ssh_key_id = sk.id`) conn.ExecContext(ctx, `DROP TABLE key_deployments`) conn.ExecContext(ctx, `ALTER TABLE key_deployments_new RENAME TO key_deployments`) conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`) conn.Close() } d.Exec(`INSERT INTO _migrations (name) VALUES ('key_deployments_nullable_keyid')`) } } return nil }