Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
)
|
||||
|
||||
// Action constants for audit logging
|
||||
const (
|
||||
// Authentication
|
||||
ActionLoginSuccess = "login_success"
|
||||
ActionLoginFailed = "login_failed"
|
||||
ActionLogout = "logout"
|
||||
ActionMFASuccess = "mfa_verified"
|
||||
ActionMFAFailed = "mfa_failed"
|
||||
ActionMFAEnabled = "mfa_enabled"
|
||||
ActionMFADisabled = "mfa_disabled"
|
||||
|
||||
// SSH Keys
|
||||
ActionKeyGenerated = "key_generated"
|
||||
ActionKeyImported = "key_imported"
|
||||
ActionKeyDeleted = "key_deleted"
|
||||
ActionKeyDownload = "key_downloaded"
|
||||
|
||||
// Servers
|
||||
ActionServerAdded = "server_added"
|
||||
ActionServerUpdated = "server_updated"
|
||||
ActionServerDeleted = "server_deleted"
|
||||
ActionServerTest = "server_test"
|
||||
ActionServerAuth = "server_auth_test"
|
||||
|
||||
// Server Groups
|
||||
ActionGroupCreated = "group_created"
|
||||
ActionGroupUpdated = "group_updated"
|
||||
ActionGroupDeleted = "group_deleted"
|
||||
ActionGroupServerAdded = "group_server_added"
|
||||
ActionGroupServerRemoved = "group_server_removed"
|
||||
ActionGroupDeploy = "group_deploy"
|
||||
|
||||
// Deployments
|
||||
ActionDeploySuccess = "deploy_success"
|
||||
ActionDeployFailed = "deploy_failed"
|
||||
|
||||
// User Management (admin)
|
||||
ActionUserCreated = "user_created"
|
||||
ActionUserUpdated = "user_updated"
|
||||
ActionUserDeleted = "user_deleted"
|
||||
|
||||
// Settings
|
||||
ActionSettingsChanged = "settings_changed"
|
||||
ActionPasswordChanged = "password_changed"
|
||||
ActionMasterKeyRegen = "masterkey_regenerated"
|
||||
ActionMasterKeyRegenerated = "masterkey_regenerated"
|
||||
ActionMasterKeyRegenFailed = "masterkey_regen_failed"
|
||||
ActionAvatarChanged = "avatar_changed"
|
||||
|
||||
// Email
|
||||
ActionEmailNotifyChanged = "email_notify_changed"
|
||||
ActionEmailTestSent = "email_test_sent"
|
||||
ActionEmailTestFailed = "email_test_failed"
|
||||
ActionEmailLoginSent = "email_login_sent"
|
||||
ActionEmailLoginFailed = "email_login_failed"
|
||||
|
||||
// Access Assignments
|
||||
ActionAssignmentCreated = "assignment_created"
|
||||
ActionAssignmentUpdated = "assignment_updated"
|
||||
ActionAssignmentDeleted = "assignment_deleted"
|
||||
ActionAssignmentSynced = "assignment_synced"
|
||||
ActionAssignmentSyncFailed = "assignment_sync_failed"
|
||||
ActionAssignmentKeyRemoved = "assignment_key_removed"
|
||||
ActionAssignmentUserDeleted = "assignment_user_deleted"
|
||||
ActionAssignmentCleanFailed = "assignment_cleanup_failed"
|
||||
|
||||
// Cron Jobs
|
||||
ActionCronJobCreated = "cron_job_created"
|
||||
ActionCronJobUpdated = "cron_job_updated"
|
||||
ActionCronJobDeleted = "cron_job_deleted"
|
||||
ActionCronJobPaused = "cron_job_paused"
|
||||
ActionCronJobResumed = "cron_job_resumed"
|
||||
ActionCronJobExecuted = "cron_job_executed"
|
||||
ActionCronJobFailed = "cron_job_failed"
|
||||
ActionCronJobKeyRemoved = "cron_job_key_removed"
|
||||
|
||||
// Account Security
|
||||
ActionAccountLocked = "account_locked"
|
||||
ActionAccountUnlocked = "account_unlocked"
|
||||
ActionForcePasswordChange = "force_password_change"
|
||||
ActionMFAEnforced = "mfa_enforced"
|
||||
ActionPasswordPolicyChanged = "password_policy_changed"
|
||||
|
||||
// Backup & Restore
|
||||
ActionBackupExported = "backup_exported"
|
||||
ActionBackupExportFailed = "backup_export_failed"
|
||||
ActionBackupImported = "backup_imported"
|
||||
ActionBackupImportFailed = "backup_import_failed"
|
||||
|
||||
// Invitations
|
||||
ActionInvitationSent = "invitation_sent"
|
||||
ActionInvitationSendFailed = "invitation_send_failed"
|
||||
ActionInvitationAccepted = "invitation_accepted"
|
||||
ActionInvitationFailed = "invitation_failed"
|
||||
)
|
||||
|
||||
// AuditEntry extends AuditLog with the username for display
|
||||
type AuditEntry struct {
|
||||
models.AuditLog
|
||||
Username string
|
||||
}
|
||||
|
||||
// Service handles audit log operations
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewService creates a new audit service
|
||||
func NewService(db *database.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Log records an audit event. Errors are logged but never returned to avoid
|
||||
// disrupting the main application flow.
|
||||
func (s *Service) Log(userID int64, action, details, ipAddress string) {
|
||||
var uid interface{}
|
||||
if userID == 0 {
|
||||
uid = sql.NullInt64{Valid: false}
|
||||
} else {
|
||||
uid = userID
|
||||
}
|
||||
|
||||
logging.Debug("Audit: action=%s user_id=%d ip=%s details=%s", action, userID, ipAddress, details)
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)`,
|
||||
uid, action, details, ipAddress,
|
||||
)
|
||||
if err != nil {
|
||||
logging.Warn("Failed to write audit log: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns paginated audit entries for all users (admin view).
|
||||
// Returns entries, total count, and error.
|
||||
func (s *Service) GetAll(page, perPage int) ([]AuditEntry, int, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log`).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := s.db.Query(
|
||||
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username,
|
||||
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
|
||||
a.created_at
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
perPage, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// GetAllExceptOwners returns paginated audit entries excluding entries from
|
||||
// users with the "owner" role. This is the admin view.
|
||||
func (s *Service) GetAllExceptOwners(page, perPage int) ([]AuditEntry, int, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE u.role IS NULL OR u.role != 'owner'`,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := s.db.Query(
|
||||
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(system)') AS username,
|
||||
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
|
||||
a.created_at
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE u.role IS NULL OR u.role != 'owner'
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
perPage, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
// GetByUser returns paginated audit entries for a specific user.
|
||||
func (s *Service) GetByUser(userID int64, page, perPage int) ([]AuditEntry, int, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 50
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE user_id = ?`, userID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to count audit logs: %w", err)
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
rows, err := s.db.Query(
|
||||
`SELECT a.id, COALESCE(a.user_id, 0), COALESCE(u.username, '(deleted)') AS username,
|
||||
a.action, COALESCE(a.details, ''), COALESCE(a.ip_address, ''),
|
||||
a.created_at
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.user_id = ?
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
userID, perPage, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to query audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Action, &e.Details, &e.IPAddress, &e.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan audit entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, total, nil
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUserExists = errors.New("username or email already exists")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrMFARequired = errors.New("mfa verification required")
|
||||
ErrInvalidMFACode = errors.New("invalid MFA code")
|
||||
ErrAccountLocked = errors.New("account is temporarily locked")
|
||||
)
|
||||
|
||||
// Service handles user authentication
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewService creates a new auth service
|
||||
func NewService(db *database.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Register creates a new user account. If mustChangePassword is true, the user
|
||||
// will be forced to change their password on next login.
|
||||
func (s *Service) Register(username, email, password, role string, mustChangePassword bool) (*models.User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
mcp := 0
|
||||
if mustChangePassword {
|
||||
mcp = 1
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, ?)`,
|
||||
username, email, string(hash), role, mcp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Role: role,
|
||||
MustChangePassword: mustChangePassword,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns the user if successful
|
||||
func (s *Service) Login(username, password string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, must_change_password, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE username = ?`,
|
||||
username,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query user: %w", err)
|
||||
}
|
||||
|
||||
// Check account lockout
|
||||
if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
|
||||
return nil, ErrAccountLocked
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByID returns a user by their ID
|
||||
func (s *Service) GetUserByID(id int64) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users WHERE id = ?`,
|
||||
id,
|
||||
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.MFAEnabled, &user.MFASecret, &user.Theme, &user.EmailNotifyLogin, &user.AvatarBase64, &user.MustChangePassword, &user.FailedLoginAttempts, &user.LockedUntil, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetAllUsers returns all registered users (admin only)
|
||||
func (s *Service) GetAllUsers() ([]models.User, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, username, email, role, mfa_enabled, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []models.User
|
||||
for rows.Next() {
|
||||
var u models.User
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.Role, &u.MFAEnabled, &u.MustChangePassword, &u.FailedLoginAttempts, &u.LockedUntil, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan user: %w", err)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// HasUsers checks if any users exist in the database
|
||||
func (s *Service) HasUsers() (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// EnsureAdmin creates a default owner user if no users exist.
|
||||
// It auto-generates a secure password and returns (created, generatedPassword, error).
|
||||
func (s *Service) EnsureAdmin(username, email string) (bool, string, error) {
|
||||
hasUsers, err := s.HasUsers()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if hasUsers {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// Generate a secure random password (20 chars, base62)
|
||||
password, err := generateSecurePassword(20)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to generate password: %w", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)`,
|
||||
username, email, string(hash), "owner",
|
||||
)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return true, password, nil
|
||||
}
|
||||
|
||||
// generateSecurePassword creates a cryptographically secure random password
|
||||
func generateSecurePassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[b[i]%byte(len(charset))]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// UpdateUser updates user details (admin function)
|
||||
func (s *Service) UpdateUser(id int64, username, email, role string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET username = ?, email = ?, role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
username, email, role, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePassword changes a user's password
|
||||
func (s *Service) UpdatePassword(id int64, newPassword string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
string(hash), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user
|
||||
func (s *Service) DeleteUser(id int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByRole counts how many users have the given role
|
||||
func (s *Service) CountByRole(role string) (int, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = ?`, role).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count users by role: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GenerateMFASecret generates a random TOTP secret
|
||||
func (s *Service) GenerateMFASecret() string {
|
||||
secret := make([]byte, 20)
|
||||
rand.Read(secret)
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
|
||||
}
|
||||
|
||||
// EnableMFA stores the MFA secret for a user
|
||||
func (s *Service) EnableMFA(userID int64, secret string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
secret, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DisableMFA removes MFA for a user
|
||||
func (s *Service) DisableMFA(userID int64) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET mfa_enabled = 0, mfa_secret = '', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateTheme updates the user's theme preference (auto, light, dark)
|
||||
func (s *Service) UpdateTheme(id int64, theme string) error {
|
||||
if theme != "auto" && theme != "light" && theme != "dark" {
|
||||
theme = "auto"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET theme = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
theme, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update theme: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateEmailNotifyLogin updates the user's login email notification setting
|
||||
func (s *Service) UpdateEmailNotifyLogin(id int64, enabled bool) error {
|
||||
val := 0
|
||||
if enabled {
|
||||
val = 1
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET email_notify_login = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
val, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update email notification setting: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAvatar updates the user's profile picture (base64-encoded data URI)
|
||||
func (s *Service) UpdateAvatar(id int64, avatarBase64 string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET avatar_base64 = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
avatarBase64, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update avatar: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LegacyAvatar holds the minimal info needed for avatar migration
|
||||
type LegacyAvatar struct {
|
||||
ID int64
|
||||
AvatarBase64 string
|
||||
}
|
||||
|
||||
// GetUsersWithLegacyAvatars returns users whose avatar_base64 contains a data URI (legacy format)
|
||||
func (s *Service) GetUsersWithLegacyAvatars() ([]LegacyAvatar, error) {
|
||||
rows, err := s.db.Query(`SELECT id, avatar_base64 FROM users WHERE avatar_base64 LIKE 'data:%'`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query legacy avatars: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []LegacyAvatar
|
||||
for rows.Next() {
|
||||
var la LegacyAvatar
|
||||
if err := rows.Scan(&la.ID, &la.AvatarBase64); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan legacy avatar: %w", err)
|
||||
}
|
||||
results = append(results, la)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetSetting reads a setting value
|
||||
func (s *Service) GetSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
// SetSetting writes a setting value
|
||||
func (s *Service) SetSetting(key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||
key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetSettingsBatch writes multiple settings in a single transaction
|
||||
func (s *Service) SetSettingsBatch(settings map[string]string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for k, v := range settings {
|
||||
if _, err := stmt.Exec(k, v); err != nil {
|
||||
return fmt.Errorf("failed to save setting %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllSettings returns all settings as a map
|
||||
func (s *Service) GetAllSettings() (map[string]string, error) {
|
||||
rows, err := s.db.Query(`SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
settings := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings[k] = v
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// --- Password Policy ---
|
||||
|
||||
// GetPasswordPolicy returns the current password policy from settings.
|
||||
// Missing settings default to sensible values.
|
||||
func (s *Service) GetPasswordPolicy() models.PasswordPolicy {
|
||||
policy := models.PasswordPolicy{
|
||||
MinLength: 8,
|
||||
RequireUpper: true,
|
||||
RequireLower: true,
|
||||
RequireDigit: true,
|
||||
RequireSpecial: false,
|
||||
}
|
||||
if v, _ := s.GetSetting("pw_min_length"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 4 {
|
||||
policy.MinLength = n
|
||||
}
|
||||
}
|
||||
if v, _ := s.GetSetting("pw_require_upper"); v == "false" {
|
||||
policy.RequireUpper = false
|
||||
}
|
||||
if v, _ := s.GetSetting("pw_require_lower"); v == "false" {
|
||||
policy.RequireLower = false
|
||||
}
|
||||
if v, _ := s.GetSetting("pw_require_digit"); v == "false" {
|
||||
policy.RequireDigit = false
|
||||
}
|
||||
if v, _ := s.GetSetting("pw_require_special"); v == "true" {
|
||||
policy.RequireSpecial = true
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
// ValidatePasswordPolicy checks a password against the configured policy.
|
||||
// Returns nil if the password is compliant, otherwise a descriptive error.
|
||||
func (s *Service) ValidatePasswordPolicy(password string) error {
|
||||
policy := s.GetPasswordPolicy()
|
||||
var violations []string
|
||||
|
||||
if len(password) < policy.MinLength {
|
||||
violations = append(violations, fmt.Sprintf("at least %d characters", policy.MinLength))
|
||||
}
|
||||
if policy.RequireUpper {
|
||||
hasUpper := false
|
||||
for _, r := range password {
|
||||
if unicode.IsUpper(r) {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUpper {
|
||||
violations = append(violations, "at least one uppercase letter")
|
||||
}
|
||||
}
|
||||
if policy.RequireLower {
|
||||
hasLower := false
|
||||
for _, r := range password {
|
||||
if unicode.IsLower(r) {
|
||||
hasLower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLower {
|
||||
violations = append(violations, "at least one lowercase letter")
|
||||
}
|
||||
}
|
||||
if policy.RequireDigit {
|
||||
hasDigit := false
|
||||
for _, r := range password {
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit {
|
||||
violations = append(violations, "at least one digit")
|
||||
}
|
||||
}
|
||||
if policy.RequireSpecial {
|
||||
hasSpecial := false
|
||||
for _, r := range password {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
hasSpecial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSpecial {
|
||||
violations = append(violations, "at least one special character")
|
||||
}
|
||||
}
|
||||
|
||||
if len(violations) > 0 {
|
||||
return fmt.Errorf("Password must contain: %s.", strings.Join(violations, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Account Lockout ---
|
||||
|
||||
// RecordFailedLogin increments the failed login counter for a username
|
||||
// and locks the account if the threshold is reached.
|
||||
func (s *Service) RecordFailedLogin(username string) {
|
||||
maxAttempts := 5
|
||||
lockDuration := 15 // minutes
|
||||
|
||||
if v, _ := s.GetSetting("lockout_attempts"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
maxAttempts = n
|
||||
}
|
||||
}
|
||||
if v, _ := s.GetSetting("lockout_duration"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
lockDuration = n
|
||||
}
|
||||
}
|
||||
|
||||
// lockout_attempts == 0 means lockout is disabled
|
||||
if maxAttempts == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
s.db.Exec(`UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE username = ?`, username)
|
||||
|
||||
// Check if threshold reached
|
||||
var attempts int
|
||||
err := s.db.QueryRow(`SELECT failed_login_attempts FROM users WHERE username = ?`, username).Scan(&attempts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if attempts >= maxAttempts {
|
||||
lockUntil := time.Now().Add(time.Duration(lockDuration) * time.Minute)
|
||||
s.db.Exec(`UPDATE users SET locked_until = ? WHERE username = ?`, lockUntil, username)
|
||||
}
|
||||
}
|
||||
|
||||
// ResetFailedLogins clears the failed login counter and lock for a user
|
||||
func (s *Service) ResetFailedLogins(userID int64) {
|
||||
s.db.Exec(`UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?`, userID)
|
||||
}
|
||||
|
||||
// UnlockAccount clears the lock for a user (admin action)
|
||||
func (s *Service) UnlockAccount(userID int64) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET failed_login_attempts = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Force Password Change ---
|
||||
|
||||
// SetMustChangePassword sets or clears the must_change_password flag
|
||||
func (s *Service) SetMustChangePassword(userID int64, must bool) error {
|
||||
val := 0
|
||||
if must {
|
||||
val = 1
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE users SET must_change_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
val, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Last Login Tracking ---
|
||||
|
||||
// UpdateLastLogin records the current time as the user's last login
|
||||
func (s *Service) UpdateLastLogin(userID int64) {
|
||||
s.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
|
||||
}
|
||||
|
||||
// --- Invitation Tokens ---
|
||||
|
||||
// CreateInvitationToken generates a secure random token for a user invitation.
|
||||
// The token expires after the given duration.
|
||||
func (s *Service) CreateInvitationToken(userID int64, expiry time.Duration) (string, error) {
|
||||
// Generate a 32-byte random token
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
token := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(tokenBytes)
|
||||
|
||||
expiresAt := time.Now().Add(expiry)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO invitation_tokens (user_id, token, expires_at) VALUES (?, ?, ?)`,
|
||||
userID, token, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store invitation token: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetInvitationByToken retrieves a valid (unused, not expired) invitation.
|
||||
func (s *Service) GetInvitationByToken(token string) (*models.InvitationToken, error) {
|
||||
inv := &models.InvitationToken{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, token, expires_at, used, created_at FROM invitation_tokens WHERE token = ?`,
|
||||
token,
|
||||
).Scan(&inv.ID, &inv.UserID, &inv.Token, &inv.ExpiresAt, &inv.Used, &inv.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invitation not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query invitation: %w", err)
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// CompleteInvitation sets the user's password and marks the invitation as used.
|
||||
func (s *Service) CompleteInvitation(token string, newPassword string) (*models.User, error) {
|
||||
inv, err := s.GetInvitationByToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inv.Used {
|
||||
return nil, fmt.Errorf("invitation has already been used")
|
||||
}
|
||||
if time.Now().After(inv.ExpiresAt) {
|
||||
return nil, fmt.Errorf("invitation has expired")
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Update the user's password and clear must_change_password flag
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
string(hash), inv.UserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update user password: %w", err)
|
||||
}
|
||||
|
||||
// Mark the invitation as used
|
||||
_, err = s.db.Exec(`UPDATE invitation_tokens SET used = 1 WHERE id = ?`, inv.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to mark invitation as used: %w", err)
|
||||
}
|
||||
|
||||
// Return the user
|
||||
return s.GetUserByID(inv.UserID)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//go:build integration
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *database.DB {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
db, err := database.New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRegisterAndLogin(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
user, err := svc.Register("testuser", "test@example.com", "password123", "user", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
if user.Username != "testuser" {
|
||||
t.Fatalf("Expected username 'testuser', got %q", user.Username)
|
||||
}
|
||||
if user.Role != "user" {
|
||||
t.Fatalf("Expected role 'user', got %q", user.Role)
|
||||
}
|
||||
|
||||
// Login with correct credentials
|
||||
loggedIn, err := svc.Login("testuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login failed: %v", err)
|
||||
}
|
||||
if loggedIn.ID != user.ID {
|
||||
t.Fatalf("Login returned different user ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
svc.Register("testuser", "test@example.com", "password123", "user", false)
|
||||
|
||||
_, err := svc.Login("testuser", "wrongpassword")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Fatalf("Expected ErrInvalidCredentials, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginNonexistentUser(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
_, err := svc.Login("nonexistent", "password123")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Fatalf("Expected ErrInvalidCredentials, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
_, err := svc.Register("testuser", "test@example.com", "pass1", "user", false)
|
||||
if err != nil {
|
||||
t.Fatalf("First register failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Register("testuser", "test2@example.com", "pass2", "user", false)
|
||||
if err != ErrUserExists {
|
||||
t.Fatalf("Expected ErrUserExists, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDefaultRole(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
user, err := svc.Register("testuser", "test@example.com", "pass", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Register failed: %v", err)
|
||||
}
|
||||
if user.Role != "user" {
|
||||
t.Fatalf("Expected default role 'user', got %q", user.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByID(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "pass", "admin", false)
|
||||
|
||||
user, err := svc.GetUserByID(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID failed: %v", err)
|
||||
}
|
||||
if user.Username != "testuser" {
|
||||
t.Fatalf("Expected username 'testuser', got %q", user.Username)
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
t.Fatalf("Expected role 'admin', got %q", user.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByIDNotFound(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
_, err := svc.GetUserByID(999)
|
||||
if err != ErrUserNotFound {
|
||||
t.Fatalf("Expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
|
||||
|
||||
err := svc.UpdateUser(created.ID, "newname", "new@example.com", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUser failed: %v", err)
|
||||
}
|
||||
|
||||
user, _ := svc.GetUserByID(created.ID)
|
||||
if user.Username != "newname" {
|
||||
t.Fatalf("Expected updated username 'newname', got %q", user.Username)
|
||||
}
|
||||
if user.Email != "new@example.com" {
|
||||
t.Fatalf("Expected updated email, got %q", user.Email)
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
t.Fatalf("Expected updated role 'admin', got %q", user.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePassword(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "oldpass", "user", false)
|
||||
|
||||
err := svc.UpdatePassword(created.ID, "newpass")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePassword failed: %v", err)
|
||||
}
|
||||
|
||||
// Old password should fail
|
||||
_, err = svc.Login("testuser", "oldpass")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Fatalf("Old password should no longer work")
|
||||
}
|
||||
|
||||
// New password should work
|
||||
_, err = svc.Login("testuser", "newpass")
|
||||
if err != nil {
|
||||
t.Fatalf("Login with new password failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
|
||||
|
||||
err := svc.DeleteUser(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteUser failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.GetUserByID(created.ID)
|
||||
if err != ErrUserNotFound {
|
||||
t.Fatalf("Expected ErrUserNotFound after deletion, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserNotFound(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
err := svc.DeleteUser(999)
|
||||
if err != ErrUserNotFound {
|
||||
t.Fatalf("Expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAdmin(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
created, generatedPass, err := svc.EnsureAdmin("admin", "admin@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAdmin failed: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("Expected user to be created")
|
||||
}
|
||||
if len(generatedPass) != 20 {
|
||||
t.Fatalf("Expected 20-char generated password, got %d chars", len(generatedPass))
|
||||
}
|
||||
|
||||
// Admin should be loginable with generated password
|
||||
user, err := svc.Login("admin", generatedPass)
|
||||
if err != nil {
|
||||
t.Fatalf("Login as admin failed: %v", err)
|
||||
}
|
||||
if user.Role != "owner" {
|
||||
t.Fatalf("Expected owner role, got %q", user.Role)
|
||||
}
|
||||
|
||||
// Second call should be no-op
|
||||
created2, _, err := svc.EnsureAdmin("admin2", "admin2@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Second EnsureAdmin should not fail: %v", err)
|
||||
}
|
||||
if created2 {
|
||||
t.Fatal("Second EnsureAdmin should not create a user")
|
||||
}
|
||||
|
||||
// admin2 should NOT exist (was skipped)
|
||||
_, err = svc.Login("admin2", "anypass")
|
||||
if err != ErrInvalidCredentials {
|
||||
t.Fatalf("admin2 should not have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllUsers(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
svc.Register("user1", "user1@test.com", "pass1", "user", false)
|
||||
svc.Register("user2", "user2@test.com", "pass2", "admin", false)
|
||||
|
||||
users, err := svc.GetAllUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllUsers failed: %v", err)
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("Expected 2 users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUsers(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
has, err := svc.HasUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("HasUsers failed: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Fatal("Expected no users initially")
|
||||
}
|
||||
|
||||
svc.Register("user1", "user1@test.com", "pass1", "user", false)
|
||||
|
||||
has, err = svc.HasUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("HasUsers failed: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Fatal("Expected users after registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
// Get non-existent setting
|
||||
val, err := svc.GetSetting("app_name")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting failed: %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Fatalf("Expected empty value, got %q", val)
|
||||
}
|
||||
|
||||
// Set and get
|
||||
err = svc.SetSetting("app_name", "Keywarden Test")
|
||||
if err != nil {
|
||||
t.Fatalf("SetSetting failed: %v", err)
|
||||
}
|
||||
|
||||
val, err = svc.GetSetting("app_name")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSetting failed: %v", err)
|
||||
}
|
||||
if val != "Keywarden Test" {
|
||||
t.Fatalf("Expected 'Keywarden Test', got %q", val)
|
||||
}
|
||||
|
||||
// Update existing
|
||||
err = svc.SetSetting("app_name", "Updated")
|
||||
if err != nil {
|
||||
t.Fatalf("SetSetting update failed: %v", err)
|
||||
}
|
||||
|
||||
val, _ = svc.GetSetting("app_name")
|
||||
if val != "Updated" {
|
||||
t.Fatalf("Expected 'Updated', got %q", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllSettings(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
svc.SetSetting("key1", "val1")
|
||||
svc.SetSetting("key2", "val2")
|
||||
|
||||
settings, err := svc.GetAllSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllSettings failed: %v", err)
|
||||
}
|
||||
if len(settings) != 2 {
|
||||
t.Fatalf("Expected 2 settings, got %d", len(settings))
|
||||
}
|
||||
if settings["key1"] != "val1" || settings["key2"] != "val2" {
|
||||
t.Fatal("Settings values mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMFASecret(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
secret := svc.GenerateMFASecret()
|
||||
if len(secret) == 0 {
|
||||
t.Fatal("MFA secret should not be empty")
|
||||
}
|
||||
|
||||
// Should be valid base32
|
||||
if len(secret) < 16 {
|
||||
t.Fatalf("MFA secret seems too short: %d chars", len(secret))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableDisableMFA(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
svc := NewService(db)
|
||||
|
||||
user, _ := svc.Register("testuser", "test@example.com", "pass", "user", false)
|
||||
|
||||
err := svc.EnableMFA(user.ID, "TESTSECRET")
|
||||
if err != nil {
|
||||
t.Fatalf("EnableMFA failed: %v", err)
|
||||
}
|
||||
|
||||
updated, _ := svc.GetUserByID(user.ID)
|
||||
if !updated.MFAEnabled {
|
||||
t.Fatal("MFA should be enabled")
|
||||
}
|
||||
if updated.MFASecret != "TESTSECRET" {
|
||||
t.Fatalf("MFA secret mismatch: got %q", updated.MFASecret)
|
||||
}
|
||||
|
||||
err = svc.DisableMFA(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DisableMFA failed: %v", err)
|
||||
}
|
||||
|
||||
updated, _ = svc.GetUserByID(user.ID)
|
||||
if updated.MFAEnabled {
|
||||
t.Fatal("MFA should be disabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
Port string
|
||||
DBPath string
|
||||
DataDir string
|
||||
KeysDir string
|
||||
MasterDir string
|
||||
SessionKey string
|
||||
EncryptionKey string
|
||||
LogLevel string // ERROR, WARN, INFO (default), DEBUG, TRACE
|
||||
|
||||
// SMTP / Email
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SMTPTLS bool
|
||||
SMTPEnabled bool
|
||||
|
||||
// Security / Hardening
|
||||
BaseURL string // e.g. "https://keywarden.example.com" (used for emails, cookie config)
|
||||
TrustedProxies string // comma-separated CIDRs, e.g. "10.0.0.0/8,172.16.0.0/12"
|
||||
SecureCookies bool // set Secure flag on cookies (enable when behind HTTPS proxy)
|
||||
RateLimitLogin int // max login POST attempts per IP per minute (0 = disabled)
|
||||
MaxRequestSize int64 // max request body in bytes (0 = no limit)
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with sensible defaults
|
||||
func Load() *Config {
|
||||
smtpHost := getEnv("KEYWARDEN_SMTP_HOST", "")
|
||||
|
||||
// Parse BaseURL – auto-derive SecureCookies from scheme if not explicitly set
|
||||
baseURL := strings.TrimRight(getEnv("KEYWARDEN_BASE_URL", ""), "/")
|
||||
secureCookiesExplicit := getEnv("KEYWARDEN_SECURE_COOKIES", "")
|
||||
secureCookies := false
|
||||
if secureCookiesExplicit != "" {
|
||||
secureCookies = secureCookiesExplicit == "true"
|
||||
} else if strings.HasPrefix(baseURL, "https://") {
|
||||
secureCookies = true
|
||||
}
|
||||
|
||||
rateLimitLogin := getEnvInt("KEYWARDEN_RATE_LIMIT_LOGIN", 10)
|
||||
maxRequestSize := getEnvInt64("KEYWARDEN_MAX_REQUEST_SIZE", 10*1024*1024) // 10 MB
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("KEYWARDEN_PORT", "8080"),
|
||||
DBPath: getEnv("KEYWARDEN_DB_PATH", "./data/keywarden.db"),
|
||||
DataDir: getEnv("KEYWARDEN_DATA_DIR", "./data"),
|
||||
KeysDir: getEnv("KEYWARDEN_KEYS_DIR", "./data/keys"),
|
||||
MasterDir: getEnv("KEYWARDEN_MASTER_DIR", "./data/master"),
|
||||
SessionKey: getEnv("KEYWARDEN_SESSION_KEY", "change-me-in-production-please"),
|
||||
EncryptionKey: getEnv("KEYWARDEN_ENCRYPTION_KEY", "change-me-encryption-key-32chars"),
|
||||
LogLevel: getEnv("KEYWARDEN_LOG_LEVEL", "INFO"),
|
||||
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: getEnv("KEYWARDEN_SMTP_PORT", "587"),
|
||||
SMTPUser: getEnv("KEYWARDEN_SMTP_USER", ""),
|
||||
SMTPPassword: getEnv("KEYWARDEN_SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("KEYWARDEN_SMTP_FROM", "keywarden@localhost"),
|
||||
SMTPTLS: getEnv("KEYWARDEN_SMTP_TLS", "true") == "true",
|
||||
SMTPEnabled: smtpHost != "",
|
||||
|
||||
BaseURL: baseURL,
|
||||
TrustedProxies: getEnv("KEYWARDEN_TRUSTED_PROXIES", ""),
|
||||
SecureCookies: secureCookies,
|
||||
RateLimitLogin: rateLimitLogin,
|
||||
MaxRequestSize: maxRequestSize,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
s := getEnv(key, "")
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func getEnvInt64(key string, fallback int64) int64 {
|
||||
s := getEnv(key, "")
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
v, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
// Clear all KEYWARDEN_ env vars to ensure defaults
|
||||
envs := []string{
|
||||
"KEYWARDEN_PORT", "KEYWARDEN_DB_PATH", "KEYWARDEN_DATA_DIR",
|
||||
"KEYWARDEN_KEYS_DIR", "KEYWARDEN_MASTER_DIR", "KEYWARDEN_SESSION_KEY",
|
||||
"KEYWARDEN_ENCRYPTION_KEY",
|
||||
}
|
||||
for _, e := range envs {
|
||||
os.Unsetenv(e)
|
||||
}
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.Port != "8080" {
|
||||
t.Fatalf("Expected default port 8080, got %q", cfg.Port)
|
||||
}
|
||||
if cfg.DBPath != "./data/keywarden.db" {
|
||||
t.Fatalf("Expected default DBPath, got %q", cfg.DBPath)
|
||||
}
|
||||
if cfg.DataDir != "./data" {
|
||||
t.Fatalf("Expected default DataDir, got %q", cfg.DataDir)
|
||||
}
|
||||
if cfg.KeysDir != "./data/keys" {
|
||||
t.Fatalf("Expected default KeysDir, got %q", cfg.KeysDir)
|
||||
}
|
||||
if cfg.MasterDir != "./data/master" {
|
||||
t.Fatalf("Expected default MasterDir, got %q", cfg.MasterDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnv(t *testing.T) {
|
||||
os.Setenv("KEYWARDEN_PORT", "9090")
|
||||
os.Setenv("KEYWARDEN_DB_PATH", "/custom/db.sqlite")
|
||||
os.Setenv("KEYWARDEN_DATA_DIR", "/custom/data")
|
||||
os.Setenv("KEYWARDEN_ENCRYPTION_KEY", "my-custom-key")
|
||||
defer func() {
|
||||
os.Unsetenv("KEYWARDEN_PORT")
|
||||
os.Unsetenv("KEYWARDEN_DB_PATH")
|
||||
os.Unsetenv("KEYWARDEN_DATA_DIR")
|
||||
os.Unsetenv("KEYWARDEN_ENCRYPTION_KEY")
|
||||
}()
|
||||
|
||||
cfg := Load()
|
||||
|
||||
if cfg.Port != "9090" {
|
||||
t.Fatalf("Expected port 9090, got %q", cfg.Port)
|
||||
}
|
||||
if cfg.DBPath != "/custom/db.sqlite" {
|
||||
t.Fatalf("Expected custom DBPath, got %q", cfg.DBPath)
|
||||
}
|
||||
if cfg.DataDir != "/custom/data" {
|
||||
t.Fatalf("Expected custom DataDir, got %q", cfg.DataDir)
|
||||
}
|
||||
if cfg.EncryptionKey != "my-custom-key" {
|
||||
t.Fatalf("Expected custom EncryptionKey, got %q", cfg.EncryptionKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package cron
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/audit"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/deploy"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/keys"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/servers"
|
||||
)
|
||||
|
||||
// Service handles scheduled key deployments
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
deploy *deploy.Service
|
||||
keys *keys.Service
|
||||
servers *servers.Service
|
||||
audit *audit.Service
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService creates a new cron service
|
||||
func NewService(db *database.DB, deploySvc *deploy.Service, keysSvc *keys.Service, serversSvc *servers.Service, auditSvc *audit.Service) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
deploy: deploySvc,
|
||||
keys: keysSvc,
|
||||
servers: serversSvc,
|
||||
audit: auditSvc,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the cron scheduler loop (checks every 30 seconds)
|
||||
func (s *Service) Start() {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run once immediately at startup
|
||||
s.tick()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.tick()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
logging.Info("Cron scheduler started")
|
||||
}
|
||||
|
||||
// Stop gracefully stops the cron scheduler
|
||||
func (s *Service) Stop() {
|
||||
close(s.stopCh)
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// tick checks for jobs that need to run
|
||||
func (s *Service) tick() {
|
||||
now := time.Now().UTC()
|
||||
jobs, err := s.GetPendingJobs(now)
|
||||
if err != nil {
|
||||
logging.Error("Cron: failed to get pending jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
s.executeJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
// executeJob runs a single cron job
|
||||
func (s *Service) executeJob(job models.CronJob) {
|
||||
logging.Info("Cron: executing job '%s' (ID %d)", job.Name, job.ID)
|
||||
|
||||
// Mark as running
|
||||
s.db.Exec(`UPDATE cron_jobs SET status = 'running', last_run = ? WHERE id = ?`, time.Now().UTC(), job.ID)
|
||||
|
||||
// Use TargetUserID (the key owner) instead of UserID (the admin who created the job)
|
||||
key, err := s.keys.GetKeyByID(job.SSHKeyID, job.TargetUserID)
|
||||
if err != nil {
|
||||
s.failJob(job, fmt.Sprintf("key not found: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Use system master key for SSH authentication
|
||||
masterKeyPEM, err := s.keys.GetSystemMasterKeyPrivate()
|
||||
if err != nil {
|
||||
s.failJob(job, fmt.Sprintf("system master key not available: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Read access config directly from the job
|
||||
systemUser := job.SystemUser
|
||||
createUser := job.CreateUser
|
||||
sudo := job.Sudo
|
||||
initialPassword := job.InitialPassword
|
||||
|
||||
// Auto-generate initial password if createUser is enabled and no password is set
|
||||
if createUser && initialPassword == "" {
|
||||
initialPassword = generateInitialPassword(10)
|
||||
logging.Debug("Cron job '%s': auto-generated initial password for system user '%s'", job.Name, systemUser)
|
||||
}
|
||||
|
||||
var targetServers []models.Server
|
||||
|
||||
if job.ServerID > 0 {
|
||||
// Single server — use Global lookup since cron jobs are admin-created
|
||||
srv, err := s.servers.GetByIDGlobal(job.ServerID)
|
||||
if err != nil {
|
||||
s.failJob(job, fmt.Sprintf("server not found: %v", err))
|
||||
return
|
||||
}
|
||||
targetServers = append(targetServers, *srv)
|
||||
} else if job.GroupID > 0 {
|
||||
// Server group — use Global lookup since cron jobs are admin-created
|
||||
members, err := s.servers.GetGroupMembersGlobal(job.GroupID)
|
||||
if err != nil || len(members) == 0 {
|
||||
s.failJob(job, fmt.Sprintf("group members not found: %v", err))
|
||||
return
|
||||
}
|
||||
targetServers = members
|
||||
} else {
|
||||
s.failJob(job, "no target server or group specified")
|
||||
return
|
||||
}
|
||||
|
||||
var successCount, failCount int
|
||||
for _, srv := range targetServers {
|
||||
server := srv
|
||||
var deployErr error
|
||||
if systemUser != "" {
|
||||
// Deploy to specific system user (from assignment)
|
||||
deployErr = s.deploy.DeployKeyToUser(key, &server, masterKeyPEM, systemUser, createUser, sudo, initialPassword)
|
||||
} else {
|
||||
// Legacy: deploy to server's default user (root)
|
||||
deployErr = s.deploy.DeployKey(key, &server, masterKeyPEM)
|
||||
}
|
||||
if deployErr != nil {
|
||||
failCount++
|
||||
logging.Error("Cron job '%s': deploy to %s@%s:%d failed: %v", job.Name, server.Username, server.Hostname, server.Port, deployErr)
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Log result
|
||||
targetInfo := ""
|
||||
if systemUser != "" {
|
||||
targetInfo = fmt.Sprintf(" (system user: %s)", systemUser)
|
||||
}
|
||||
details := fmt.Sprintf("Cron job '%s': deployed key '%s'%s — %d success, %d failed", job.Name, key.Name, targetInfo, successCount, failCount)
|
||||
if failCount > 0 && successCount == 0 {
|
||||
s.audit.Log(job.UserID, audit.ActionCronJobFailed, details, "cron")
|
||||
} else {
|
||||
s.audit.Log(job.UserID, audit.ActionCronJobExecuted, details, "cron")
|
||||
}
|
||||
|
||||
// Store auto-generated initial password (encrypted) if it was generated and deploy succeeded
|
||||
if initialPassword != "" && job.InitialPassword == "" && successCount > 0 {
|
||||
if encPW, encErr := s.keys.EncryptValue(initialPassword); encErr == nil {
|
||||
s.db.Exec(`UPDATE cron_jobs SET initial_password = ? WHERE id = ?`, encPW, job.ID)
|
||||
} else {
|
||||
logging.Warn("Cron job '%s': failed to encrypt initial password: %v", job.Name, encErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update job status
|
||||
if job.Schedule == "once" {
|
||||
// One-time job: mark as done
|
||||
s.db.Exec(`UPDATE cron_jobs SET status = 'done', last_run = ?, message = ? WHERE id = ?`,
|
||||
time.Now().UTC(), details, job.ID)
|
||||
} else {
|
||||
// Recurring job: calculate next run and stay active
|
||||
nextRun := CalculateNextRun(job)
|
||||
s.db.Exec(`UPDATE cron_jobs SET status = 'active', last_run = ?, next_run = ?, message = ? WHERE id = ?`,
|
||||
time.Now().UTC(), nextRun, details, job.ID)
|
||||
}
|
||||
|
||||
// Handle auto-removal after expiry (for temporary deployments)
|
||||
if job.RemoveAfterMin > 0 {
|
||||
s.wg.Add(1)
|
||||
go func(j models.CronJob, serverList []models.Server, sshKey *models.SSHKey, masterPEM []byte) {
|
||||
defer s.wg.Done()
|
||||
timer := time.NewTimer(time.Duration(j.RemoveAfterMin) * time.Minute)
|
||||
select {
|
||||
case <-timer.C:
|
||||
s.handleExpiry(j, serverList, sshKey, masterPEM)
|
||||
case <-s.stopCh:
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}(job, targetServers, key, masterKeyPEM)
|
||||
}
|
||||
}
|
||||
|
||||
// handleExpiry handles the expiry action for a temporary access job
|
||||
func (s *Service) handleExpiry(job models.CronJob, serverList []models.Server, key *models.SSHKey, masterKeyPEM []byte) {
|
||||
expiryAction := job.ExpiryAction
|
||||
if expiryAction == "" {
|
||||
expiryAction = "remove_key"
|
||||
}
|
||||
systemUser := job.SystemUser
|
||||
|
||||
logging.Info("Cron: expiry action '%s' for key '%s' after %d minutes (job '%s')", expiryAction, key.Name, job.RemoveAfterMin, job.Name)
|
||||
|
||||
for _, srv := range serverList {
|
||||
server := srv
|
||||
var err error
|
||||
switch expiryAction {
|
||||
case "disable_user":
|
||||
if systemUser != "" {
|
||||
err = s.deploy.DisableSystemUser(key, &server, masterKeyPEM, systemUser)
|
||||
} else {
|
||||
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
|
||||
}
|
||||
case "delete_user":
|
||||
if systemUser != "" {
|
||||
err = s.deploy.RemoveSystemUser(key, &server, masterKeyPEM, systemUser)
|
||||
} else {
|
||||
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
|
||||
}
|
||||
default: // "remove_key"
|
||||
if systemUser != "" {
|
||||
err = s.deploy.RemoveKeyFromUser(key, &server, masterKeyPEM, systemUser)
|
||||
} else {
|
||||
err = s.deploy.RemoveKey(key, &server, masterKeyPEM)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logging.Error("Cron job '%s': expiry action '%s' on %s@%s:%d failed: %v", job.Name, expiryAction, server.Username, server.Hostname, server.Port, err)
|
||||
}
|
||||
}
|
||||
|
||||
actionLabel := map[string]string{"remove_key": "removed key", "disable_user": "disabled user", "delete_user": "deleted user"}[expiryAction]
|
||||
details := fmt.Sprintf("Cron job '%s': %s '%s' on %d server(s) after %d min", job.Name, actionLabel, key.Name, len(serverList), job.RemoveAfterMin)
|
||||
s.audit.Log(job.UserID, audit.ActionCronJobKeyRemoved, details, "cron")
|
||||
}
|
||||
|
||||
// failJob marks a job as failed
|
||||
func (s *Service) failJob(job models.CronJob, msg string) {
|
||||
logging.Error("Cron job '%s' failed: %s", job.Name, msg)
|
||||
status := "failed"
|
||||
if job.Schedule != "once" {
|
||||
// Recurring jobs stay active but log the failure
|
||||
status = "active"
|
||||
nextRun := CalculateNextRun(job)
|
||||
s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, next_run = ?, message = ? WHERE id = ?`,
|
||||
status, time.Now().UTC(), nextRun, msg, job.ID)
|
||||
} else {
|
||||
s.db.Exec(`UPDATE cron_jobs SET status = ?, last_run = ?, message = ? WHERE id = ?`,
|
||||
status, time.Now().UTC(), msg, job.ID)
|
||||
}
|
||||
s.audit.Log(job.UserID, audit.ActionCronJobFailed, fmt.Sprintf("Cron job '%s' failed: %s", job.Name, msg), "cron")
|
||||
}
|
||||
|
||||
// CalculateNextRun computes the next execution time for recurring jobs.
|
||||
// It uses the job's timezone and schedule parameters to ensure the execution
|
||||
// time stays aligned (no drift). All returned times are in UTC.
|
||||
func CalculateNextRun(job models.CronJob) time.Time {
|
||||
loc, err := time.LoadLocation(job.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
|
||||
switch job.Schedule {
|
||||
case "hourly":
|
||||
// Next occurrence of the specified minute
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), job.MinuteOfHour, 0, 0, loc)
|
||||
if !next.After(now) {
|
||||
next = next.Add(1 * time.Hour)
|
||||
}
|
||||
return next.UTC()
|
||||
|
||||
case "daily":
|
||||
// Next occurrence of the specified time of day
|
||||
hour, minute := parseTimeOfDay(job.TimeOfDay)
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
return next.UTC()
|
||||
|
||||
case "weekly":
|
||||
// Next occurrence of the specified weekday at the specified time
|
||||
hour, minute := parseTimeOfDay(job.TimeOfDay)
|
||||
targetDay := time.Weekday(job.DayOfWeek)
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
|
||||
daysUntil := int(targetDay) - int(now.Weekday())
|
||||
if daysUntil < 0 {
|
||||
daysUntil += 7
|
||||
}
|
||||
next = next.AddDate(0, 0, daysUntil)
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 7)
|
||||
}
|
||||
return next.UTC()
|
||||
|
||||
case "monthly":
|
||||
// Next occurrence of the specified day of month at the specified time
|
||||
hour, minute := parseTimeOfDay(job.TimeOfDay)
|
||||
day := job.DayOfMonth
|
||||
if day < 1 {
|
||||
day = 1
|
||||
}
|
||||
next := safeDate(now.Year(), now.Month(), day, hour, minute, loc)
|
||||
if !next.After(now) {
|
||||
next = safeDate(now.Year(), now.Month()+1, day, hour, minute, loc)
|
||||
}
|
||||
return next.UTC()
|
||||
|
||||
default:
|
||||
return now.Add(24 * time.Hour).UTC()
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateFirstRun computes the initial next_run time for a new job.
|
||||
// For "once", it uses the scheduled_at time directly.
|
||||
// For recurring schedules, it finds the next matching time from now.
|
||||
func CalculateFirstRun(job models.CronJob) time.Time {
|
||||
if job.Schedule == "once" {
|
||||
return job.ScheduledAt.UTC()
|
||||
}
|
||||
return CalculateNextRun(job)
|
||||
}
|
||||
|
||||
// parseTimeOfDay parses "HH:MM" format and returns hour, minute
|
||||
func parseTimeOfDay(tod string) (int, int) {
|
||||
var hour, minute int
|
||||
fmt.Sscanf(tod, "%d:%d", &hour, &minute)
|
||||
if hour < 0 || hour > 23 {
|
||||
hour = 0
|
||||
}
|
||||
if minute < 0 || minute > 59 {
|
||||
minute = 0
|
||||
}
|
||||
return hour, minute
|
||||
}
|
||||
|
||||
// safeDate creates a date, clamping the day to the last day of the month
|
||||
func safeDate(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time {
|
||||
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
|
||||
if day > lastDay {
|
||||
day = lastDay
|
||||
}
|
||||
return time.Date(year, month, day, hour, minute, 0, 0, loc)
|
||||
}
|
||||
|
||||
// --- Database Operations ---
|
||||
|
||||
// Create creates a new temporary access job
|
||||
func (s *Service) Create(userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) (*models.CronJob, error) {
|
||||
if expiryAction == "" {
|
||||
expiryAction = "remove_key"
|
||||
}
|
||||
job := models.CronJob{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
SSHKeyID: keyID,
|
||||
ServerID: serverID,
|
||||
GroupID: groupID,
|
||||
Schedule: schedule,
|
||||
ScheduledAt: scheduledAt.UTC(),
|
||||
RemoveAfterMin: removeAfterMin,
|
||||
Timezone: tz,
|
||||
TimeOfDay: timeOfDay,
|
||||
DayOfWeek: dayOfWeek,
|
||||
DayOfMonth: dayOfMonth,
|
||||
MinuteOfHour: minuteOfHour,
|
||||
TargetUserID: targetUserID,
|
||||
SystemUser: systemUser,
|
||||
Sudo: sudo,
|
||||
CreateUser: createUser,
|
||||
InitialPassword: initialPassword,
|
||||
ExpiryAction: expiryAction,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
nextRun := CalculateFirstRun(job)
|
||||
job.NextRun = nextRun
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO cron_jobs (user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, remove_after_min, status, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID, name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin,
|
||||
tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour, targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cron job: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
job.ID = id
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
// GetByUser returns all cron jobs for a user
|
||||
func (s *Service) GetByUser(userID int64) ([]models.CronJobDisplay, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT cj.id, cj.user_id, cj.name, cj.ssh_key_id, cj.server_id, cj.group_id,
|
||||
cj.schedule, cj.scheduled_at, cj.next_run, cj.last_run, cj.remove_after_min,
|
||||
cj.status, cj.message, cj.created_at,
|
||||
cj.timezone, cj.time_of_day, cj.day_of_week, cj.day_of_month, cj.minute_of_hour,
|
||||
cj.target_user_id, cj.system_user, cj.sudo, cj.create_user, cj.initial_password, cj.expiry_action,
|
||||
COALESCE(sk.name, '(deleted)') as key_name,
|
||||
COALESCE(srv.name, '') as server_name,
|
||||
COALESCE(sg.name, '') as group_name,
|
||||
COALESCE(tu.username, '') as target_username
|
||||
FROM cron_jobs cj
|
||||
LEFT JOIN ssh_keys sk ON cj.ssh_key_id = sk.id
|
||||
LEFT JOIN servers srv ON cj.server_id = srv.id
|
||||
LEFT JOIN server_groups sg ON cj.group_id = sg.id
|
||||
LEFT JOIN users tu ON cj.target_user_id = tu.id
|
||||
WHERE cj.user_id = ?
|
||||
ORDER BY cj.created_at DESC`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query cron jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []models.CronJobDisplay
|
||||
for rows.Next() {
|
||||
var j models.CronJobDisplay
|
||||
var lastRun, message *string
|
||||
var serverName, groupName string
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID,
|
||||
&j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin,
|
||||
&j.Status, &message, &j.CreatedAt,
|
||||
&j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour,
|
||||
&j.TargetUserID, &j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction,
|
||||
&j.KeyName, &serverName, &groupName,
|
||||
&j.TargetUsername,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
if lastRun != nil {
|
||||
if t, ok := parseTimeString(*lastRun); ok {
|
||||
j.LastRun = &t
|
||||
}
|
||||
}
|
||||
if message != nil {
|
||||
j.Message = *message
|
||||
}
|
||||
if serverName != "" {
|
||||
j.TargetName = serverName
|
||||
j.TargetType = "host"
|
||||
} else if groupName != "" {
|
||||
j.TargetName = groupName
|
||||
j.TargetType = "group"
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// GetByID returns a specific cron job
|
||||
func (s *Service) GetByID(jobID, userID int64) (*models.CronJob, error) {
|
||||
job := &models.CronJob{}
|
||||
var lastRun, message *string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at,
|
||||
timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id,
|
||||
system_user, sudo, create_user, initial_password, expiry_action
|
||||
FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID,
|
||||
).Scan(&job.ID, &job.UserID, &job.Name, &job.SSHKeyID, &job.ServerID, &job.GroupID,
|
||||
&job.Schedule, &job.ScheduledAt, &job.NextRun, &lastRun, &job.RemoveAfterMin,
|
||||
&job.Status, &message, &job.CreatedAt,
|
||||
&job.Timezone, &job.TimeOfDay, &job.DayOfWeek, &job.DayOfMonth, &job.MinuteOfHour,
|
||||
&job.TargetUserID,
|
||||
&job.SystemUser, &job.Sudo, &job.CreateUser, &job.InitialPassword, &job.ExpiryAction)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cron job not found: %w", err)
|
||||
}
|
||||
if lastRun != nil {
|
||||
if t, ok := parseTimeString(*lastRun); ok {
|
||||
job.LastRun = &t
|
||||
}
|
||||
}
|
||||
if message != nil {
|
||||
job.Message = *message
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// Update updates a temporary access job
|
||||
func (s *Service) Update(jobID, userID int64, name string, keyID, serverID, groupID int64, schedule string, scheduledAt time.Time, removeAfterMin int, tz, timeOfDay string, dayOfWeek, dayOfMonth, minuteOfHour int, targetUserID int64, systemUser string, sudo, createUser bool, initialPassword, expiryAction string) error {
|
||||
if expiryAction == "" {
|
||||
expiryAction = "remove_key"
|
||||
}
|
||||
job := models.CronJob{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
SSHKeyID: keyID,
|
||||
ServerID: serverID,
|
||||
GroupID: groupID,
|
||||
Schedule: schedule,
|
||||
ScheduledAt: scheduledAt.UTC(),
|
||||
RemoveAfterMin: removeAfterMin,
|
||||
Timezone: tz,
|
||||
TimeOfDay: timeOfDay,
|
||||
DayOfWeek: dayOfWeek,
|
||||
DayOfMonth: dayOfMonth,
|
||||
MinuteOfHour: minuteOfHour,
|
||||
TargetUserID: targetUserID,
|
||||
SystemUser: systemUser,
|
||||
Sudo: sudo,
|
||||
CreateUser: createUser,
|
||||
InitialPassword: initialPassword,
|
||||
ExpiryAction: expiryAction,
|
||||
}
|
||||
|
||||
nextRun := CalculateFirstRun(job)
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE cron_jobs SET name=?, ssh_key_id=?, server_id=?, group_id=?, schedule=?, scheduled_at=?, next_run=?, remove_after_min=?, status='active',
|
||||
timezone=?, time_of_day=?, day_of_week=?, day_of_month=?, minute_of_hour=?,
|
||||
target_user_id=?, system_user=?, sudo=?, create_user=?, initial_password=?, expiry_action=?
|
||||
WHERE id=? AND user_id=?`,
|
||||
name, keyID, serverID, groupID, schedule, scheduledAt.UTC(), nextRun, removeAfterMin,
|
||||
tz, timeOfDay, dayOfWeek, dayOfMonth, minuteOfHour,
|
||||
targetUserID, systemUser, sudo, createUser, initialPassword, expiryAction,
|
||||
jobID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update cron job: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("cron job not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a cron job
|
||||
func (s *Service) Delete(jobID, userID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM cron_jobs WHERE id = ? AND user_id = ?`, jobID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete cron job: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("cron job not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TogglePause pauses or resumes a cron job
|
||||
func (s *Service) TogglePause(jobID, userID int64) error {
|
||||
job, err := s.GetByID(jobID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newStatus := "paused"
|
||||
if job.Status == "paused" {
|
||||
newStatus = "active"
|
||||
// Recalculate next run when resuming
|
||||
nextRun := CalculateNextRun(*job)
|
||||
_, err = s.db.Exec(`UPDATE cron_jobs SET status = ?, next_run = ? WHERE id = ? AND user_id = ?`, newStatus, nextRun, jobID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`UPDATE cron_jobs SET status = ? WHERE id = ? AND user_id = ?`, newStatus, jobID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPendingJobs returns jobs that are due for execution
|
||||
func (s *Service) GetPendingJobs(now time.Time) ([]models.CronJob, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, created_at,
|
||||
timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id,
|
||||
system_user, sudo, create_user, initial_password, expiry_action
|
||||
FROM cron_jobs
|
||||
WHERE status = 'active' AND next_run <= ?
|
||||
ORDER BY next_run ASC`, now.UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query pending jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []models.CronJob
|
||||
for rows.Next() {
|
||||
var j models.CronJob
|
||||
var lastRun, message *string
|
||||
if err := rows.Scan(&j.ID, &j.UserID, &j.Name, &j.SSHKeyID, &j.ServerID, &j.GroupID,
|
||||
&j.Schedule, &j.ScheduledAt, &j.NextRun, &lastRun, &j.RemoveAfterMin,
|
||||
&j.Status, &message, &j.CreatedAt,
|
||||
&j.Timezone, &j.TimeOfDay, &j.DayOfWeek, &j.DayOfMonth, &j.MinuteOfHour,
|
||||
&j.TargetUserID,
|
||||
&j.SystemUser, &j.Sudo, &j.CreateUser, &j.InitialPassword, &j.ExpiryAction); err != nil {
|
||||
continue
|
||||
}
|
||||
if lastRun != nil {
|
||||
if t, ok := parseTimeString(*lastRun); ok {
|
||||
j.LastRun = &t
|
||||
}
|
||||
}
|
||||
if message != nil {
|
||||
j.Message = *message
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// CountByUser returns total cron jobs for a user
|
||||
func (s *Service) CountByUser(userID int64) int {
|
||||
var count int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM cron_jobs WHERE user_id = ?`, userID).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// parseTimeString tries multiple time formats to parse a SQLite datetime string
|
||||
func parseTimeString(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// generateInitialPassword generates a random alphanumeric password
|
||||
func generateInitialPassword(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackupData contains all exportable data from the database
|
||||
type BackupData struct {
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Users []map[string]interface{} `json:"users"`
|
||||
SSHKeys []map[string]interface{} `json:"ssh_keys"`
|
||||
Servers []map[string]interface{} `json:"servers"`
|
||||
ServerGroups []map[string]interface{} `json:"server_groups"`
|
||||
GroupMembers []map[string]interface{} `json:"server_group_members"`
|
||||
KeyDeployments []map[string]interface{} `json:"key_deployments"`
|
||||
AuditLog []map[string]interface{} `json:"audit_log"`
|
||||
Settings []map[string]interface{} `json:"settings"`
|
||||
AccessAssign []map[string]interface{} `json:"access_assignments"`
|
||||
CronJobs []map[string]interface{} `json:"cron_jobs"`
|
||||
}
|
||||
|
||||
// ExportAll exports all database tables to a BackupData struct
|
||||
func (d *DB) ExportAll() (*BackupData, error) {
|
||||
backup := &BackupData{
|
||||
Version: "1",
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
tables := []struct {
|
||||
query string
|
||||
dest *[]map[string]interface{}
|
||||
}{
|
||||
{`SELECT id, username, email, password_hash, role, mfa_enabled, mfa_secret, theme, email_notify_login, avatar_base64, must_change_password, failed_login_attempts, locked_until, last_login_at, created_at, updated_at FROM users ORDER BY id`, &backup.Users},
|
||||
{`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, passphrase_enc, created_at FROM ssh_keys ORDER BY id`, &backup.SSHKeys},
|
||||
{`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at FROM servers ORDER BY id`, &backup.Servers},
|
||||
{`SELECT id, user_id, name, description, created_at, updated_at FROM server_groups ORDER BY id`, &backup.ServerGroups},
|
||||
{`SELECT id, group_id, server_id FROM server_group_members ORDER BY id`, &backup.GroupMembers},
|
||||
{`SELECT id, ssh_key_id, server_id, deployed_at, status, message FROM key_deployments ORDER BY id`, &backup.KeyDeployments},
|
||||
{`SELECT id, user_id, action, details, ip_address, created_at FROM audit_log ORDER BY id`, &backup.AuditLog},
|
||||
{`SELECT key, value, updated_at FROM settings ORDER BY key`, &backup.Settings},
|
||||
{`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at FROM access_assignments ORDER BY id`, &backup.AccessAssign},
|
||||
{`SELECT id, user_id, name, ssh_key_id, server_id, group_id, schedule, scheduled_at, next_run, last_run, remove_after_min, status, message, timezone, time_of_day, day_of_week, day_of_month, minute_of_hour, target_user_id, system_user, sudo, create_user, initial_password, expiry_action, created_at FROM cron_jobs ORDER BY id`, &backup.CronJobs},
|
||||
}
|
||||
|
||||
for _, t := range tables {
|
||||
rows, err := d.Query(t.query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query table: %w", err)
|
||||
}
|
||||
data, err := rowsToMaps(rows)
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read rows: %w", err)
|
||||
}
|
||||
*t.dest = data
|
||||
}
|
||||
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// ImportAll restores all database tables from a BackupData struct.
|
||||
// It clears existing data and replaces it with the backup data.
|
||||
func (d *DB) ImportAll(backup *BackupData) error {
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Disable foreign key checks during import
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("failed to disable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
// Clear all tables in dependency order
|
||||
clearOrder := []string{
|
||||
"cron_jobs",
|
||||
"access_assignments",
|
||||
"key_deployments",
|
||||
"server_group_members",
|
||||
"server_groups",
|
||||
"ssh_keys",
|
||||
"servers",
|
||||
"audit_log",
|
||||
"settings",
|
||||
"users",
|
||||
}
|
||||
for _, table := range clearOrder {
|
||||
if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
|
||||
return fmt.Errorf("failed to clear table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Import tables in reverse dependency order (parents first)
|
||||
importOrder := []struct {
|
||||
table string
|
||||
columns []string
|
||||
data []map[string]interface{}
|
||||
}{
|
||||
{"users", []string{"id", "username", "email", "password_hash", "role", "mfa_enabled", "mfa_secret", "theme", "email_notify_login", "avatar_base64", "must_change_password", "failed_login_attempts", "locked_until", "last_login_at", "created_at", "updated_at"}, backup.Users},
|
||||
{"ssh_keys", []string{"id", "user_id", "name", "key_type", "bits", "fingerprint", "public_key", "private_key_enc", "passphrase_enc", "created_at"}, backup.SSHKeys},
|
||||
{"servers", []string{"id", "user_id", "name", "hostname", "port", "username", "description", "created_at", "updated_at"}, backup.Servers},
|
||||
{"server_groups", []string{"id", "user_id", "name", "description", "created_at", "updated_at"}, backup.ServerGroups},
|
||||
{"server_group_members", []string{"id", "group_id", "server_id"}, backup.GroupMembers},
|
||||
{"key_deployments", []string{"id", "ssh_key_id", "server_id", "deployed_at", "status", "message"}, backup.KeyDeployments},
|
||||
{"audit_log", []string{"id", "user_id", "action", "details", "ip_address", "created_at"}, backup.AuditLog},
|
||||
{"settings", []string{"key", "value", "updated_at"}, backup.Settings},
|
||||
{"access_assignments", []string{"id", "user_id", "ssh_key_id", "server_id", "group_id", "system_user", "desired_state", "sudo", "create_user", "initial_password", "status", "last_sync_at", "created_at", "updated_at"}, backup.AccessAssign},
|
||||
{"cron_jobs", []string{"id", "user_id", "name", "ssh_key_id", "server_id", "group_id", "schedule", "scheduled_at", "next_run", "last_run", "remove_after_min", "status", "message", "timezone", "time_of_day", "day_of_week", "day_of_month", "minute_of_hour", "target_user_id", "system_user", "sudo", "create_user", "initial_password", "expiry_action", "created_at"}, backup.CronJobs},
|
||||
}
|
||||
|
||||
for _, imp := range importOrder {
|
||||
if len(imp.data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build INSERT statement
|
||||
placeholders := ""
|
||||
for i := range imp.columns {
|
||||
if i > 0 {
|
||||
placeholders += ", "
|
||||
}
|
||||
placeholders += "?"
|
||||
}
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", imp.table, joinColumns(imp.columns), placeholders)
|
||||
|
||||
stmt, err := tx.Prepare(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare insert for %s: %w", imp.table, err)
|
||||
}
|
||||
|
||||
for _, row := range imp.data {
|
||||
args := make([]interface{}, len(imp.columns))
|
||||
for i, col := range imp.columns {
|
||||
args[i] = row[col]
|
||||
}
|
||||
if _, err := stmt.Exec(args...); err != nil {
|
||||
stmt.Close()
|
||||
return fmt.Errorf("failed to insert into %s: %w", imp.table, err)
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("failed to re-enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncryptBackup encrypts JSON backup data with AES-256-GCM using the given password
|
||||
func EncryptBackup(data []byte, password string) ([]byte, error) {
|
||||
key := sha256.Sum256([]byte(password))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Prepend a magic header so we can identify backup files
|
||||
magic := []byte("KWBAK1") // Keywarden Backup v1
|
||||
ciphertext := gcm.Seal(nonce, nonce, data, nil)
|
||||
result := append(magic, ciphertext...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptBackup decrypts an AES-256-GCM encrypted backup with the given password
|
||||
func DecryptBackup(encrypted []byte, password string) ([]byte, error) {
|
||||
// Check magic header
|
||||
magic := []byte("KWBAK1")
|
||||
if len(encrypted) < len(magic) {
|
||||
return nil, fmt.Errorf("invalid backup file: too short")
|
||||
}
|
||||
if string(encrypted[:len(magic)]) != string(magic) {
|
||||
return nil, fmt.Errorf("invalid backup file: wrong format")
|
||||
}
|
||||
encrypted = encrypted[len(magic):]
|
||||
|
||||
key := sha256.Sum256([]byte(password))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(encrypted) < nonceSize {
|
||||
return nil, fmt.Errorf("invalid backup file: ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decryption failed: wrong password or corrupted file")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// rowsToMaps converts sql.Rows to a slice of maps
|
||||
func rowsToMaps(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
valuePtrs := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
val := values[i]
|
||||
// Convert byte slices to strings for JSON serialization
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
result = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// joinColumns joins column names with commas
|
||||
func joinColumns(cols []string) string {
|
||||
result := ""
|
||||
for i, col := range cols {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += col
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseBackupJSON parses decrypted JSON data into a BackupData struct
|
||||
func ParseBackupJSON(data []byte) (*BackupData, error) {
|
||||
var backup BackupData
|
||||
if err := json.Unmarshal(data, &backup); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse backup data: %w", err)
|
||||
}
|
||||
if backup.Version == "" {
|
||||
return nil, fmt.Errorf("invalid backup: missing version")
|
||||
}
|
||||
return &backup, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptBackup(t *testing.T) {
|
||||
password := "TestP@ssw0rd123!"
|
||||
original := &BackupData{
|
||||
Version: "1",
|
||||
CreatedAt: "2026-04-05T12:00:00Z",
|
||||
Users: []map[string]interface{}{
|
||||
{"id": float64(1), "username": "admin", "email": "admin@test.local"},
|
||||
},
|
||||
SSHKeys: []map[string]interface{}{},
|
||||
Servers: []map[string]interface{}{},
|
||||
ServerGroups: []map[string]interface{}{},
|
||||
GroupMembers: []map[string]interface{}{},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal: %v", err)
|
||||
}
|
||||
|
||||
encrypted, err := EncryptBackup(jsonData, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Check magic header
|
||||
if string(encrypted[:6]) != "KWBAK1" {
|
||||
t.Error("Missing KWBAK1 magic header")
|
||||
}
|
||||
|
||||
// Decrypt with correct password
|
||||
decrypted, err := DecryptBackup(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify content matches
|
||||
var restored BackupData
|
||||
if err := json.Unmarshal(decrypted, &restored); err != nil {
|
||||
t.Fatalf("Failed to unmarshal decrypted data: %v", err)
|
||||
}
|
||||
|
||||
if restored.Version != original.Version {
|
||||
t.Errorf("Version mismatch: got %s, want %s", restored.Version, original.Version)
|
||||
}
|
||||
if len(restored.Users) != 1 {
|
||||
t.Errorf("Expected 1 user, got %d", len(restored.Users))
|
||||
}
|
||||
|
||||
// Wrong password should fail
|
||||
_, err = DecryptBackup(encrypted, "wrong-password")
|
||||
if err == nil {
|
||||
t.Error("Expected error with wrong password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptBackupInvalidFile(t *testing.T) {
|
||||
_, err := DecryptBackup([]byte("invalid data"), "password")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid file, got nil")
|
||||
}
|
||||
|
||||
_, err = DecryptBackup([]byte("short"), "password")
|
||||
if err == nil {
|
||||
t.Error("Expected error for too-short data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBackupJSON(t *testing.T) {
|
||||
valid := `{"version":"1","created_at":"2026-04-05T12:00:00Z","users":[],"ssh_keys":[],"servers":[],"server_groups":[],"server_group_members":[],"key_deployments":[],"audit_log":[],"settings":[],"access_assignments":[],"cron_jobs":[]}`
|
||||
backup, err := ParseBackupJSON([]byte(valid))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse valid JSON: %v", err)
|
||||
}
|
||||
if backup.Version != "1" {
|
||||
t.Errorf("Version mismatch: got %s, want 1", backup.Version)
|
||||
}
|
||||
|
||||
// Missing version
|
||||
invalid := `{"created_at":"2026-04-05T12:00:00Z"}`
|
||||
_, err = ParseBackupJSON([]byte(invalid))
|
||||
if err == nil {
|
||||
t.Error("Expected error for missing version, got nil")
|
||||
}
|
||||
|
||||
// Invalid JSON
|
||||
_, err = ParseBackupJSON([]byte("not json"))
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"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')`)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
//go:build integration
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewDatabase(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Verify file was created
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
t.Fatal("Database file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsCreateTables(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
tables := []string{"users", "ssh_keys", "servers", "key_deployments", "audit_log", "settings", "_migrations"}
|
||||
for _, table := range tables {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check table %s: %v", table, err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("Table %q was not created", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsIdempotent(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Run migrations twice (opening creates + migrates)
|
||||
db1, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("New() first call failed: %v", err)
|
||||
}
|
||||
db1.Close()
|
||||
|
||||
db2, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("New() second call failed (migrations should be idempotent): %v", err)
|
||||
}
|
||||
defer db2.Close()
|
||||
}
|
||||
|
||||
func TestAlterMigrationsTracked(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check that alter migrations are tracked
|
||||
expectedMigrations := []string{"add_mfa_enabled", "add_mfa_secret", "add_is_master_key"}
|
||||
for _, name := range expectedMigrations {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM _migrations WHERE name = ?`, name).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check migration %s: %v", name, err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("Migration %q was not tracked", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertAndQueryUser(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
result, err := db.Exec(
|
||||
`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "test@example.com", "hash123", "user",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert user failed: %v", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
if id == 0 {
|
||||
t.Fatal("Expected non-zero insert ID")
|
||||
}
|
||||
|
||||
var username string
|
||||
err = db.QueryRow(`SELECT username FROM users WHERE id = ?`, id).Scan(&username)
|
||||
if err != nil {
|
||||
t.Fatalf("Query user failed: %v", err)
|
||||
}
|
||||
if username != "testuser" {
|
||||
t.Fatalf("Expected username 'testuser', got %q", username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHKeyWithMasterFlag(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create a user first
|
||||
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "test@example.com", "hash", "user")
|
||||
|
||||
// Insert a master key
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, is_master)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
1, "Master Key", "ed25519", 256, "SHA256:test", "pubkey", "encpriv", 1,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert master key failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify is_master flag
|
||||
var isMaster int
|
||||
err = db.QueryRow(`SELECT is_master FROM ssh_keys WHERE user_id = 1 AND is_master = 1`).Scan(&isMaster)
|
||||
if err != nil {
|
||||
t.Fatalf("Query master key failed: %v", err)
|
||||
}
|
||||
if isMaster != 1 {
|
||||
t.Fatalf("Expected is_master=1, got %d", isMaster)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeyCascade(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := New(filepath.Join(tmpDir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert user and key
|
||||
db.Exec(`INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)`,
|
||||
"testuser", "test@example.com", "hash", "user")
|
||||
db.Exec(`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, 1, "Test", "ed25519", 256, "fp", "pub", "priv")
|
||||
|
||||
// Delete user — key should cascade
|
||||
db.Exec(`DELETE FROM users WHERE id = 1`)
|
||||
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM ssh_keys WHERE user_id = 1`).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("Expected 0 keys after user deletion (cascade), got %d", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Service handles deploying SSH keys to remote servers
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewService creates a new deploy service
|
||||
func NewService(db *database.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// DeployKey deploys a public key to a remote server's authorized_keys
|
||||
func (s *Service) DeployKey(key *models.SSHKey, server *models.Server, authPrivateKey []byte) error {
|
||||
logging.Debug("Deploy: connecting to %s@%s:%d with key auth for key '%s'", server.Username, server.Hostname, server.Port, key.Name)
|
||||
|
||||
// Parse the private key used for authentication
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: implement known_hosts
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
|
||||
return fmt.Errorf("failed to connect to server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("session failed: %v", err))
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Clean the public key (remove trailing newline)
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
|
||||
// Append to authorized_keys
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u -o ~/.ssh/authorized_keys ~/.ssh/authorized_keys`,
|
||||
pubKey,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
|
||||
return fmt.Errorf("failed to deploy key: %w", err)
|
||||
}
|
||||
|
||||
s.logDeployment(key.ID, server.ID, "success", "key deployed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployKeyWithPassword deploys a public key using password authentication
|
||||
func (s *Service) DeployKeyWithPassword(key *models.SSHKey, server *models.Server, password string) error {
|
||||
logging.Debug("Deploy: connecting to %s@%s:%d with password auth for key '%s'", server.Username, server.Hostname, server.Port, key.Name)
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
|
||||
return fmt.Errorf("failed to connect to server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("session failed: %v", err))
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u -o ~/.ssh/authorized_keys ~/.ssh/authorized_keys`,
|
||||
pubKey,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
|
||||
return fmt.Errorf("failed to deploy key: %w", err)
|
||||
}
|
||||
|
||||
s.logDeployment(key.ID, server.ID, "success", "key deployed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveKey removes a public key from a remote server's authorized_keys
|
||||
func (s *Service) RemoveKey(key *models.SSHKey, server *models.Server, authPrivateKey []byte) error {
|
||||
logging.Debug("Deploy: removing key '%s' from %s@%s:%d", key.Name, server.Username, server.Hostname, server.Port)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to server for key removal: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for key removal: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
// Escape single quotes in the key for safe sed usage
|
||||
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
|
||||
|
||||
cmd := fmt.Sprintf(
|
||||
`grep -v '%s' ~/.ssh/authorized_keys > ~/.ssh/authorized_keys.tmp 2>/dev/null && mv ~/.ssh/authorized_keys.tmp ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys || true`,
|
||||
escapedKey,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
return fmt.Errorf("failed to remove key: %w", err)
|
||||
}
|
||||
|
||||
s.logDeployment(key.ID, server.ID, "success", "key removed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployKeyToUser deploys a public key to a specific system user's authorized_keys.
|
||||
// It connects to the server as the server's admin user and manages the target systemUser.
|
||||
// If createUser is true, the system user will be created if it doesn't exist.
|
||||
// If sudo is true, a sudoers.d entry with NOPASSWD will be created.
|
||||
// If initialPassword is set and createUser is true, the password will be set on the system user.
|
||||
func (s *Service) DeployKeyToUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string, createUser, sudo bool, initialPassword string) error {
|
||||
logging.Debug("Deploy: connecting to %s@%s:%d to deploy key '%s' for system user '%s' (createUser=%v, sudo=%v)",
|
||||
server.Username, server.Hostname, server.Port, key.Name, systemUser, createUser, sudo)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
|
||||
return fmt.Errorf("failed to connect to server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// If createUser is true, ensure the system user exists
|
||||
if createUser {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for user creation: %w", err)
|
||||
}
|
||||
// Create user if not exists; use -m for home directory, -s for shell
|
||||
createCmd := fmt.Sprintf(
|
||||
`id '%s' >/dev/null 2>&1 || useradd -m -s /bin/bash '%s'`,
|
||||
systemUser, systemUser,
|
||||
)
|
||||
if cerr := session.Run(createCmd); cerr != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("failed to create system user '%s': %w", systemUser, cerr)
|
||||
}
|
||||
session.Close()
|
||||
logging.Info("Deploy: ensured system user '%s' exists on %s", systemUser, server.Hostname)
|
||||
|
||||
// Set initial password if provided
|
||||
if initialPassword != "" {
|
||||
pwSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for password setup: %w", err)
|
||||
}
|
||||
pwCmd := fmt.Sprintf(`echo '%s:%s' | chpasswd`, systemUser, initialPassword)
|
||||
if perr := pwSession.Run(pwCmd); perr != nil {
|
||||
pwSession.Close()
|
||||
logging.Warn("Deploy: failed to set initial password for user '%s' on %s: %v", systemUser, server.Hostname, perr)
|
||||
} else {
|
||||
pwSession.Close()
|
||||
logging.Info("Deploy: set initial password for user '%s' on %s", systemUser, server.Hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If sudo is true, create a sudoers.d entry with NOPASSWD
|
||||
if sudo {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for sudo setup: %w", err)
|
||||
}
|
||||
sudoCmd := fmt.Sprintf(
|
||||
`echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s && chmod 440 /etc/sudoers.d/%s`,
|
||||
systemUser, systemUser, systemUser,
|
||||
)
|
||||
if serr := session.Run(sudoCmd); serr != nil {
|
||||
session.Close()
|
||||
logging.Warn("Deploy: failed to add sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
|
||||
} else {
|
||||
session.Close()
|
||||
logging.Info("Deploy: ensured NOPASSWD sudo for user '%s' on %s", systemUser, server.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy the key to the system user's authorized_keys
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for key deployment: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
||||
if systemUser == "root" {
|
||||
homeDir = "/root"
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && echo '%s' >> %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown -R '%s':'%s' %s/.ssh && sort -u -o %s/.ssh/authorized_keys %s/.ssh/authorized_keys`,
|
||||
homeDir, homeDir, pubKey, homeDir, homeDir, systemUser, systemUser, homeDir, homeDir, homeDir,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
|
||||
return fmt.Errorf("failed to deploy key for user '%s': %w", systemUser, err)
|
||||
}
|
||||
|
||||
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key deployed to user '%s'", systemUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployKeyToUserWithPassword is like DeployKeyToUser but uses password authentication
|
||||
func (s *Service) DeployKeyToUserWithPassword(key *models.SSHKey, server *models.Server, password string, systemUser string, createUser, sudo bool, initialPassword string) error {
|
||||
logging.Debug("Deploy: connecting to %s@%s:%d with password to deploy key '%s' for system user '%s'",
|
||||
server.Username, server.Hostname, server.Port, key.Name, systemUser)
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("connection failed: %v", err))
|
||||
return fmt.Errorf("failed to connect to server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if createUser {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for user creation: %w", err)
|
||||
}
|
||||
createCmd := fmt.Sprintf(
|
||||
`id '%s' >/dev/null 2>&1 || useradd -m -s /bin/bash '%s'`,
|
||||
systemUser, systemUser,
|
||||
)
|
||||
if cerr := session.Run(createCmd); cerr != nil {
|
||||
session.Close()
|
||||
return fmt.Errorf("failed to create system user '%s': %w", systemUser, cerr)
|
||||
}
|
||||
session.Close()
|
||||
logging.Info("Deploy: ensured system user '%s' exists on %s", systemUser, server.Hostname)
|
||||
|
||||
// Set initial password if provided
|
||||
if initialPassword != "" {
|
||||
pwSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for password setup: %w", err)
|
||||
}
|
||||
pwCmd := fmt.Sprintf(`echo '%s:%s' | chpasswd`, systemUser, initialPassword)
|
||||
if perr := pwSession.Run(pwCmd); perr != nil {
|
||||
pwSession.Close()
|
||||
logging.Warn("Deploy: failed to set initial password for user '%s' on %s: %v", systemUser, server.Hostname, perr)
|
||||
} else {
|
||||
pwSession.Close()
|
||||
logging.Info("Deploy: set initial password for user '%s' on %s", systemUser, server.Hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sudo {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for sudo setup: %w", err)
|
||||
}
|
||||
sudoCmd := fmt.Sprintf(
|
||||
`echo '%s ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/%s && chmod 440 /etc/sudoers.d/%s`,
|
||||
systemUser, systemUser, systemUser,
|
||||
)
|
||||
if serr := session.Run(sudoCmd); serr != nil {
|
||||
session.Close()
|
||||
logging.Warn("Deploy: failed to add sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
|
||||
} else {
|
||||
session.Close()
|
||||
logging.Info("Deploy: ensured NOPASSWD sudo for user '%s' on %s", systemUser, server.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for key deployment: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
||||
if systemUser == "root" {
|
||||
homeDir = "/root"
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && echo '%s' >> %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown -R '%s':'%s' %s/.ssh && sort -u -o %s/.ssh/authorized_keys %s/.ssh/authorized_keys`,
|
||||
homeDir, homeDir, pubKey, homeDir, homeDir, systemUser, systemUser, homeDir, homeDir, homeDir,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
s.logDeployment(key.ID, server.ID, "failed", fmt.Sprintf("command failed: %v", err))
|
||||
return fmt.Errorf("failed to deploy key for user '%s': %w", systemUser, err)
|
||||
}
|
||||
|
||||
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key deployed to user '%s'", systemUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveKeyFromUser removes a public key from a specific system user's authorized_keys.
|
||||
// It connects as the server's admin user and manages the target systemUser's keys.
|
||||
func (s *Service) RemoveKeyFromUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
|
||||
logging.Debug("Deploy: connecting to %s@%s:%d to remove key '%s' from system user '%s'",
|
||||
server.Username, server.Hostname, server.Port, key.Name, systemUser)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to server for key removal: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for key removal: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
|
||||
|
||||
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
||||
if systemUser == "root" {
|
||||
homeDir = "/root"
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf(
|
||||
`grep -v '%s' %s/.ssh/authorized_keys > %s/.ssh/authorized_keys.tmp 2>/dev/null && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown '%s':'%s' %s/.ssh/authorized_keys || true`,
|
||||
escapedKey, homeDir, homeDir, homeDir, homeDir, homeDir, systemUser, systemUser, homeDir,
|
||||
)
|
||||
|
||||
if err := session.Run(cmd); err != nil {
|
||||
return fmt.Errorf("failed to remove key from user '%s': %w", systemUser, err)
|
||||
}
|
||||
|
||||
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("key removed from user '%s'", systemUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSystemUser removes a Linux system user from a server, including:
|
||||
// - Removing the SSH key from their authorized_keys
|
||||
// - Removing sudo rights (/etc/sudoers.d/<user>)
|
||||
// - Deleting the system user account (userdel -r)
|
||||
func (s *Service) RemoveSystemUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
|
||||
logging.Info("Deploy: removing system user '%s' from %s@%s:%d", systemUser, server.Username, server.Hostname, server.Port)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to server for user removal: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Step 1: Remove sudoers entry
|
||||
sudoSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for sudo removal: %w", err)
|
||||
}
|
||||
sudoCmd := fmt.Sprintf(`rm -f /etc/sudoers.d/'%s'`, systemUser)
|
||||
if serr := sudoSession.Run(sudoCmd); serr != nil {
|
||||
logging.Warn("Deploy: failed to remove sudo for user '%s' on %s: %v", systemUser, server.Hostname, serr)
|
||||
} else {
|
||||
logging.Info("Deploy: removed sudoers entry for '%s' on %s", systemUser, server.Hostname)
|
||||
}
|
||||
sudoSession.Close()
|
||||
|
||||
// Step 2: Kill all processes of the user (so userdel doesn't fail)
|
||||
killSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for process kill: %w", err)
|
||||
}
|
||||
killCmd := fmt.Sprintf(`pkill -u '%s' 2>/dev/null || true`, systemUser)
|
||||
killSession.Run(killCmd)
|
||||
killSession.Close()
|
||||
|
||||
// Small delay to let processes terminate
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Step 3: Delete the system user with home directory
|
||||
delSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for user deletion: %w", err)
|
||||
}
|
||||
defer delSession.Close()
|
||||
|
||||
delCmd := fmt.Sprintf(`userdel -r '%s' 2>/dev/null || userdel '%s' 2>/dev/null`, systemUser, systemUser)
|
||||
if derr := delSession.Run(delCmd); derr != nil {
|
||||
return fmt.Errorf("failed to delete system user '%s': %w", systemUser, derr)
|
||||
}
|
||||
|
||||
logging.Info("Deploy: successfully deleted system user '%s' from %s", systemUser, server.Hostname)
|
||||
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("system user '%s' deleted", systemUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableSystemUser locks a system user account on a server by:
|
||||
// - Removing the SSH key from authorized_keys
|
||||
// - Locking the account (usermod --lock)
|
||||
// - Setting the shell to /usr/sbin/nologin
|
||||
func (s *Service) DisableSystemUser(key *models.SSHKey, server *models.Server, authPrivateKey []byte, systemUser string) error {
|
||||
logging.Info("Deploy: disabling system user '%s' on %s@%s:%d", systemUser, server.Username, server.Hostname, server.Port)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse authentication key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: server.Username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", server.Hostname, server.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to server for user disable: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Step 1: Remove SSH key from authorized_keys
|
||||
removeSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for key removal: %w", err)
|
||||
}
|
||||
pubKey := strings.TrimSpace(key.PublicKey)
|
||||
escapedKey := strings.ReplaceAll(pubKey, "'", "'\\''")
|
||||
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
||||
if systemUser == "root" {
|
||||
homeDir = "/root"
|
||||
}
|
||||
removeCmd := fmt.Sprintf(
|
||||
`grep -v '%s' %s/.ssh/authorized_keys > %s/.ssh/authorized_keys.tmp 2>/dev/null && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys || true`,
|
||||
escapedKey, homeDir, homeDir, homeDir, homeDir, homeDir,
|
||||
)
|
||||
removeSession.Run(removeCmd)
|
||||
removeSession.Close()
|
||||
|
||||
// Step 2: Lock the account
|
||||
lockSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for user lock: %w", err)
|
||||
}
|
||||
lockCmd := fmt.Sprintf(`usermod --lock '%s' 2>/dev/null || true`, systemUser)
|
||||
lockSession.Run(lockCmd)
|
||||
lockSession.Close()
|
||||
|
||||
// Step 3: Set shell to nologin
|
||||
shellSession, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session for shell change: %w", err)
|
||||
}
|
||||
defer shellSession.Close()
|
||||
shellCmd := fmt.Sprintf(`usermod --shell /usr/sbin/nologin '%s' 2>/dev/null || chsh -s /usr/sbin/nologin '%s' 2>/dev/null || true`, systemUser, systemUser)
|
||||
shellSession.Run(shellCmd)
|
||||
|
||||
logging.Info("Deploy: successfully disabled system user '%s' on %s", systemUser, server.Hostname)
|
||||
s.logDeployment(key.ID, server.ID, "success", fmt.Sprintf("system user '%s' disabled (locked + nologin)", systemUser))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection tests TCP connectivity to a server (port reachable)
|
||||
func (s *Service) TestConnection(hostname string, port int) error {
|
||||
logging.Debug("Testing TCP connection to %s:%d", hostname, port)
|
||||
addr := fmt.Sprintf("%s:%d", hostname, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach %s: %w", addr, err)
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSSHAuth tests actual SSH authentication to a server using a private key
|
||||
func (s *Service) TestSSHAuth(hostname string, port int, username string, privateKey []byte) error {
|
||||
logging.Debug("Testing SSH auth for %s@%s:%d", username, hostname, port)
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse master key: %w", err)
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", hostname, port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH authentication failed: %w", err)
|
||||
}
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// logDeployment records a deployment attempt
|
||||
func (s *Service) logDeployment(keyID, serverID int64, status, message string) {
|
||||
s.db.Exec(
|
||||
`INSERT INTO key_deployments (ssh_key_id, server_id, status, message) VALUES (?, ?, ?, ?)`,
|
||||
keyID, serverID, status, message,
|
||||
)
|
||||
}
|
||||
|
||||
// GetDeployments returns deployment history for a user's keys
|
||||
func (s *Service) GetDeployments(userID int64) ([]map[string]interface{}, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT kd.id, sk.name as key_name, srv.name as server_name, kd.status, kd.message, kd.deployed_at
|
||||
FROM key_deployments kd
|
||||
JOIN ssh_keys sk ON kd.ssh_key_id = sk.id
|
||||
JOIN servers srv ON kd.server_id = srv.id
|
||||
WHERE sk.user_id = ?
|
||||
ORDER BY kd.deployed_at DESC LIMIT 50`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deployments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deployments []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var keyName, serverName, status, message string
|
||||
var deployedAt time.Time
|
||||
if err := rows.Scan(&id, &keyName, &serverName, &status, &message, &deployedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
deployments = append(deployments, map[string]interface{}{
|
||||
"id": id,
|
||||
"key_name": keyName,
|
||||
"server_name": serverName,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"deployed_at": deployedAt,
|
||||
})
|
||||
}
|
||||
return deployments, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Service handles AES-256-GCM encryption/decryption
|
||||
type Service struct {
|
||||
key []byte // 32 bytes for AES-256
|
||||
}
|
||||
|
||||
// NewService creates a new encryption service from a passphrase
|
||||
func NewService(passphrase string) *Service {
|
||||
// Derive a 32-byte key from the passphrase using SHA-256
|
||||
hash := sha256.Sum256([]byte(passphrase))
|
||||
return &Service{key: hash[:]}
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext using AES-256-GCM and returns base64-encoded ciphertext
|
||||
func (s *Service) Encrypt(plaintext string) (string, error) {
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts base64-encoded AES-256-GCM ciphertext
|
||||
func (s *Service) Decrypt(encoded string) (string, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(s.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
svc := NewService("test-passphrase")
|
||||
if svc == nil {
|
||||
t.Fatal("NewService returned nil")
|
||||
}
|
||||
if len(svc.key) != 32 {
|
||||
t.Fatalf("expected 32-byte key, got %d", len(svc.key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundtrip(t *testing.T) {
|
||||
svc := NewService("my-secret-passphrase-32-chars!!")
|
||||
plaintext := "This is a private SSH key content"
|
||||
|
||||
encrypted, err := svc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if encrypted == plaintext {
|
||||
t.Fatal("Encrypted text should differ from plaintext")
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != plaintext {
|
||||
t.Fatalf("Decrypted text mismatch: got %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
|
||||
svc := NewService("test-passphrase")
|
||||
plaintext := "same input"
|
||||
|
||||
enc1, err := svc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt 1 failed: %v", err)
|
||||
}
|
||||
|
||||
enc2, err := svc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if enc1 == enc2 {
|
||||
t.Fatal("Two encryptions of the same plaintext should produce different ciphertexts (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongKey(t *testing.T) {
|
||||
svc1 := NewService("correct-passphrase")
|
||||
svc2 := NewService("wrong-passphrase")
|
||||
|
||||
encrypted, err := svc1.Encrypt("secret data")
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc2.Decrypt(encrypted)
|
||||
if err == nil {
|
||||
t.Fatal("Decrypt with wrong key should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidBase64(t *testing.T) {
|
||||
svc := NewService("test")
|
||||
_, err := svc.Decrypt("not-valid-base64!!!")
|
||||
if err == nil {
|
||||
t.Fatal("Decrypt of invalid base64 should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptTooShort(t *testing.T) {
|
||||
svc := NewService("test")
|
||||
// Valid base64 but too short for nonce + ciphertext
|
||||
_, err := svc.Decrypt("AQID")
|
||||
if err == nil {
|
||||
t.Fatal("Decrypt of too-short ciphertext should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptEmptyString(t *testing.T) {
|
||||
svc := NewService("test")
|
||||
|
||||
encrypted, err := svc.Encrypt("")
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt empty string failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt empty string failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != "" {
|
||||
t.Fatalf("Expected empty string, got %q", decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptLargePayload(t *testing.T) {
|
||||
svc := NewService("test")
|
||||
// Simulate a large SSH private key
|
||||
large := strings.Repeat("A", 4096)
|
||||
|
||||
encrypted, err := svc.Encrypt(large)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt large payload failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := svc.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt large payload failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != large {
|
||||
t.Fatal("Large payload roundtrip mismatch")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package keys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/encryption"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/sshutil"
|
||||
)
|
||||
|
||||
// Service handles SSH key operations
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
enc *encryption.Service
|
||||
}
|
||||
|
||||
// NewService creates a new key service with encryption
|
||||
func NewService(db *database.DB, enc *encryption.Service) *Service {
|
||||
return &Service{db: db, enc: enc}
|
||||
}
|
||||
|
||||
// GenerateKey generates a new SSH key pair and stores it encrypted
|
||||
func (s *Service) GenerateKey(userID int64, name, keyType string, bits int, comment string) (*models.SSHKey, error) {
|
||||
return s.generateKey(userID, name, keyType, bits, comment)
|
||||
}
|
||||
|
||||
// generateKey is the internal key generation function
|
||||
func (s *Service) generateKey(userID int64, name, keyType string, bits int, comment string) (*models.SSHKey, error) {
|
||||
var privPEM, pubKey []byte
|
||||
var fingerprint string
|
||||
var err error
|
||||
|
||||
switch keyType {
|
||||
case "rsa":
|
||||
privPEM, pubKey, fingerprint, err = sshutil.GenerateRSAKey(bits, comment)
|
||||
case "ed25519":
|
||||
privPEM, pubKey, fingerprint, err = sshutil.GenerateEd25519Key(comment)
|
||||
bits = 256
|
||||
case "ed448":
|
||||
privPEM, pubKey, fingerprint, err = sshutil.GenerateEd448Key(comment)
|
||||
bits = 456
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported key type: %s", keyType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt private key before storage
|
||||
encPrivKey, err := s.enc.Encrypt(string(privPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID, name, keyType, bits, fingerprint, string(pubKey), encPrivKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store key: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.SSHKey{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
KeyType: keyType,
|
||||
Bits: bits,
|
||||
Fingerprint: fingerprint,
|
||||
PublicKey: string(pubKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ImportKey imports an existing key pair (encrypts the private key)
|
||||
func (s *Service) ImportKey(userID int64, name string, privateKeyPEM []byte) (*models.SSHKey, error) {
|
||||
pubKey, fingerprint, keyType, err := sshutil.ParsePrivateKey(privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Map SSH key type names
|
||||
kt := "rsa"
|
||||
bits := 0
|
||||
switch keyType {
|
||||
case "ssh-rsa":
|
||||
kt = "rsa"
|
||||
bits = 2048 // approximate
|
||||
case "ssh-ed25519":
|
||||
kt = "ed25519"
|
||||
bits = 256
|
||||
case "ssh-ed448":
|
||||
kt = "ed448"
|
||||
bits = 456
|
||||
}
|
||||
|
||||
// Encrypt private key before storage
|
||||
encPrivKey, err := s.enc.Encrypt(string(privateKeyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO ssh_keys (user_id, name, key_type, bits, fingerprint, public_key, private_key_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID, name, kt, bits, fingerprint, string(pubKey), encPrivKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store key: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.SSHKey{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
KeyType: kt,
|
||||
Bits: bits,
|
||||
Fingerprint: fingerprint,
|
||||
PublicKey: string(pubKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetKeysByUser returns all keys for a user
|
||||
func (s *Service) GetKeysByUser(userID int64) ([]models.SSHKey, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
|
||||
FROM ssh_keys WHERE user_id = ? ORDER BY created_at DESC`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []models.SSHKey
|
||||
for rows.Next() {
|
||||
var k models.SSHKey
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan key: %w", err)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetAllKeys returns all SSH keys for all users (admin use)
|
||||
func (s *Service) GetAllKeys() ([]models.SSHKey, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
|
||||
FROM ssh_keys ORDER BY user_id, name ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query all keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []models.SSHKey
|
||||
for rows.Next() {
|
||||
var k models.SSHKey
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan key: %w", err)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetKeyByID returns a specific key with decrypted private key
|
||||
func (s *Service) GetKeyByID(keyID, userID int64) (*models.SSHKey, error) {
|
||||
key := &models.SSHKey{}
|
||||
var encPrivKey string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, private_key_enc, created_at
|
||||
FROM ssh_keys WHERE id = ? AND user_id = ?`, keyID, userID,
|
||||
).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyType, &key.Bits, &key.Fingerprint, &key.PublicKey, &encPrivKey, &key.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key not found: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt private key
|
||||
decrypted, err := s.enc.Decrypt(encPrivKey)
|
||||
if err != nil {
|
||||
// Fallback: might be an old unencrypted key
|
||||
key.PrivateKeyEnc = encPrivKey
|
||||
} else {
|
||||
key.PrivateKeyEnc = decrypted
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DeleteKey deletes a key
|
||||
func (s *Service) DeleteKey(keyID, userID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM ssh_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete key: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllKeysWithOwner returns all SSH keys with their owner's username (for admin/owner views)
|
||||
func (s *Service) GetAllKeysWithOwner() ([]models.SSHKeyWithOwner, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT k.id, k.user_id, k.name, k.key_type, k.bits, k.fingerprint, k.public_key, k.created_at,
|
||||
COALESCE(u.username, '(deleted)')
|
||||
FROM ssh_keys k
|
||||
LEFT JOIN users u ON k.user_id = u.id
|
||||
ORDER BY u.username ASC, k.name ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query all keys with owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var keys []models.SSHKeyWithOwner
|
||||
for rows.Next() {
|
||||
var k models.SSHKeyWithOwner
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyType, &k.Bits, &k.Fingerprint, &k.PublicKey, &k.CreatedAt, &k.OwnerUsername); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan key: %w", err)
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetKeyByIDGlobal returns a specific key without user_id check (admin/owner access)
|
||||
// Note: Private key is NOT returned decrypted — only metadata and public key
|
||||
func (s *Service) GetKeyByIDGlobal(keyID int64) (*models.SSHKey, error) {
|
||||
key := &models.SSHKey{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, key_type, bits, fingerprint, public_key, created_at
|
||||
FROM ssh_keys WHERE id = ?`, keyID,
|
||||
).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyType, &key.Bits, &key.Fingerprint, &key.PublicKey, &key.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key not found: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DeleteKeyGlobal deletes a key without user_id check (admin/owner access)
|
||||
func (s *Service) DeleteKeyGlobal(keyID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM ssh_keys WHERE id = ?`, keyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete key: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("key not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- System Master Key ---
|
||||
// The system master key is an Ed25519 key pair used by Keywarden to authenticate
|
||||
// against remote servers for key deployments. It is generated once on first startup
|
||||
// and stored encrypted in the settings table. It cannot be deleted, only regenerated.
|
||||
|
||||
// EnsureSystemMasterKey generates the system master key if it doesn't exist yet.
|
||||
// Returns the public key string.
|
||||
func (s *Service) EnsureSystemMasterKey() (string, error) {
|
||||
pub, err := s.getSetting("system_master_key_public")
|
||||
if err == nil && pub != "" {
|
||||
return pub, nil
|
||||
}
|
||||
// Generate new master key
|
||||
return s.generateSystemMasterKey()
|
||||
}
|
||||
|
||||
// generateSystemMasterKey generates a new Ed25519 key pair and stores it in settings.
|
||||
func (s *Service) generateSystemMasterKey() (string, error) {
|
||||
privPEM, pubKey, fingerprint, err := sshutil.GenerateEd25519Key("keywarden-system-master")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate system master key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt private key
|
||||
encPriv, err := s.enc.Encrypt(string(privPEM))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt system master key: %w", err)
|
||||
}
|
||||
|
||||
pubStr := string(pubKey)
|
||||
|
||||
// Store in settings table
|
||||
if err := s.setSetting("system_master_key_private", encPriv); err != nil {
|
||||
return "", fmt.Errorf("failed to store system master key private: %w", err)
|
||||
}
|
||||
if err := s.setSetting("system_master_key_public", pubStr); err != nil {
|
||||
return "", fmt.Errorf("failed to store system master key public: %w", err)
|
||||
}
|
||||
if err := s.setSetting("system_master_key_fingerprint", fingerprint); err != nil {
|
||||
return "", fmt.Errorf("failed to store system master key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
return pubStr, nil
|
||||
}
|
||||
|
||||
// GetSystemMasterKeyPublic returns the public key of the system master key.
|
||||
func (s *Service) GetSystemMasterKeyPublic() (string, error) {
|
||||
pub, err := s.getSetting("system_master_key_public")
|
||||
if err != nil || pub == "" {
|
||||
return "", fmt.Errorf("system master key not found")
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// GetSystemMasterKeyFingerprint returns the fingerprint of the system master key.
|
||||
func (s *Service) GetSystemMasterKeyFingerprint() (string, error) {
|
||||
fp, err := s.getSetting("system_master_key_fingerprint")
|
||||
if err != nil || fp == "" {
|
||||
return "", fmt.Errorf("system master key fingerprint not found")
|
||||
}
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// GetSystemMasterKeyPrivate returns the decrypted private key PEM of the system master key.
|
||||
func (s *Service) GetSystemMasterKeyPrivate() ([]byte, error) {
|
||||
encPriv, err := s.getSetting("system_master_key_private")
|
||||
if err != nil || encPriv == "" {
|
||||
return nil, fmt.Errorf("system master key not found")
|
||||
}
|
||||
decrypted, err := s.enc.Decrypt(encPriv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt system master key: %w", err)
|
||||
}
|
||||
return []byte(decrypted), nil
|
||||
}
|
||||
|
||||
// RegenerateSystemMasterKey generates a new system master key, replacing the old one.
|
||||
// Returns the new public key string.
|
||||
func (s *Service) RegenerateSystemMasterKey() (string, error) {
|
||||
return s.generateSystemMasterKey()
|
||||
}
|
||||
|
||||
// getSetting reads a value from the settings table.
|
||||
func (s *Service) getSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// setSetting writes a value to the settings table (upsert).
|
||||
func (s *Service) setSetting(key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
||||
key, value,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// EncryptValue encrypts a plaintext string using the application encryption key
|
||||
func (s *Service) EncryptValue(plaintext string) (string, error) {
|
||||
return s.enc.Encrypt(plaintext)
|
||||
}
|
||||
|
||||
// DecryptValue decrypts an encrypted string using the application encryption key
|
||||
func (s *Service) DecryptValue(ciphertext string) (string, error) {
|
||||
return s.enc.Decrypt(ciphertext)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level represents the severity of a log message
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelError Level = iota // only errors
|
||||
LevelWarn // errors + warnings
|
||||
LevelInfo // errors + warnings + info (default)
|
||||
LevelDebug // errors + warnings + info + debug
|
||||
LevelTrace // everything, including very verbose trace output
|
||||
)
|
||||
|
||||
// String returns the human-readable name of the log level
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelError:
|
||||
return "ERROR"
|
||||
case LevelWarn:
|
||||
return "WARN"
|
||||
case LevelInfo:
|
||||
return "INFO"
|
||||
case LevelDebug:
|
||||
return "DEBUG"
|
||||
case LevelTrace:
|
||||
return "TRACE"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// Logger is the application-wide structured logger
|
||||
type Logger struct {
|
||||
level Level
|
||||
}
|
||||
|
||||
// global singleton – initialised via Init()
|
||||
var global *Logger
|
||||
|
||||
func init() {
|
||||
// Set log flags for consistent timestamp output (goes to stdout/stderr → Docker logs)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||
log.SetOutput(os.Stdout)
|
||||
|
||||
// Default until Init() is called
|
||||
global = &Logger{level: LevelInfo}
|
||||
}
|
||||
|
||||
// Init creates the global logger from the KEYWARDEN_LOG_LEVEL env var.
|
||||
// Valid values: ERROR, WARN, INFO (default), DEBUG, TRACE
|
||||
func Init(envValue string) {
|
||||
global = &Logger{level: ParseLevel(envValue)}
|
||||
global.Info("Log level set to %s", global.level.String())
|
||||
}
|
||||
|
||||
// ParseLevel converts a string to a Level. Defaults to INFO on unknown input.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||
case "ERROR":
|
||||
return LevelError
|
||||
case "WARN", "WARNING":
|
||||
return LevelWarn
|
||||
case "INFO", "":
|
||||
return LevelInfo
|
||||
case "DEBUG":
|
||||
return LevelDebug
|
||||
case "TRACE":
|
||||
return LevelTrace
|
||||
default:
|
||||
return LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// GetLevel returns the current global log level
|
||||
func GetLevel() Level {
|
||||
return global.level
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logging methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (l *Logger) log(lvl Level, format string, args ...interface{}) {
|
||||
if lvl > l.level {
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Printf("[%-5s] %s", lvl.String(), msg)
|
||||
}
|
||||
|
||||
// Error logs at ERROR level (always shown)
|
||||
func Error(format string, args ...interface{}) { global.log(LevelError, format, args...) }
|
||||
|
||||
// Warn logs at WARN level
|
||||
func Warn(format string, args ...interface{}) { global.log(LevelWarn, format, args...) }
|
||||
|
||||
// Info logs at INFO level
|
||||
func Info(format string, args ...interface{}) { global.log(LevelInfo, format, args...) }
|
||||
|
||||
// Debug logs at DEBUG level
|
||||
func Debug(format string, args ...interface{}) { global.log(LevelDebug, format, args...) }
|
||||
|
||||
// Trace logs at TRACE level (very verbose)
|
||||
func Trace(format string, args ...interface{}) { global.log(LevelTrace, format, args...) }
|
||||
|
||||
// Fatal logs at ERROR level and exits the process (like log.Fatalf)
|
||||
func Fatal(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Fatalf("[ERROR] %s", msg)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Error-returning variant for wrapping + logging in one call
|
||||
func (l *Logger) Info(format string, args ...interface{}) {
|
||||
l.log(LevelInfo, format, args...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP Request Logging Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture the status code and bytes written
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
bytesWritten int
|
||||
}
|
||||
|
||||
func newResponseWriter(w http.ResponseWriter) *responseWriter {
|
||||
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.bytesWritten += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RequestLogger returns middleware that logs every HTTP request.
|
||||
// Output format:
|
||||
//
|
||||
// [INFO ] HTTP | 200 | 12.34ms | 192.168.1.1 | GET /dashboard | user=admin | Mozilla/5.0 ...
|
||||
//
|
||||
// At DEBUG level it additionally logs request headers.
|
||||
// At TRACE level it logs everything including cookies (except values).
|
||||
//
|
||||
// An optional clientIPFunc can be provided to customise IP extraction
|
||||
// (e.g. using trusted-proxy-aware logic). If omitted, the built-in
|
||||
// extractClientIP is used.
|
||||
func RequestLogger(getUserName func(r *http.Request) string, clientIPFunc ...func(r *http.Request) string) func(http.Handler) http.Handler {
|
||||
getIP := extractClientIP
|
||||
if len(clientIPFunc) > 0 && clientIPFunc[0] != nil {
|
||||
getIP = clientIPFunc[0]
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip logging for static assets at INFO level to reduce noise
|
||||
isStatic := strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/avatar/")
|
||||
|
||||
start := time.Now()
|
||||
wrapped := newResponseWriter(w)
|
||||
|
||||
// Process the request
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
// Determine client IP
|
||||
clientIP := getIP(r)
|
||||
|
||||
// Determine username (empty string if not authenticated)
|
||||
username := ""
|
||||
if getUserName != nil {
|
||||
username = getUserName(r)
|
||||
}
|
||||
|
||||
userAgent := r.UserAgent()
|
||||
if len(userAgent) > 120 {
|
||||
userAgent = userAgent[:120] + "…"
|
||||
}
|
||||
|
||||
// Build the log line
|
||||
userPart := ""
|
||||
if username != "" {
|
||||
userPart = fmt.Sprintf(" | user=%s", username)
|
||||
}
|
||||
|
||||
// Static assets: only log at DEBUG or higher
|
||||
if isStatic {
|
||||
Debug("HTTP | %d | %10v | %-15s | %s %s%s | %s",
|
||||
wrapped.statusCode, duration.Round(time.Microsecond),
|
||||
clientIP, r.Method, r.URL.Path, userPart, userAgent)
|
||||
} else {
|
||||
Info("HTTP | %d | %10v | %-15s | %s %s%s | %s",
|
||||
wrapped.statusCode, duration.Round(time.Microsecond),
|
||||
clientIP, r.Method, r.URL.Path, userPart, userAgent)
|
||||
}
|
||||
|
||||
// At TRACE level, log response size and all request headers
|
||||
if GetLevel() >= LevelTrace {
|
||||
Trace("HTTP response: %d bytes written for %s %s", wrapped.bytesWritten, r.Method, r.URL.Path)
|
||||
Trace("HTTP request headers for %s %s:", r.Method, r.URL.Path)
|
||||
for name, values := range r.Header {
|
||||
// Redact sensitive headers
|
||||
if strings.EqualFold(name, "Cookie") || strings.EqualFold(name, "Authorization") {
|
||||
Trace(" %s: [REDACTED]", name)
|
||||
} else {
|
||||
Trace(" %s: %s", name, strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractClientIP gets the real client IP, respecting reverse proxy headers
|
||||
func extractClientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.SplitN(fwd, ",", 2)
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if real := r.Header.Get("X-Real-Ip"); real != "" {
|
||||
return real
|
||||
}
|
||||
// r.RemoteAddr is "ip:port"
|
||||
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
|
||||
return r.RemoteAddr[:idx]
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/config"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/logging"
|
||||
)
|
||||
|
||||
// LoginNotificationData holds the template data for a login notification email
|
||||
type LoginNotificationData struct {
|
||||
Username string
|
||||
IPAddress string
|
||||
Timestamp string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// InvitationData holds the template data for an invitation email
|
||||
type InvitationData struct {
|
||||
Username string
|
||||
InviteURL string
|
||||
ExpiresIn string
|
||||
}
|
||||
|
||||
// Service handles email sending
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewService creates a new mail service
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
enabled := cfg.SMTPEnabled && cfg.SMTPHost != ""
|
||||
if enabled {
|
||||
logging.Info("Email notifications enabled (SMTP: %s:%s)", cfg.SMTPHost, cfg.SMTPPort)
|
||||
} else {
|
||||
logging.Info("Email notifications disabled (no SMTP host configured)")
|
||||
}
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the mail service is configured and active
|
||||
func (s *Service) IsEnabled() bool {
|
||||
return s.enabled
|
||||
}
|
||||
|
||||
// SendLoginNotification sends a login notification email to the user.
|
||||
// This runs synchronously but callers should invoke it in a goroutine.
|
||||
func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationData) error {
|
||||
if !s.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
htmlBody, err := renderTemplate(loginNotificationHTML, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render HTML template: %w", err)
|
||||
}
|
||||
|
||||
txtBody, err := renderTemplate(loginNotificationTXT, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render TXT template: %w", err)
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Keywarden: Login notification for %s", data.Username)
|
||||
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
|
||||
}
|
||||
|
||||
// SendTestEmail sends a test email to verify SMTP configuration
|
||||
func (s *Service) SendTestEmail(toEmail string) error {
|
||||
if !s.enabled {
|
||||
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
|
||||
}
|
||||
|
||||
subject := "Keywarden: SMTP Test Email"
|
||||
txtBody := "This is a test email from Keywarden.\n\nIf you received this, your SMTP configuration is working correctly.\n"
|
||||
htmlBody := `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f1f5f9;">
|
||||
<div style="max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: #206bc4; margin-top: 0;">🔑 Keywarden – SMTP Test</h2>
|
||||
<p>This is a test email from Keywarden.</p>
|
||||
<p style="color: #4caf50; font-weight: bold;">✅ Your SMTP configuration is working correctly.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
|
||||
}
|
||||
|
||||
// SendInvitation sends an invitation email with a registration link to a new user.
|
||||
func (s *Service) SendInvitation(toEmail string, data InvitationData) error {
|
||||
if !s.enabled {
|
||||
return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)")
|
||||
}
|
||||
|
||||
htmlBody, err := renderTemplate(invitationHTML, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render invitation HTML template: %w", err)
|
||||
}
|
||||
|
||||
txtBody, err := renderTemplate(invitationTXT, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render invitation TXT template: %w", err)
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Keywarden: You have been invited – %s", data.Username)
|
||||
return s.sendMultipart(toEmail, subject, txtBody, htmlBody)
|
||||
}
|
||||
|
||||
// sendMultipart sends a multipart (text + HTML) email
|
||||
func (s *Service) sendMultipart(to, subject, textBody, htmlBody string) error {
|
||||
logging.Info("Sending email: to=%s subject='%s' smtp=%s:%s", to, subject, s.cfg.SMTPHost, s.cfg.SMTPPort)
|
||||
logging.Debug("Email details: from=%s tls=%v", s.cfg.SMTPFrom, s.cfg.SMTPTLS)
|
||||
|
||||
boundary := fmt.Sprintf("keywarden-%d", time.Now().UnixNano())
|
||||
|
||||
// Encode Subject per RFC 2047 so non-ASCII characters (e.g. en-dash)
|
||||
// are transmitted safely through all MTAs.
|
||||
encodedSubject := mime.QEncoding.Encode("utf-8", subject)
|
||||
|
||||
headers := map[string]string{
|
||||
"From": s.cfg.SMTPFrom,
|
||||
"To": to,
|
||||
"Subject": encodedSubject,
|
||||
"MIME-Version": "1.0",
|
||||
"Content-Type": fmt.Sprintf("multipart/alternative; boundary=\"%s\"", boundary),
|
||||
"Date": time.Now().Format(time.RFC1123Z),
|
||||
"X-Mailer": "Keywarden SSH Key Management",
|
||||
}
|
||||
|
||||
var msg bytes.Buffer
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// Text part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
|
||||
msg.WriteString(textBody)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
// HTML part
|
||||
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||
msg.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
|
||||
msg.WriteString(htmlBody)
|
||||
msg.WriteString("\r\n")
|
||||
|
||||
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
|
||||
|
||||
err := s.send(to, msg.Bytes())
|
||||
if err != nil {
|
||||
logging.Error("Email delivery failed: to=%s error=%v", to, err)
|
||||
} else {
|
||||
logging.Info("Email delivered successfully: to=%s", to)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// send delivers a raw email message via SMTP
|
||||
func (s *Service) send(to string, msg []byte) error {
|
||||
addr := net.JoinHostPort(s.cfg.SMTPHost, s.cfg.SMTPPort)
|
||||
|
||||
var auth smtp.Auth
|
||||
if s.cfg.SMTPUser != "" {
|
||||
auth = smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPassword, s.cfg.SMTPHost)
|
||||
}
|
||||
|
||||
if s.cfg.SMTPTLS {
|
||||
// STARTTLS or implicit TLS
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.cfg.SMTPHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
// Try implicit TLS first (port 465), fall back to STARTTLS
|
||||
if s.cfg.SMTPPort == "465" {
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TLS dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP client creation failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP DATA failed: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close data failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// STARTTLS (port 587 etc.)
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP client creation failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("STARTTLS failed: %w", err)
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP DATA failed: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close data failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// Plain SMTP (no TLS) – use manual client to avoid Go's smtp.SendMail
|
||||
// automatically attempting STARTTLS when the server advertises it.
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP client creation failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(s.cfg.SMTPFrom); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP DATA failed: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close data failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func renderTemplate(tmplStr string, data interface{}) (string, error) {
|
||||
tmpl, err := template.New("email").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// --- Email Templates ---
|
||||
|
||||
var loginNotificationHTML = strings.TrimSpace(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
|
||||
<!-- Header -->
|
||||
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">🔑 Keywarden</h1>
|
||||
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
|
||||
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">Login Notification</h2>
|
||||
<p style="color: #475569; line-height: 1.6;">
|
||||
A successful login to your Keywarden account was detected:
|
||||
</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">User</td>
|
||||
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">IP Address</td>
|
||||
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.IPAddress}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Time</td>
|
||||
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Timestamp}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Browser</td>
|
||||
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.UserAgent}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
|
||||
<p style="margin: 0; color: #856404; font-size: 14px;">
|
||||
⚠️ <strong>If this was not you</strong>, please change your password immediately and review your account security settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
|
||||
You received this email because login notifications are enabled for your account. You can disable them in the Settings page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
|
||||
© 2026 Keywarden – Centralized SSH Key Management and Deployment
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
var loginNotificationTXT = strings.TrimSpace(`
|
||||
Keywarden - Login Notification
|
||||
============================
|
||||
|
||||
A successful login to your Keywarden account was detected:
|
||||
|
||||
User: {{.Username}}
|
||||
IP Address: {{.IPAddress}}
|
||||
Time: {{.Timestamp}}
|
||||
Browser: {{.UserAgent}}
|
||||
|
||||
If this was not you, please change your password immediately
|
||||
and review your account security settings.
|
||||
|
||||
--
|
||||
You received this email because login notifications are enabled
|
||||
for your account. You can disable them in the Settings page.
|
||||
|
||||
Keywarden - Centralized SSH Key Management and Deployment
|
||||
`)
|
||||
|
||||
var invitationHTML = strings.TrimSpace(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
|
||||
<!-- Header -->
|
||||
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">🔑 Keywarden</h1>
|
||||
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
|
||||
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">You have been invited!</h2>
|
||||
<p style="color: #475569; line-height: 1.6;">
|
||||
An account has been created for you in Keywarden. Please complete your registration by setting a password.
|
||||
</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">Username</td>
|
||||
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Valid for</td>
|
||||
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.ExpiresIn}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="{{.InviteURL}}" style="display: inline-block; background: #206bc4; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
||||
🚀 Complete Registration
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 16px; margin: 24px 0;">
|
||||
<p style="margin: 0; color: #0369a1; font-size: 14px;">
|
||||
🛈 If the button does not work, copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="margin: 8px 0 0; color: #0369a1; font-size: 12px; word-break: break-all;">{{.InviteURL}}</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
|
||||
<p style="margin: 0; color: #856404; font-size: 14px;">
|
||||
⚠️ <strong>This link is valid for {{.ExpiresIn}} and can only be used once.</strong> If you did not expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
|
||||
This is an automated invitation from Keywarden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
|
||||
© 2026 Keywarden – Centralized SSH Key Management and Deployment
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
var invitationTXT = strings.TrimSpace(`
|
||||
Keywarden - Invitation
|
||||
======================
|
||||
|
||||
You have been invited to Keywarden!
|
||||
|
||||
An account has been created for you. Please complete your registration
|
||||
by setting a password.
|
||||
|
||||
Username: {{.Username}}
|
||||
Valid for: {{.ExpiresIn}}
|
||||
|
||||
Complete your registration here:
|
||||
{{.InviteURL}}
|
||||
|
||||
This link is valid for {{.ExpiresIn}} and can only be used once.
|
||||
If you did not expect this invitation, you can safely ignore this email.
|
||||
|
||||
--
|
||||
Keywarden - Centralized SSH Key Management and Deployment
|
||||
`)
|
||||
@@ -0,0 +1,180 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// User represents a registered user
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
MFAEnabled bool `json:"mfa_enabled"`
|
||||
MFASecret string `json:"-"`
|
||||
Theme string `json:"theme"` // "auto", "light", "dark"
|
||||
EmailNotifyLogin bool `json:"email_notify_login"`
|
||||
AvatarBase64 string `json:"avatar_base64"` // base64-encoded profile picture (data URI)
|
||||
MustChangePassword bool `json:"must_change_password"` // force password change on next login
|
||||
FailedLoginAttempts int `json:"failed_login_attempts"` // consecutive failed login attempts
|
||||
LockedUntil *time.Time `json:"locked_until"` // account locked until this time
|
||||
LastLoginAt *time.Time `json:"last_login_at"` // last successful login
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PasswordPolicy holds the password complexity requirements
|
||||
type PasswordPolicy struct {
|
||||
MinLength int `json:"min_length"`
|
||||
RequireUpper bool `json:"require_upper"`
|
||||
RequireLower bool `json:"require_lower"`
|
||||
RequireDigit bool `json:"require_digit"`
|
||||
RequireSpecial bool `json:"require_special"`
|
||||
}
|
||||
|
||||
// SSHKey represents a stored SSH key pair
|
||||
type SSHKey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
KeyType string `json:"key_type"` // "rsa", "ed25519", or "ed448"
|
||||
Bits int `json:"bits"` // 2048, 4096 for RSA; 256 for Ed25519; 456 for Ed448
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
PrivateKeyEnc string `json:"-"` // encrypted private key
|
||||
PassphraseEnc string `json:"-"` // encrypted passphrase (optional)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SSHKeyWithOwner extends SSHKey with the owner's username for admin views
|
||||
type SSHKeyWithOwner struct {
|
||||
SSHKey
|
||||
OwnerUsername string `json:"owner_username"`
|
||||
}
|
||||
|
||||
// Server represents a remote SSH server
|
||||
type Server struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// KeyDeployment represents a key deployed to a server
|
||||
type KeyDeployment struct {
|
||||
ID int64 `json:"id"`
|
||||
SSHKeyID int64 `json:"ssh_key_id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
DeployedAt time.Time `json:"deployed_at"`
|
||||
Status string `json:"status"` // "pending", "success", "failed"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ServerGroup represents a group of servers
|
||||
type ServerGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ServerGroupWithCount extends ServerGroup with the number of member servers
|
||||
type ServerGroupWithCount struct {
|
||||
ServerGroup
|
||||
ServerCount int `json:"server_count"`
|
||||
}
|
||||
|
||||
// AuditLog represents an audit trail entry
|
||||
type AuditLog struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Action string `json:"action"`
|
||||
Details string `json:"details"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CronJob represents a scheduled temporary access job
|
||||
type CronJob struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
SSHKeyID int64 `json:"ssh_key_id"`
|
||||
ServerID int64 `json:"server_id"` // 0 if targeting a group
|
||||
GroupID int64 `json:"group_id"` // 0 if targeting a single server
|
||||
Schedule string `json:"schedule"` // "once", "hourly", "daily", "weekly", "monthly"
|
||||
ScheduledAt time.Time `json:"scheduled_at"`
|
||||
NextRun time.Time `json:"next_run"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
RemoveAfterMin int `json:"remove_after_min"` // 0 = permanent
|
||||
Status string `json:"status"` // "active", "paused", "running", "done", "failed"
|
||||
Message string `json:"message"`
|
||||
Timezone string `json:"timezone"` // IANA timezone, e.g. "Europe/Berlin"
|
||||
TimeOfDay string `json:"time_of_day"` // "HH:MM" for daily/weekly/monthly
|
||||
DayOfWeek int `json:"day_of_week"` // 0=Sunday..6=Saturday (-1=unset)
|
||||
DayOfMonth int `json:"day_of_month"` // 1-31 (0=unset)
|
||||
MinuteOfHour int `json:"minute_of_hour"` // 0-59 for hourly schedule
|
||||
TargetUserID int64 `json:"target_user_id"` // KeyWarden user to grant access
|
||||
SystemUser string `json:"system_user"` // system user on target host
|
||||
Sudo bool `json:"sudo"` // grant sudo rights
|
||||
CreateUser bool `json:"create_user"` // create system user if missing
|
||||
InitialPassword string `json:"initial_password"` // encrypted initial password for created user
|
||||
ExpiryAction string `json:"expiry_action"` // "remove_key", "disable_user", "delete_user"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CronJobDisplay extends CronJob with resolved names for UI display
|
||||
type CronJobDisplay struct {
|
||||
CronJob
|
||||
KeyName string `json:"key_name"`
|
||||
TargetName string `json:"target_name"`
|
||||
TargetType string `json:"target_type"` // "host" or "group"
|
||||
TargetUsername string `json:"target_username"` // KeyWarden username
|
||||
}
|
||||
|
||||
// AccessAssignment represents an access assignment (user+key → host/group)
|
||||
type AccessAssignment struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"` // Keywarden user
|
||||
SSHKeyID int64 `json:"ssh_key_id"` // SSH key to deploy
|
||||
ServerID int64 `json:"server_id"` // target host (0 if group)
|
||||
GroupID int64 `json:"group_id"` // target group (0 if single host)
|
||||
SystemUser string `json:"system_user"` // system user on target host
|
||||
DesiredState string `json:"desired_state"` // "present" or "absent"
|
||||
Sudo bool `json:"sudo"` // grant sudo rights
|
||||
CreateUser bool `json:"create_user"` // create system user if missing
|
||||
InitialPassword string `json:"initial_password"` // encrypted initial password for created user
|
||||
Status string `json:"status"` // "pending", "synced", "failed"
|
||||
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AccessAssignmentDisplay extends AccessAssignment with resolved names for UI
|
||||
type AccessAssignmentDisplay struct {
|
||||
AccessAssignment
|
||||
Username string `json:"username"`
|
||||
KeyName string `json:"key_name"`
|
||||
TargetName string `json:"target_name"`
|
||||
TargetType string `json:"target_type"` // "host" or "group"
|
||||
}
|
||||
|
||||
// InvitationToken represents a one-time invitation link for a new user
|
||||
type InvitationToken struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CSRFMiddleware returns middleware that implements the Double-Submit Cookie
|
||||
// pattern for CSRF protection.
|
||||
//
|
||||
// On every request a _csrf cookie is ensured (generated if absent). On
|
||||
// state-changing methods (POST, PUT, DELETE, PATCH) the middleware validates
|
||||
// that the request carries a matching token either as:
|
||||
// - a form field named "_csrf", or
|
||||
// - an X-CSRF-Token request header (for AJAX / fetch calls).
|
||||
//
|
||||
// The cookie is NOT HttpOnly so that client-side JavaScript can read the
|
||||
// value and inject it into forms / fetch headers automatically.
|
||||
func CSRFMiddleware(secureCookies bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// --- Ensure CSRF token exists ---
|
||||
token := ""
|
||||
if c, err := r.Cookie("_csrf"); err == nil && len(c.Value) == 64 {
|
||||
token = c.Value
|
||||
}
|
||||
if token == "" {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
token = fmt.Sprintf("%x", b)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "_csrf",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: false, // JS must be able to read it
|
||||
Secure: secureCookies,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: 86400, // 24 hours
|
||||
})
|
||||
}
|
||||
|
||||
// --- Validate on state-changing methods ---
|
||||
if r.Method == http.MethodPost || r.Method == http.MethodPut ||
|
||||
r.Method == http.MethodDelete || r.Method == http.MethodPatch {
|
||||
|
||||
// Accept the token from either the form body or a request header
|
||||
submitted := r.FormValue("_csrf")
|
||||
if submitted == "" {
|
||||
submitted = r.Header.Get("X-CSRF-Token")
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(submitted), []byte(token)) != 1 {
|
||||
http.Error(w, "Forbidden – invalid or missing CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HeadersMiddleware returns middleware that sets security-relevant HTTP
|
||||
// response headers on every response.
|
||||
//
|
||||
// These headers protect against clickjacking, MIME-sniffing, information
|
||||
// leakage and other common web attacks. They are set regardless of
|
||||
// whether TLS is in use.
|
||||
func HeadersMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
|
||||
// Prevent the page from being embedded in an iframe (clickjacking)
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
|
||||
// Stop browsers from MIME-sniffing the content type
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// Control what information is sent in the Referer header
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Restrict browser features that are not needed
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
|
||||
// Content Security Policy – fairly strict but allows inline
|
||||
// styles/scripts that Tabler and the app currently use.
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; "+
|
||||
"font-src 'self' data:; "+
|
||||
"connect-src 'self'; "+
|
||||
"frame-ancestors 'none'; "+
|
||||
"form-action 'self'; "+
|
||||
"base-uri 'self'")
|
||||
|
||||
// Opt out of Google FLoC / Topics
|
||||
h.Set("X-Permitted-Cross-Domain-Policies", "none")
|
||||
|
||||
// Cache control for authenticated pages – prevent caching of
|
||||
// sensitive data. Static assets set their own cache headers.
|
||||
if r.URL.Path != "" && !isStaticAsset(r.URL.Path) {
|
||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
|
||||
h.Set("Pragma", "no-cache")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isStaticAsset returns true for paths that serve static files.
|
||||
func isStaticAsset(path string) bool {
|
||||
return len(path) > 8 && (path[:8] == "/static/" || path[:8] == "/avatar/")
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// trustedNets holds the parsed trusted proxy CIDR ranges.
|
||||
// Set once at startup via Init().
|
||||
var trustedNets []*net.IPNet
|
||||
|
||||
// Init parses the trusted proxy configuration and prepares the package
|
||||
// for use. Must be called once at startup before any middleware runs.
|
||||
//
|
||||
// trustedProxies is a comma-separated list of CIDRs or IPs, e.g.
|
||||
// "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16" or "10.0.1.92".
|
||||
// An empty string means no trusted proxies are configured (legacy mode:
|
||||
// proxy headers are trusted unconditionally for backward compatibility).
|
||||
func Init(trustedProxies string) {
|
||||
trustedNets = nil
|
||||
if trustedProxies == "" {
|
||||
return
|
||||
}
|
||||
for _, entry := range strings.Split(trustedProxies, ",") {
|
||||
cidr := strings.TrimSpace(entry)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
// Plain IP → convert to single-host CIDR
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if strings.Contains(cidr, ":") {
|
||||
cidr += "/128"
|
||||
} else {
|
||||
cidr += "/32"
|
||||
}
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue // skip invalid entries silently
|
||||
}
|
||||
trustedNets = append(trustedNets, ipNet)
|
||||
}
|
||||
}
|
||||
|
||||
// ClientIP extracts the real client IP address from the request.
|
||||
//
|
||||
// When trusted proxies are configured, X-Forwarded-For is walked from
|
||||
// right to left and the first non-trusted IP is returned (secure approach).
|
||||
// When no trusted proxies are configured, the legacy behavior is used
|
||||
// (leftmost X-Forwarded-For entry, i.e. the value the first proxy saw).
|
||||
func ClientIP(r *http.Request) string {
|
||||
remoteIP := extractRemoteIP(r.RemoteAddr)
|
||||
|
||||
if len(trustedNets) > 0 {
|
||||
// Strict mode: only honour proxy headers when the direct peer is trusted
|
||||
if !isTrustedIP(remoteIP) {
|
||||
return remoteIP
|
||||
}
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
// Walk right-to-left: rightmost untrusted IP is the real client
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(parts[i])
|
||||
if ip != "" && !isTrustedIP(ip) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
if real := r.Header.Get("X-Real-Ip"); real != "" {
|
||||
return real
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
// Legacy mode (no trusted proxies configured): trust headers as before
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.SplitN(fwd, ",", 2)
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if real := r.Header.Get("X-Real-Ip"); real != "" {
|
||||
return real
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
// extractRemoteIP strips the port from r.RemoteAddr.
|
||||
func extractRemoteIP(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// isTrustedIP checks if an IP is within any of the configured trusted networks.
|
||||
func isTrustedIP(ip string) bool {
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range trustedNets {
|
||||
if n.Contains(parsed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter implements a fixed-window IP-based rate limiter.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
clients map[string]*window
|
||||
limit int
|
||||
period time.Duration
|
||||
}
|
||||
|
||||
type window struct {
|
||||
count int
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int, period time.Duration) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
clients: make(map[string]*window),
|
||||
limit: limit,
|
||||
period: period,
|
||||
}
|
||||
// Background goroutine to evict expired entries (prevent memory leak)
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
rl.mu.Lock()
|
||||
for ip, w := range rl.clients {
|
||||
if now.After(w.resetAt) {
|
||||
delete(rl.clients, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
w, ok := rl.clients[ip]
|
||||
if !ok || now.After(w.resetAt) {
|
||||
rl.clients[ip] = &window{count: 1, resetAt: now.Add(rl.period)}
|
||||
return true
|
||||
}
|
||||
w.count++
|
||||
return w.count <= rl.limit
|
||||
}
|
||||
|
||||
// RateLimitMiddleware returns middleware that rate-limits requests to the
|
||||
// login endpoint by client IP address.
|
||||
//
|
||||
// loginLimit is the maximum number of login attempts per IP per minute.
|
||||
// A value of 0 disables rate limiting entirely.
|
||||
func RateLimitMiddleware(loginLimit int) func(http.Handler) http.Handler {
|
||||
if loginLimit <= 0 {
|
||||
// Disabled – pass through
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
|
||||
loginRL := newRateLimiter(loginLimit, 1*time.Minute)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only rate-limit POST to login endpoints
|
||||
if r.Method == http.MethodPost && isLoginPath(r.URL.Path) {
|
||||
ip := ClientIP(r)
|
||||
if !loginRL.allow(ip) {
|
||||
http.Error(w, "Too Many Requests – please try again later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isLoginPath returns true for login-related POST paths.
|
||||
func isLoginPath(path string) bool {
|
||||
return path == "/login" || strings.HasPrefix(path, "/login/")
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SizeLimitMiddleware returns middleware that limits the size of incoming
|
||||
// request bodies to maxBytes. This prevents denial-of-service attacks via
|
||||
// excessively large uploads. A value of 0 disables the limit.
|
||||
//
|
||||
// The limit is enforced with http.MaxBytesReader which causes the server
|
||||
// to return 413 Request Entity Too Large if the body exceeds the limit.
|
||||
func SizeLimitMiddleware(maxBytes int64) func(http.Handler) http.Handler {
|
||||
if maxBytes <= 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil && r.ContentLength != 0 {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package servers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.techniverse.net/scriptos/keywarden/internal/database"
|
||||
"git.techniverse.net/scriptos/keywarden/internal/models"
|
||||
)
|
||||
|
||||
// Service handles server management
|
||||
type Service struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewService creates a new server service
|
||||
func NewService(db *database.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Create adds a new server
|
||||
func (s *Service) Create(userID int64, name, hostname string, port int, username, description string) (*models.Server, error) {
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO servers (user_id, name, hostname, port, username, description) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
userID, name, hostname, port, username, description,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.Server{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Hostname: hostname,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Description: description,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByUser returns all servers for a user
|
||||
func (s *Service) GetByUser(userID int64) ([]models.Server, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
|
||||
FROM servers WHERE user_id = ? ORDER BY name ASC`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query servers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []models.Server
|
||||
for rows.Next() {
|
||||
var srv models.Server
|
||||
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// GetByID returns a specific server
|
||||
func (s *Service) GetByID(serverID, userID int64) (*models.Server, error) {
|
||||
srv := &models.Server{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
|
||||
FROM servers WHERE id = ? AND user_id = ?`, serverID, userID,
|
||||
).Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server not found: %w", err)
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Update modifies a server
|
||||
func (s *Service) Update(serverID, userID int64, name, hostname string, port int, username, description string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE servers SET name=?, hostname=?, port=?, username=?, description=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=? AND user_id=?`,
|
||||
name, hostname, port, username, description, serverID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update server: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a server
|
||||
func (s *Service) Delete(serverID, userID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM servers WHERE id = ? AND user_id = ?`, serverID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete server: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Server Groups ---
|
||||
|
||||
// CreateGroup creates a new server group
|
||||
func (s *Service) CreateGroup(userID int64, name, description string) (*models.ServerGroup, error) {
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO server_groups (user_id, name, description) VALUES (?, ?, ?)`,
|
||||
userID, name, description,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create server group: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.ServerGroup{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGroupsByUser returns all server groups for a user with server counts
|
||||
func (s *Service) GetGroupsByUser(userID int64) ([]models.ServerGroupWithCount, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sg.id, sg.user_id, sg.name, sg.description, sg.created_at, sg.updated_at,
|
||||
COUNT(sgm.server_id) as server_count
|
||||
FROM server_groups sg
|
||||
LEFT JOIN server_group_members sgm ON sg.id = sgm.group_id
|
||||
WHERE sg.user_id = ?
|
||||
GROUP BY sg.id
|
||||
ORDER BY sg.name ASC`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query server groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []models.ServerGroupWithCount
|
||||
for rows.Next() {
|
||||
var g models.ServerGroupWithCount
|
||||
if err := rows.Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt, &g.ServerCount); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server group: %w", err)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetGroupByID returns a specific server group
|
||||
func (s *Service) GetGroupByID(groupID, userID int64) (*models.ServerGroup, error) {
|
||||
g := &models.ServerGroup{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, description, created_at, updated_at
|
||||
FROM server_groups WHERE id = ? AND user_id = ?`, groupID, userID,
|
||||
).Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server group not found: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// UpdateGroup modifies a server group
|
||||
func (s *Service) UpdateGroup(groupID, userID int64, name, description string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE server_groups SET name=?, description=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=? AND user_id=?`,
|
||||
name, description, groupID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update server group: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server group not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroup removes a server group
|
||||
func (s *Service) DeleteGroup(groupID, userID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM server_groups WHERE id = ? AND user_id = ?`, groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete server group: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server group not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddServerToGroup adds a server to a group
|
||||
func (s *Service) AddServerToGroup(groupID, serverID, userID int64) error {
|
||||
// Verify group belongs to user
|
||||
_, err := s.GetGroupByID(groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("group not found: %w", err)
|
||||
}
|
||||
// Verify server belongs to user
|
||||
_, err = s.GetByID(serverID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
|
||||
groupID, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add server to group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveServerFromGroup removes a server from a group
|
||||
func (s *Service) RemoveServerFromGroup(groupID, serverID, userID int64) error {
|
||||
// Verify group belongs to user
|
||||
_, err := s.GetGroupByID(groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("group not found: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
`DELETE FROM server_group_members WHERE group_id = ? AND server_id = ?`,
|
||||
groupID, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove server from group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddServerToGroupGlobal adds a server to a group without user_id check
|
||||
func (s *Service) AddServerToGroupGlobal(groupID, serverID int64) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
|
||||
groupID, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add server to group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveServerFromGroupGlobal removes a server from a group without user_id check
|
||||
func (s *Service) RemoveServerFromGroupGlobal(groupID, serverID int64) error {
|
||||
_, err := s.db.Exec(
|
||||
`DELETE FROM server_group_members WHERE group_id = ? AND server_id = ?`,
|
||||
groupID, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove server from group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupMembers returns all servers in a group
|
||||
func (s *Service) GetGroupMembers(groupID, userID int64) ([]models.Server, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
|
||||
FROM servers s
|
||||
JOIN server_group_members sgm ON s.id = sgm.server_id
|
||||
WHERE sgm.group_id = ? AND s.user_id = ?
|
||||
ORDER BY s.name ASC`, groupID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query group members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []models.Server
|
||||
for rows.Next() {
|
||||
var srv models.Server
|
||||
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// GetGroupIDsForServer returns all group IDs that a server belongs to
|
||||
func (s *Service) GetGroupIDsForServer(serverID, userID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sgm.group_id FROM server_group_members sgm
|
||||
JOIN server_groups sg ON sg.id = sgm.group_id
|
||||
WHERE sgm.server_id = ? AND sg.user_id = ?`, serverID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query server groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// SetServerGroups replaces all group memberships for a server
|
||||
func (s *Service) SetServerGroups(serverID, userID int64, groupIDs []int64) error {
|
||||
// Verify server belongs to user
|
||||
_, err := s.GetByID(serverID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server not found: %w", err)
|
||||
}
|
||||
|
||||
// Remove all current group memberships for this server
|
||||
_, err = s.db.Exec(`DELETE FROM server_group_members WHERE server_id = ?`, serverID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear group memberships: %w", err)
|
||||
}
|
||||
|
||||
// Add new memberships
|
||||
for _, gid := range groupIDs {
|
||||
// Verify group belongs to user
|
||||
_, err := s.GetGroupByID(gid, userID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
|
||||
gid, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add server to group: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupMemberIDs returns server IDs in a group
|
||||
func (s *Service) GetGroupMemberIDs(groupID, userID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sgm.server_id FROM server_group_members sgm
|
||||
JOIN servers s ON s.id = sgm.server_id
|
||||
WHERE sgm.group_id = ? AND s.user_id = ?`, groupID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query group member IDs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// --- Global queries (admin/owner) ---
|
||||
|
||||
// GetAllServers returns all servers regardless of owner
|
||||
func (s *Service) GetAllServers() ([]models.Server, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
|
||||
FROM servers ORDER BY name ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query all servers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []models.Server
|
||||
for rows.Next() {
|
||||
var srv models.Server
|
||||
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// GetAllGroups returns all server groups regardless of owner
|
||||
func (s *Service) GetAllGroups() ([]models.ServerGroupWithCount, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sg.id, sg.user_id, sg.name, sg.description, sg.created_at, sg.updated_at,
|
||||
COUNT(sgm.server_id) as server_count
|
||||
FROM server_groups sg
|
||||
LEFT JOIN server_group_members sgm ON sg.id = sgm.group_id
|
||||
GROUP BY sg.id
|
||||
ORDER BY sg.name ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query all server groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []models.ServerGroupWithCount
|
||||
for rows.Next() {
|
||||
var g models.ServerGroupWithCount
|
||||
if err := rows.Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt, &g.ServerCount); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server group: %w", err)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// --- Global access functions (admin/owner) ---
|
||||
|
||||
// GetByIDGlobal returns a server without user_id check (admin/owner access)
|
||||
func (s *Service) GetByIDGlobal(serverID int64) (*models.Server, error) {
|
||||
srv := &models.Server{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, hostname, port, username, description, created_at, updated_at
|
||||
FROM servers WHERE id = ?`, serverID,
|
||||
).Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server not found: %w", err)
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// UpdateGlobal modifies a server without user_id check (admin/owner access)
|
||||
func (s *Service) UpdateGlobal(serverID int64, name, hostname string, port int, username, description string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE servers SET name=?, hostname=?, port=?, username=?, description=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?`,
|
||||
name, hostname, port, username, description, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update server: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGlobal removes a server without user_id check (admin/owner access)
|
||||
func (s *Service) DeleteGlobal(serverID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, serverID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete server: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupByIDGlobal returns a server group without user_id check
|
||||
func (s *Service) GetGroupByIDGlobal(groupID int64) (*models.ServerGroup, error) {
|
||||
g := &models.ServerGroup{}
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, name, description, created_at, updated_at
|
||||
FROM server_groups WHERE id = ?`, groupID,
|
||||
).Scan(&g.ID, &g.UserID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server group not found: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// UpdateGroupGlobal modifies a server group without user_id check
|
||||
func (s *Service) UpdateGroupGlobal(groupID int64, name, description string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE server_groups SET name=?, description=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
name, description, groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update server group: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server group not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroupGlobal removes a server group without user_id check
|
||||
func (s *Service) DeleteGroupGlobal(groupID int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM server_groups WHERE id = ?`, groupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete server group: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("server group not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupMembersGlobal returns all servers in a group without user_id check
|
||||
func (s *Service) GetGroupMembersGlobal(groupID int64) ([]models.Server, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
|
||||
FROM servers s
|
||||
JOIN server_group_members sgm ON s.id = sgm.server_id
|
||||
WHERE sgm.group_id = ?
|
||||
ORDER BY s.name ASC`, groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query group members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []models.Server
|
||||
for rows.Next() {
|
||||
var srv models.Server
|
||||
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// GetGroupIDsForServerGlobal returns all group IDs that a server belongs to (without user_id check)
|
||||
func (s *Service) GetGroupIDsForServerGlobal(serverID int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT sgm.group_id FROM server_group_members sgm WHERE sgm.server_id = ?`, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query server groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// SetServerGroupsGlobal replaces all group memberships for a server (without user_id check)
|
||||
func (s *Service) SetServerGroupsGlobal(serverID int64, groupIDs []int64) error {
|
||||
_, err := s.db.Exec(`DELETE FROM server_group_members WHERE server_id = ?`, serverID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear group memberships: %w", err)
|
||||
}
|
||||
|
||||
for _, gid := range groupIDs {
|
||||
_, err = s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_group_members (group_id, server_id) VALUES (?, ?)`,
|
||||
gid, serverID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add server to group: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServersByAssignedUser returns hosts that a user has access to via assignments
|
||||
func (s *Service) GetServersByAssignedUser(userID int64) ([]models.Server, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT DISTINCT s.id, s.user_id, s.name, s.hostname, s.port, s.username, s.description, s.created_at, s.updated_at
|
||||
FROM servers s
|
||||
WHERE s.id IN (
|
||||
SELECT a.server_id FROM access_assignments a WHERE a.user_id = ? AND a.server_id > 0
|
||||
UNION
|
||||
SELECT sgm.server_id FROM access_assignments a
|
||||
JOIN server_group_members sgm ON a.group_id = sgm.group_id
|
||||
WHERE a.user_id = ? AND a.group_id > 0
|
||||
)
|
||||
ORDER BY s.name ASC`, userID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query assigned servers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var servers []models.Server
|
||||
for rows.Next() {
|
||||
var srv models.Server
|
||||
if err := rows.Scan(&srv.ID, &srv.UserID, &srv.Name, &srv.Hostname, &srv.Port, &srv.Username, &srv.Description, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan server: %w", err)
|
||||
}
|
||||
servers = append(servers, srv)
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// UpdateAssignmentStatus updates the sync status of an assignment
|
||||
func (s *Service) UpdateAssignmentStatus(id int64, status, message string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE access_assignments SET status=?, last_sync_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
status, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Access Assignments ---
|
||||
|
||||
// CreateAssignment creates a new access assignment
|
||||
func (s *Service) CreateAssignment(userID, sshKeyID, serverID, groupID int64, systemUser, desiredState string, sudo, createUser bool) (*models.AccessAssignment, error) {
|
||||
sudoInt := 0
|
||||
if sudo {
|
||||
sudoInt = 1
|
||||
}
|
||||
createUserInt := 0
|
||||
if createUser {
|
||||
createUserInt = 1
|
||||
}
|
||||
if desiredState == "" {
|
||||
desiredState = "present"
|
||||
}
|
||||
result, err := s.db.Exec(
|
||||
`INSERT INTO access_assignments (user_id, ssh_key_id, server_id, group_id, system_user, desired_state, sudo, create_user)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID, sshKeyID, serverID, groupID, systemUser, desiredState, sudoInt, createUserInt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create assignment: %w", err)
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
return &models.AccessAssignment{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
SSHKeyID: sshKeyID,
|
||||
ServerID: serverID,
|
||||
GroupID: groupID,
|
||||
SystemUser: systemUser,
|
||||
DesiredState: desiredState,
|
||||
Sudo: sudo,
|
||||
CreateUser: createUser,
|
||||
Status: "pending",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAllAssignments returns all access assignments with resolved display names
|
||||
func (s *Service) GetAllAssignments() ([]models.AccessAssignmentDisplay, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT a.id, a.user_id, a.ssh_key_id, a.server_id, a.group_id,
|
||||
a.system_user, a.desired_state, a.sudo, a.create_user,
|
||||
a.status, a.initial_password, a.last_sync_at, a.created_at, a.updated_at,
|
||||
u.username,
|
||||
COALESCE(k.name, '(deleted)'),
|
||||
CASE
|
||||
WHEN a.server_id > 0 THEN COALESCE(s.name, '(deleted)')
|
||||
WHEN a.group_id > 0 THEN COALESCE(sg.name, '(deleted)')
|
||||
ELSE '(none)'
|
||||
END,
|
||||
CASE
|
||||
WHEN a.server_id > 0 THEN 'host'
|
||||
WHEN a.group_id > 0 THEN 'group'
|
||||
ELSE 'none'
|
||||
END
|
||||
FROM access_assignments a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
LEFT JOIN ssh_keys k ON a.ssh_key_id = k.id
|
||||
LEFT JOIN servers s ON a.server_id = s.id AND a.server_id > 0
|
||||
LEFT JOIN server_groups sg ON a.group_id = sg.id AND a.group_id > 0
|
||||
ORDER BY a.created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query assignments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var assignments []models.AccessAssignmentDisplay
|
||||
for rows.Next() {
|
||||
var a models.AccessAssignmentDisplay
|
||||
var sudo, createUser int
|
||||
if err := rows.Scan(&a.ID, &a.UserID, &a.SSHKeyID, &a.ServerID, &a.GroupID,
|
||||
&a.SystemUser, &a.DesiredState, &sudo, &createUser,
|
||||
&a.Status, &a.InitialPassword, &a.LastSyncAt, &a.CreatedAt, &a.UpdatedAt,
|
||||
&a.Username, &a.KeyName, &a.TargetName, &a.TargetType); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan assignment: %w", err)
|
||||
}
|
||||
a.Sudo = sudo == 1
|
||||
a.CreateUser = createUser == 1
|
||||
assignments = append(assignments, a)
|
||||
}
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// GetAssignmentsByUser returns access assignments for a specific user
|
||||
func (s *Service) GetAssignmentsByUser(userID int64) ([]models.AccessAssignmentDisplay, error) {
|
||||
all, err := s.GetAllAssignments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []models.AccessAssignmentDisplay
|
||||
for _, a := range all {
|
||||
if a.UserID == userID {
|
||||
filtered = append(filtered, a)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetAssignmentByID returns a single access assignment
|
||||
func (s *Service) GetAssignmentByID(id int64) (*models.AccessAssignment, error) {
|
||||
a := &models.AccessAssignment{}
|
||||
var sudo, createUser int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, ssh_key_id, server_id, group_id, system_user, desired_state,
|
||||
sudo, create_user, initial_password, status, last_sync_at, created_at, updated_at
|
||||
FROM access_assignments WHERE id = ?`, id,
|
||||
).Scan(&a.ID, &a.UserID, &a.SSHKeyID, &a.ServerID, &a.GroupID, &a.SystemUser, &a.DesiredState,
|
||||
&sudo, &createUser, &a.InitialPassword, &a.Status, &a.LastSyncAt, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assignment not found: %w", err)
|
||||
}
|
||||
a.Sudo = sudo == 1
|
||||
a.CreateUser = createUser == 1
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAssignment updates an existing access assignment
|
||||
func (s *Service) UpdateAssignment(id, userID, sshKeyID, serverID, groupID int64, systemUser, desiredState string, sudo, createUser bool) error {
|
||||
sudoInt := 0
|
||||
if sudo {
|
||||
sudoInt = 1
|
||||
}
|
||||
createUserInt := 0
|
||||
if createUser {
|
||||
createUserInt = 1
|
||||
}
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE access_assignments SET user_id=?, ssh_key_id=?, server_id=?, group_id=?,
|
||||
system_user=?, desired_state=?, sudo=?, create_user=?, status='pending', updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?`,
|
||||
userID, sshKeyID, serverID, groupID, systemUser, desiredState, sudoInt, createUserInt, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update assignment: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("assignment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAssignment removes an access assignment
|
||||
func (s *Service) DeleteAssignment(id int64) error {
|
||||
result, err := s.db.Exec(`DELETE FROM access_assignments WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete assignment: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("assignment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAssignmentInitialPassword stores the encrypted initial password for an assignment
|
||||
func (s *Service) UpdateAssignmentInitialPassword(id int64, encryptedPassword string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE access_assignments SET initial_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
encryptedPassword, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudflare/circl/sign/ed448"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// GenerateRSAKey generates an RSA key pair with the given bit size (2048 or 4096)
|
||||
func GenerateRSAKey(bits int, comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
|
||||
if bits != 2048 && bits != 4096 {
|
||||
return nil, nil, "", fmt.Errorf("unsupported RSA key size: %d (use 2048 or 4096)", bits)
|
||||
}
|
||||
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PEM
|
||||
privPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: marshalRSAPrivateKey(privKey),
|
||||
})
|
||||
|
||||
// Generate SSH public key
|
||||
pub, err := ssh.NewPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
|
||||
}
|
||||
|
||||
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
|
||||
fp := fingerprintSHA256(pub)
|
||||
|
||||
return privPEM, pubBytes, fp, nil
|
||||
}
|
||||
|
||||
// GenerateEd25519Key generates an Ed25519 key pair
|
||||
func GenerateEd25519Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
|
||||
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate Ed25519 key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PEM using OpenSSH format
|
||||
privPEM, err := ssh.MarshalPrivateKey(privKey, comment)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to marshal Ed25519 private key: %w", err)
|
||||
}
|
||||
|
||||
privPEMBytes := pem.EncodeToMemory(privPEM)
|
||||
|
||||
// Generate SSH public key
|
||||
pub, err := ssh.NewPublicKey(pubKey)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to create SSH public key: %w", err)
|
||||
}
|
||||
|
||||
pubBytes := appendComment(ssh.MarshalAuthorizedKey(pub), comment)
|
||||
fp := fingerprintSHA256(pub)
|
||||
|
||||
return privPEMBytes, pubBytes, fp, nil
|
||||
}
|
||||
|
||||
// ed448PublicKey wraps an Ed448 public key to implement ssh.PublicKey
|
||||
type ed448PublicKey []byte
|
||||
|
||||
func (k ed448PublicKey) Type() string {
|
||||
return "ssh-ed448"
|
||||
}
|
||||
|
||||
func (k ed448PublicKey) Marshal() []byte {
|
||||
w := struct {
|
||||
KeyType string
|
||||
Key []byte
|
||||
}{
|
||||
KeyType: k.Type(),
|
||||
Key: []byte(k),
|
||||
}
|
||||
return ssh.Marshal(&w)
|
||||
}
|
||||
|
||||
func (k ed448PublicKey) Verify(data []byte, sig *ssh.Signature) error {
|
||||
if sig.Format != k.Type() {
|
||||
return fmt.Errorf("ssh: signature type %s for key type %s", sig.Format, k.Type())
|
||||
}
|
||||
if !ed448.Verify(ed448.PublicKey(k), data, sig.Blob, "") {
|
||||
return fmt.Errorf("ssh: ed448 signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateEd448Key generates an Ed448 key pair
|
||||
func GenerateEd448Key(comment string) (privateKeyPEM []byte, publicKey []byte, fingerprint string, err error) {
|
||||
pubKey, privKey, err := ed448.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate Ed448 key: %w", err)
|
||||
}
|
||||
|
||||
sshPubKey := ed448PublicKey(pubKey)
|
||||
|
||||
privPEM, err := marshalOpenSSHEd448(privKey, pubKey, comment)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to marshal Ed448 private key: %w", err)
|
||||
}
|
||||
|
||||
pubBytes := appendComment(ssh.MarshalAuthorizedKey(sshPubKey), comment)
|
||||
fp := fingerprintSHA256(sshPubKey)
|
||||
|
||||
return privPEM, pubBytes, fp, nil
|
||||
}
|
||||
|
||||
// marshalOpenSSHEd448 encodes an Ed448 key pair in openssh-key-v1 private key format
|
||||
func marshalOpenSSHEd448(privKey ed448.PrivateKey, pubKey ed448.PublicKey, comment string) ([]byte, error) {
|
||||
// Public key wire format
|
||||
pubWire := struct {
|
||||
KeyType string
|
||||
PubKey []byte
|
||||
}{
|
||||
KeyType: "ssh-ed448",
|
||||
PubKey: []byte(pubKey),
|
||||
}
|
||||
pubWireBytes := ssh.Marshal(&pubWire)
|
||||
|
||||
// Random check value for integrity verification
|
||||
var checkBuf [4]byte
|
||||
if _, err := rand.Read(checkBuf[:]); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random check: %w", err)
|
||||
}
|
||||
check := binary.BigEndian.Uint32(checkBuf[:])
|
||||
|
||||
// Build the private key blob (seed + public key, following OpenSSH convention)
|
||||
var keyBlob []byte
|
||||
if len(privKey) <= ed448.SeedSize {
|
||||
keyBlob = make([]byte, 0, ed448.SeedSize+len(pubKey))
|
||||
keyBlob = append(keyBlob, privKey...)
|
||||
keyBlob = append(keyBlob, pubKey...)
|
||||
} else {
|
||||
keyBlob = []byte(privKey)
|
||||
}
|
||||
|
||||
// Private key section (unencrypted)
|
||||
privSection := struct {
|
||||
Check1 uint32
|
||||
Check2 uint32
|
||||
KeyType string
|
||||
PubKey []byte
|
||||
PrivKey []byte
|
||||
Comment string
|
||||
}{
|
||||
Check1: check,
|
||||
Check2: check,
|
||||
KeyType: "ssh-ed448",
|
||||
PubKey: []byte(pubKey),
|
||||
PrivKey: keyBlob,
|
||||
Comment: comment,
|
||||
}
|
||||
privSectionBytes := ssh.Marshal(&privSection)
|
||||
|
||||
// Pad to block size of 8
|
||||
padLen := (8 - len(privSectionBytes)%8) % 8
|
||||
for i := 0; i < padLen; i++ {
|
||||
privSectionBytes = append(privSectionBytes, byte(i+1))
|
||||
}
|
||||
|
||||
// Assemble full openssh-key-v1 format
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("openssh-key-v1\x00")
|
||||
|
||||
outer := struct {
|
||||
CipherName string
|
||||
KdfName string
|
||||
KdfOpts string
|
||||
NumKeys uint32
|
||||
PubKey []byte
|
||||
PrivKey []byte
|
||||
}{
|
||||
CipherName: "none",
|
||||
KdfName: "none",
|
||||
KdfOpts: "",
|
||||
NumKeys: 1,
|
||||
PubKey: pubWireBytes,
|
||||
PrivKey: privSectionBytes,
|
||||
}
|
||||
buf.Write(ssh.Marshal(&outer))
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{
|
||||
Type: "OPENSSH PRIVATE KEY",
|
||||
Bytes: buf.Bytes(),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ParsePublicKey parses an SSH public key and returns its fingerprint
|
||||
func ParsePublicKey(pubKeyBytes []byte) (fingerprint string, keyType string, err error) {
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
return fingerprintSHA256(pub), pub.Type(), nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey parses a PEM-encoded private key and extracts the public key
|
||||
func ParsePrivateKey(privKeyPEM []byte) (publicKey []byte, fingerprint string, keyType string, err error) {
|
||||
signer, err := ssh.ParsePrivateKey(privKeyPEM)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
pub := signer.PublicKey()
|
||||
pubBytes := ssh.MarshalAuthorizedKey(pub)
|
||||
fp := fingerprintSHA256(pub)
|
||||
|
||||
return pubBytes, fp, pub.Type(), nil
|
||||
}
|
||||
|
||||
// appendComment appends a comment to an SSH authorized key line
|
||||
func appendComment(pubBytes []byte, comment string) []byte {
|
||||
if comment == "" {
|
||||
return pubBytes
|
||||
}
|
||||
// MarshalAuthorizedKey returns "type base64\n", insert comment before newline
|
||||
line := strings.TrimRight(string(pubBytes), "\n")
|
||||
return []byte(line + " " + comment + "\n")
|
||||
}
|
||||
|
||||
// fingerprintSHA256 returns the SHA256 fingerprint of an SSH public key
|
||||
func fingerprintSHA256(pub ssh.PublicKey) string {
|
||||
hash := sha256.Sum256(pub.Marshal())
|
||||
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// marshalRSAPrivateKey marshals an RSA private key to PKCS#1 DER bytes
|
||||
func marshalRSAPrivateKey(key *rsa.PrivateKey) []byte {
|
||||
return x509.MarshalPKCS1PrivateKey(key)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Keywarden - Centralized SSH Key Management and Deployment
|
||||
// Copyright (C) 2026 Patrick Asmus (scriptos)
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package sshutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateEd25519Key(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateEd25519Key("test@keywarden")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "ssh-ed25519") {
|
||||
t.Fatal("Public key should contain ssh-ed25519")
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "test@keywarden") {
|
||||
t.Fatal("Public key should contain the comment")
|
||||
}
|
||||
if !strings.Contains(string(privPEM), "PRIVATE KEY") {
|
||||
t.Fatal("Private key PEM should contain PRIVATE KEY header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRSAKey2048(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateRSAKey(2048, "rsa-test")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRSAKey(2048) failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "ssh-rsa") {
|
||||
t.Fatal("Public key should contain ssh-rsa")
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "rsa-test") {
|
||||
t.Fatal("Public key should contain the comment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRSAKey4096(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateRSAKey(4096, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRSAKey(4096) failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRSAKeyInvalidBits(t *testing.T) {
|
||||
_, _, _, err := GenerateRSAKey(1024, "")
|
||||
if err == nil {
|
||||
t.Fatal("GenerateRSAKey(1024) should fail for unsupported key size")
|
||||
}
|
||||
|
||||
_, _, _, err = GenerateRSAKey(3072, "")
|
||||
if err == nil {
|
||||
t.Fatal("GenerateRSAKey(3072) should fail for unsupported key size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyEd25519(t *testing.T) {
|
||||
privPEM, expectedPub, _, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key failed: %v", err)
|
||||
}
|
||||
|
||||
pubKey, fingerprint, keyType, err := ParsePrivateKey(privPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||
}
|
||||
|
||||
if keyType != "ssh-ed25519" {
|
||||
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if strings.TrimSpace(string(pubKey)) != strings.TrimSpace(string(expectedPub)) {
|
||||
t.Fatal("Parsed public key does not match generated public key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyRSA(t *testing.T) {
|
||||
privPEM, _, _, err := GenerateRSAKey(2048, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRSAKey failed: %v", err)
|
||||
}
|
||||
|
||||
_, fingerprint, keyType, err := ParsePrivateKey(privPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey failed: %v", err)
|
||||
}
|
||||
|
||||
if keyType != "ssh-rsa" {
|
||||
t.Fatalf("Expected key type ssh-rsa, got %q", keyType)
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicKey(t *testing.T) {
|
||||
_, pubKey, expectedFP, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key failed: %v", err)
|
||||
}
|
||||
|
||||
fingerprint, keyType, err := ParsePublicKey(pubKey)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePublicKey failed: %v", err)
|
||||
}
|
||||
|
||||
if keyType != "ssh-ed25519" {
|
||||
t.Fatalf("Expected key type ssh-ed25519, got %q", keyType)
|
||||
}
|
||||
if fingerprint != expectedFP {
|
||||
t.Fatalf("Fingerprint mismatch: got %q, want %q", fingerprint, expectedFP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd25519KeyUniqueness(t *testing.T) {
|
||||
_, pub1, fp1, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key 1 failed: %v", err)
|
||||
}
|
||||
|
||||
_, pub2, fp2, err := GenerateEd25519Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd25519Key 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if string(pub1) == string(pub2) {
|
||||
t.Fatal("Two generated keys should have different public keys")
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Fatal("Two generated keys should have different fingerprints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd448Key(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateEd448Key("test@keywarden")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "ssh-ed448") {
|
||||
t.Fatal("Public key should contain ssh-ed448")
|
||||
}
|
||||
if !strings.Contains(string(pubKey), "test@keywarden") {
|
||||
t.Fatal("Public key should contain the comment")
|
||||
}
|
||||
if !strings.Contains(string(privPEM), "OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("Private key PEM should contain OPENSSH PRIVATE KEY header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd448KeyUniqueness(t *testing.T) {
|
||||
_, pub1, fp1, err := GenerateEd448Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key 1 failed: %v", err)
|
||||
}
|
||||
|
||||
_, pub2, fp2, err := GenerateEd448Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key 2 failed: %v", err)
|
||||
}
|
||||
|
||||
if string(pub1) == string(pub2) {
|
||||
t.Fatal("Two generated Ed448 keys should have different public keys")
|
||||
}
|
||||
if fp1 == fp2 {
|
||||
t.Fatal("Two generated Ed448 keys should have different fingerprints")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEd448KeyNoComment(t *testing.T) {
|
||||
privPEM, pubKey, fingerprint, err := GenerateEd448Key("")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEd448Key failed: %v", err)
|
||||
}
|
||||
|
||||
if len(privPEM) == 0 {
|
||||
t.Fatal("Private key PEM is empty")
|
||||
}
|
||||
if len(pubKey) == 0 {
|
||||
t.Fatal("Public key is empty")
|
||||
}
|
||||
if !strings.HasPrefix(fingerprint, "SHA256:") {
|
||||
t.Fatalf("Fingerprint should start with SHA256:, got %q", fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyInvalid(t *testing.T) {
|
||||
_, _, _, err := ParsePrivateKey([]byte("not a valid PEM"))
|
||||
if err == nil {
|
||||
t.Fatal("ParsePrivateKey should fail for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicKeyInvalid(t *testing.T) {
|
||||
_, _, err := ParsePublicKey([]byte("not a valid public key"))
|
||||
if err == nil {
|
||||
t.Fatal("ParsePublicKey should fail for invalid key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user