307 lines
12 KiB
Go
307 lines
12 KiB
Go
// 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
|
|
}
|