765 lines
27 KiB
Go
765 lines
27 KiB
Go
// 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(hostname, fmt.Sprintf("%d", 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 := net.JoinHostPort(hostname, fmt.Sprintf("%d", 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
|
|
}
|
|
|
|
// ReadAuthorizedKeys reads the current authorized_keys for a system user on a remote server.
|
|
// Returns the list of key lines (non-empty, non-comment lines).
|
|
func (s *Service) ReadAuthorizedKeys(server *models.Server, authPrivateKey []byte, systemUser string) ([]string, error) {
|
|
signer, err := ssh.ParsePrivateKey(authPrivateKey)
|
|
if err != nil {
|
|
return nil, 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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
|
|
client, err := ssh.Dial("tcp", addr, config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to server: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
|
if systemUser == "root" {
|
|
homeDir = "/root"
|
|
}
|
|
|
|
cmd := fmt.Sprintf(`cat %s/.ssh/authorized_keys 2>/dev/null || echo ""`, homeDir)
|
|
output, err := session.Output(cmd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read authorized_keys: %w", err)
|
|
}
|
|
|
|
var keys []string
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
keys = append(keys, line)
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
// WriteAuthorizedKeys replaces the entire authorized_keys file for a system user on a remote server
|
|
// with the provided set of keys. This is the enforcement function.
|
|
func (s *Service) WriteAuthorizedKeys(server *models.Server, authPrivateKey []byte, systemUser string, authorizedKeys []string) error {
|
|
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 := net.JoinHostPort(server.Hostname, fmt.Sprintf("%d", server.Port))
|
|
client, err := ssh.Dial("tcp", addr, config)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to connect to server: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
homeDir := fmt.Sprintf("/home/%s", systemUser)
|
|
if systemUser == "root" {
|
|
homeDir = "/root"
|
|
}
|
|
|
|
// Build the authorized_keys content
|
|
content := strings.Join(authorizedKeys, "\n")
|
|
if !strings.HasSuffix(content, "\n") {
|
|
content += "\n"
|
|
}
|
|
|
|
// Use printf to write the content to avoid shell interpretation issues
|
|
// First write to a temp file, then atomically move it
|
|
escapedContent := strings.ReplaceAll(content, "'", "'\\''")
|
|
cmd := fmt.Sprintf(
|
|
`mkdir -p %s/.ssh && chmod 700 %s/.ssh && printf '%%s' '%s' > %s/.ssh/authorized_keys.tmp && mv %s/.ssh/authorized_keys.tmp %s/.ssh/authorized_keys && chmod 600 %s/.ssh/authorized_keys && chown '%s':'%s' %s/.ssh/authorized_keys`,
|
|
homeDir, homeDir, escapedContent, homeDir, homeDir, homeDir, homeDir, systemUser, systemUser, homeDir,
|
|
)
|
|
|
|
if err := session.Run(cmd); err != nil {
|
|
return fmt.Errorf("failed to write authorized_keys: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|