backup: pre-hardening baseline

This commit is contained in:
backup 2026-09-02 11:20:31 -05:00
commit 9e4c612dcb
57 changed files with 10393 additions and 0 deletions

View file

@ -0,0 +1,144 @@
package database
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
type DB struct {
*sql.DB
}
func New(path string) (*DB, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
db, err := sql.Open("sqlite3", path+"?_foreign_keys=on&_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(1)
return &DB{db}, nil
}
func (d *DB) Migrate() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)`,
`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
CHECK (expires_at > created_at)
)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS backups (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
reason TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_backups_filename ON backups(filename)`,
`CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at)`,
}
for _, q := range queries {
if _, err := d.Exec(q); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return d.ensureAdminUser()
}
func (d *DB) ensureAdminUser() error {
var count int
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if err != nil {
return err
}
if count == 0 {
// Default admin: admin / changeme (bcrypt hash) — development-only
hash := "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju"
_, err = d.Exec(
`INSERT INTO users (id, username, email, password_hash, role) VALUES (?, ?, ?, ?, ?)`,
"admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", hash, "admin",
)
if err != nil {
return fmt.Errorf("create admin user: %w", err)
}
}
return nil
}
// EnsureAdminPasswordViaEnv enforces production password policy.
// If envPassword is set, it must be >=12 chars; it will create or update the admin user.
// If devMode is false and the default admin/changeme is still in use, it logs a warning.
func (d *DB) EnsureAdminPasswordViaEnv(envPassword string, devMode bool) error {
if envPassword != "" {
if len(envPassword) < 12 {
return fmt.Errorf("GUI_ADMIN_PASSWORD must be at least 12 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(envPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash admin password: %w", err)
}
// Upsert admin user
_, err = d.Exec(`
INSERT INTO users (id, username, email, password_hash, role)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash=excluded.password_hash, updated_at=CURRENT_TIMESTAMP
`, "admin-uuid-0000-0000-000000000001", "admin", "admin@localhost", string(hash), "admin")
if err != nil {
return fmt.Errorf("upsert admin via env: %w", err)
}
log.Println("Admin password set via GUI_ADMIN_PASSWORD")
return nil
}
if !devMode {
// Check if default password still in use
var hash string
err := d.QueryRow(`SELECT password_hash FROM users WHERE username='admin'`).Scan(&hash)
if err == nil {
// Compare against known dev hash
if hash == "$2a$10$KsL.67hxLy.jwc50Uk7b3.dEmO1LNE3atnfUjNskAKlh9raiug4ju" {
log.Println("WARNING: default admin password 'changeme' is in use — set GUI_ADMIN_PASSWORD (min 12 chars) for production")
}
// Also try bcrypt check in case hash was regenerated for same password
if bcrypt.CompareHashAndPassword([]byte(hash), []byte("changeme")) == nil {
log.Println("WARNING: admin password is still 'changeme' — change it or set GUI_ADMIN_PASSWORD for production")
}
}
}
return nil
}