0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-06-29 18:21:10 +00:00
terraform-provider-proxmox/proxmox/ssh/client.go
renovate[bot] ce5cc746f9
chore(deps): update golangci/golangci-lint (v2.0.2 → v2.1.2) (#1903)
* chore(deps): update golangci/golangci-lint (v2.0.2 → v2.1.2)

| datasource      | package                | from   | to     |
| --------------- | ---------------------- | ------ | ------ |
| github-releases | golangci/golangci-lint | v2.0.2 | v2.1.2 |

* fix linter errors

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>

---------

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
2025-04-16 19:19:18 -04:00

689 lines
18 KiB
Go

/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ssh
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/pkg/sftp"
"github.com/skeema/knownhosts"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/net/proxy"
"github.com/bpg/terraform-provider-proxmox/proxmox/api"
"github.com/bpg/terraform-provider-proxmox/utils"
)
const (
// TrySudo is a shell function that tries to execute a command with sudo if the user has sudo permissions.
TrySudo = `try_sudo(){ if [ $(sudo -n pvesm apiinfo 2>&1 | grep "APIVER" | wc -l) -gt 0 ]; then sudo $1; else $1; fi }`
)
// NewErrUserHasNoPermission creates a new error indicating that the SSH user does not have required permissions.
func NewErrUserHasNoPermission(username string) error {
return fmt.Errorf("the SSH user '%s' does not have required permissions. "+
"Make sure 'sudo' is installed and the user is configured in sudoers file. "+
"Refer to the documentation for more details", username)
}
// Client is an interface for performing SSH requests against the Proxmox Nodes.
type Client interface {
// Username returns the SSH username.
Username() string
// ExecuteNodeCommands executes a command on a node.
ExecuteNodeCommands(ctx context.Context, nodeName string, commands []string) ([]byte, error)
// NodeUpload uploads a file to a node.
NodeUpload(ctx context.Context, nodeName string,
remoteFileDir string, fileUploadRequest *api.FileUploadRequest) error
// NodeStreamUpload uploads a file to a node by streaming its content over SSH.
NodeStreamUpload(ctx context.Context, nodeName string,
remoteFileDir string, fileUploadRequest *api.FileUploadRequest) error
}
type client struct {
username string
password string
agent bool
agentSocket string
privateKey string
socks5Server string
socks5Username string
socks5Password string
nodeResolver NodeResolver
}
// NewClient creates a new SSH client.
func NewClient(
username string, password string,
agent bool, agentSocket string,
privateKey string,
socks5Server string, socks5Username string, socks5Password string,
nodeResolver NodeResolver,
) (Client, error) {
if agent &&
runtime.GOOS != "linux" &&
runtime.GOOS != "darwin" &&
runtime.GOOS != "freebsd" &&
runtime.GOOS != "windows" {
return nil, errors.New(
"the ssh agent flag is only supported on POSIX and Windows systems, please set it to 'false'" +
" or remove it from your provider configuration",
)
}
if (socks5Username != "" || socks5Password != "") && socks5Server == "" {
return nil, errors.New("socks5 server is required when socks5 username or password is set")
}
if nodeResolver == nil {
return nil, errors.New("node resolver is required")
}
return &client{
username: username,
password: password,
agent: agent,
agentSocket: agentSocket,
privateKey: privateKey,
socks5Server: socks5Server,
socks5Username: socks5Username,
socks5Password: socks5Password,
nodeResolver: nodeResolver,
}, nil
}
func (c *client) Username() string {
return c.username
}
// ExecuteNodeCommands executes commands on a given node.
func (c *client) ExecuteNodeCommands(ctx context.Context, nodeName string, commands []string) ([]byte, error) {
node, err := c.nodeResolver.Resolve(ctx, nodeName)
if err != nil {
return nil, fmt.Errorf("failed to find node endpoint: %w", err)
}
tflog.Debug(ctx, "executing commands on the node using SSH", map[string]interface{}{
"node_address": node.Address,
"node_port": node.Port,
"commands": commands,
})
sshClient, err := c.openNodeShell(ctx, node)
if err != nil {
return nil, err
}
defer func(sshClient *ssh.Client) {
e := sshClient.Close()
if e != nil {
tflog.Warn(ctx, "failed to close SSH client", map[string]interface{}{
"error": e,
})
}
}(sshClient)
output, err := c.executeCommands(ctx, sshClient, commands)
if err != nil {
return nil, err
}
return output, nil
}
func (c *client) executeCommands(ctx context.Context, sshClient *ssh.Client, commands []string) ([]byte, error) {
sshSession, err := sshClient.NewSession()
if err != nil {
return nil, fmt.Errorf("failed to create SSH session: %w", err)
}
defer func(session *ssh.Session) {
e := session.Close()
if e != nil && !errors.Is(e, io.EOF) {
tflog.Warn(ctx, "failed to close SSH session", map[string]interface{}{
"error": e,
})
}
}(sshSession)
script := strings.Join(commands, "; ")
output, err := sshSession.CombinedOutput(
fmt.Sprintf(
// explicitly use bash to support shell features like pipes and var assignment
"/bin/bash -c '%s'",
// shell script escaping for single quotes
strings.ReplaceAll(script, `'`, `'"'"'`),
),
)
if err != nil {
return nil, errors.New(string(output))
}
return output, nil
}
func (c *client) NodeUpload(
ctx context.Context,
nodeName string,
remoteFileDir string,
d *api.FileUploadRequest,
) error {
ip, err := c.nodeResolver.Resolve(ctx, nodeName)
if err != nil {
return fmt.Errorf("failed to find node endpoint: %w", err)
}
tflog.Debug(ctx, "uploading file to the node datastore using SFTP", map[string]interface{}{
"node_address": ip,
"remote_dir": remoteFileDir,
"file_name": d.FileName,
"content_type": d.ContentType,
})
fileInfo, err := d.File.Stat()
if err != nil {
return fmt.Errorf("failed to get file info: %w", err)
}
fileSize := fileInfo.Size()
sshClient, err := c.openNodeShell(ctx, ip)
if err != nil {
return fmt.Errorf("failed to open SSH client: %w", err)
}
defer func(sshClient *ssh.Client) {
e := sshClient.Close()
if e != nil {
tflog.Warn(ctx, "failed to close SSH client", map[string]interface{}{
"error": e,
})
}
}(sshClient)
if d.ContentType != "" {
remoteFileDir = filepath.Join(remoteFileDir, d.ContentType)
}
remoteFilePath := strings.ReplaceAll(filepath.Join(remoteFileDir, d.FileName), `\`, "/")
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func(sftpClient *sftp.Client) {
e := sftpClient.Close()
if e != nil {
tflog.Warn(ctx, "failed to close SFTP client", map[string]interface{}{
"error": e,
})
}
}(sftpClient)
err = sftpClient.MkdirAll(remoteFileDir)
if err != nil {
return fmt.Errorf("failed to create directory %s: %w", remoteFileDir, err)
}
remoteFile, err := sftpClient.Create(remoteFilePath)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", remoteFilePath, err)
}
defer func(remoteFile *sftp.File) {
e := remoteFile.Close()
if e != nil {
tflog.Warn(ctx, "failed to close remote file", map[string]interface{}{
"error": e,
})
}
}(remoteFile)
bytesUploaded, err := remoteFile.ReadFrom(d.File)
if err != nil {
return fmt.Errorf("failed to upload file %s: %w", remoteFilePath, err)
}
if bytesUploaded != fileSize {
return fmt.Errorf("failed to upload file %s: uploaded %d bytes, expected %d bytes",
remoteFilePath, bytesUploaded, fileSize)
}
tflog.Debug(ctx, "uploaded file to datastore", map[string]interface{}{
"remote_file_path": remoteFilePath,
"size": bytesUploaded,
})
return nil
}
func (c *client) NodeStreamUpload(
ctx context.Context,
nodeName string,
remoteFileDir string,
d *api.FileUploadRequest,
) error {
ip, err := c.nodeResolver.Resolve(ctx, nodeName)
if err != nil {
return fmt.Errorf("failed to find node endpoint: %w", err)
}
tflog.Debug(ctx, "uploading file to the node datastore via SSH input stream ", map[string]interface{}{
"node_address": ip,
"remote_dir": remoteFileDir,
"file_name": d.FileName,
"content_type": d.ContentType,
})
fileInfo, err := d.File.Stat()
if err != nil {
return fmt.Errorf("failed to get file info: %w", err)
}
fileSize := fileInfo.Size()
sshClient, err := c.openNodeShell(ctx, ip)
if err != nil {
return fmt.Errorf("failed to open SSH client: %w", err)
}
defer func(sshClient *ssh.Client) {
e := sshClient.Close()
if e != nil {
tflog.Warn(ctx, "failed to close SSH client", map[string]interface{}{
"error": e,
})
}
}(sshClient)
if d.ContentType != "" {
remoteFileDir = filepath.Join(remoteFileDir, d.ContentType)
}
remoteFilePath := strings.ReplaceAll(filepath.Join(remoteFileDir, d.FileName), `\`, "/")
err = c.uploadFile(ctx, sshClient, d, remoteFilePath)
if err != nil {
return err
}
err = c.checkUploadedFile(ctx, sshClient, remoteFilePath, fileSize)
if err != nil {
return err
}
if d.Mode != "" {
parsedFileMode, parseErr := strconv.ParseUint(d.Mode, 8, 12)
if parseErr != nil {
return fmt.Errorf("failed to parse file mode %q: %w", d.Mode, parseErr)
}
mode := uint32(parsedFileMode)
if err = c.changeModeUploadedFile(ctx, sshClient, remoteFilePath, os.FileMode(mode)); err != nil {
return err
}
}
tflog.Debug(ctx, "uploaded file to datastore", map[string]interface{}{
"remote_file_path": remoteFilePath,
})
return nil
}
func (c *client) uploadFile(
ctx context.Context,
sshClient *ssh.Client,
req *api.FileUploadRequest,
remoteFilePath string,
) error {
sshSession, err := sshClient.NewSession()
if err != nil {
return fmt.Errorf("failed to create SSH session: %w", err)
}
defer func(session *ssh.Session) {
e := session.Close()
if e != nil && !errors.Is(e, io.EOF) {
tflog.Warn(ctx, "failed to close SSH session", map[string]interface{}{
"error": e,
})
}
}(sshSession)
sshSession.Stdin = req.File
output, err := sshSession.CombinedOutput(
fmt.Sprintf(`%s; try_sudo "/usr/bin/tee %s"`, TrySudo, remoteFilePath),
)
if err != nil {
return fmt.Errorf("error transferring file: %s", string(output))
}
return nil
}
func (c *client) checkUploadedFile(
ctx context.Context,
sshClient *ssh.Client,
remoteFilePath string,
fileSize int64,
) error {
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func(sftpClient *sftp.Client) {
e := sftpClient.Close()
if e != nil {
tflog.Warn(ctx, "failed to close SFTP client", map[string]interface{}{
"error": e,
})
}
}(sftpClient)
remoteFile, err := sftpClient.Open(remoteFilePath)
if err != nil {
return fmt.Errorf("failed to open remote file %s: %w", remoteFilePath, err)
}
remoteStat, err := remoteFile.Stat()
if err != nil {
return fmt.Errorf("failed to read remote file %s: %w", remoteFilePath, err)
}
bytesUploaded := remoteStat.Size()
if bytesUploaded != fileSize {
return fmt.Errorf("failed to upload file %s: uploaded %d bytes, expected %d bytes",
remoteFilePath, bytesUploaded, fileSize)
}
return nil
}
func (c *client) changeModeUploadedFile(
ctx context.Context,
sshClient *ssh.Client,
remoteFilePath string,
fileMode os.FileMode,
) error {
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
return fmt.Errorf("failed to create SFTP client: %w", err)
}
defer func(sftpClient *sftp.Client) {
e := sftpClient.Close()
if e != nil {
tflog.Warn(ctx, "failed to close SFTP client", map[string]interface{}{
"error": e,
})
}
}(sftpClient)
remoteFile, err := sftpClient.Open(remoteFilePath)
if err != nil {
return fmt.Errorf("failed to open remote file %s: %w", remoteFilePath, err)
}
remoteStat, err := remoteFile.Stat()
if err != nil {
return fmt.Errorf("failed to read remote file %s: %w", remoteFilePath, err)
}
if err = sftpClient.Chmod(remoteFilePath, fileMode); err != nil {
return fmt.Errorf("failed to change file mode of remote file from %#o (%s) to %#o (%s): %w",
remoteStat.Mode().Perm(), remoteStat.Mode(), fileMode.Perm(), fileMode, err)
}
tflog.Debug(ctx, "changed mode of uploaded file", map[string]interface{}{
"before": fmt.Sprintf("%#o (%s)", remoteStat.Mode().Perm(), remoteStat.Mode()),
"after": fmt.Sprintf("%#o (%s)", fileMode.Perm(), fileMode),
})
return nil
}
// openNodeShell establishes a new SSH connection to a node.
func (c *client) openNodeShell(ctx context.Context, node ProxmoxNode) (*ssh.Client, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to determine the home directory: %w", err)
}
var sshHost string
if strings.Contains(node.Address, ":") {
// IPv6
sshHost = fmt.Sprintf("[%s]:%d", node.Address, node.Port)
} else {
// IPv4
sshHost = fmt.Sprintf("%s:%d", node.Address, node.Port)
}
sshPath := path.Join(homeDir, ".ssh")
if _, err = os.Stat(sshPath); os.IsNotExist(err) {
e := os.Mkdir(sshPath, 0o700)
if e != nil && !os.IsExist(e) {
return nil, fmt.Errorf("failed to create %s: %w", sshPath, e)
}
}
khPath := path.Join(sshPath, "known_hosts")
if _, err = os.Stat(khPath); os.IsNotExist(err) {
e := os.WriteFile(khPath, []byte{}, 0o600)
if e != nil {
return nil, fmt.Errorf("failed to create %s: %w", khPath, e)
}
}
kh, err := knownhosts.New(khPath)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", khPath, err)
}
// Create a custom permissive host key callback which still errors on hosts
// with changed keys, but allows unknown hosts and adds them to known_hosts
cb := ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error {
khErr := kh(hostname, remote, key)
if knownhosts.IsHostKeyChanged(khErr) {
return fmt.Errorf("REMOTE HOST IDENTIFICATION HAS CHANGED for host %s! This may indicate a MitM attack", hostname)
}
if knownhosts.IsHostUnknown(khErr) {
f, fErr := os.OpenFile(khPath, os.O_APPEND|os.O_WRONLY, 0o600)
if fErr == nil {
defer utils.CloseOrLogError(ctx)(f)
fErr = knownhosts.WriteKnownHost(f, hostname, remote, key)
}
if fErr == nil {
tflog.Info(ctx, fmt.Sprintf("Added host %s to known_hosts", hostname))
} else {
tflog.Error(ctx, fmt.Sprintf("Failed to add host %s to known_hosts", hostname), map[string]interface{}{
"error": khErr,
})
}
return nil
}
return khErr
})
tflog.Info(ctx, fmt.Sprintf("agent is set to %t", c.agent))
var sshClient *ssh.Client
if c.agent {
sshClient, err = c.createSSHClientAgent(ctx, cb, kh, sshHost)
if err == nil {
return sshClient, nil
}
tflog.Error(ctx, "Failed SSH connection through agent",
map[string]interface{}{
"error": err,
})
}
if c.privateKey != "" {
sshClient, err = c.createSSHClientWithPrivateKey(ctx, cb, kh, sshHost)
if err == nil {
return sshClient, nil
}
tflog.Error(ctx, "Failed SSH connection with private key",
map[string]interface{}{
"error": err,
})
}
tflog.Info(ctx, "Falling back to password authentication for SSH connection")
sshClient, err = c.createSSHClient(ctx, cb, kh, sshHost)
if err != nil {
return nil, fmt.Errorf("unable to authenticate user %q over SSH to %q. Please verify that ssh-agent is "+
"correctly loaded with an authorized key via 'ssh-add -L' (NOTE: configurations in ~/.ssh/config are "+
"not considered by the provider): %w", c.username, sshHost, err)
}
return sshClient, nil
}
func (c *client) createSSHClient(
ctx context.Context,
cb ssh.HostKeyCallback,
kh knownhosts.HostKeyCallback,
sshHost string,
) (*ssh.Client, error) {
if c.password == "" {
tflog.Error(ctx, "Using password authentication fallback for SSH connection, but the SSH password is empty")
}
sshConfig := &ssh.ClientConfig{
User: c.username,
Auth: []ssh.AuthMethod{ssh.Password(c.password)},
HostKeyCallback: cb,
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
}
return c.connect(ctx, sshHost, sshConfig)
}
// createSSHClientAgent establishes an ssh connection through the agent authentication mechanism.
func (c *client) createSSHClientAgent(
ctx context.Context,
cb ssh.HostKeyCallback,
kh knownhosts.HostKeyCallback,
sshHost string,
) (*ssh.Client, error) {
conn, err := dialSocket(c.agentSocket)
if err != nil {
return nil, fmt.Errorf("failed connecting to SSH auth socket '%s': %w", c.agentSocket, err)
}
ag := agent.NewClient(conn)
sshConfig := &ssh.ClientConfig{
User: c.username,
Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(ag.Signers), ssh.Password(c.password)},
HostKeyCallback: cb,
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
}
return c.connect(ctx, sshHost, sshConfig)
}
func (c *client) createSSHClientWithPrivateKey(
ctx context.Context,
cb ssh.HostKeyCallback,
kh knownhosts.HostKeyCallback,
sshHost string,
) (*ssh.Client, error) {
privateKey, err := ssh.ParsePrivateKey([]byte(c.privateKey))
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
sshConfig := &ssh.ClientConfig{
User: c.username,
Auth: []ssh.AuthMethod{ssh.PublicKeys(privateKey)},
HostKeyCallback: cb,
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
}
return c.connect(ctx, sshHost, sshConfig)
}
func (c *client) connect(ctx context.Context, sshHost string, sshConfig *ssh.ClientConfig) (*ssh.Client, error) {
if c.socks5Server != "" {
sshClient, err := c.socks5SSHClient(sshHost, sshConfig)
if err != nil {
return nil, fmt.Errorf("failed to dial %s via SOCKS5 proxy %s: %w", sshHost, c.socks5Server, err)
}
tflog.Debug(ctx, "SSH connection via SOCKS5 established", map[string]interface{}{
"host": sshHost,
"socks5_server": c.socks5Server,
"user": c.username,
})
return sshClient, nil
}
sshClient, err := ssh.Dial("tcp", sshHost, sshConfig)
if err != nil {
return nil, fmt.Errorf("failed to dial %s: %w", sshHost, err)
}
tflog.Debug(ctx, "SSH connection established", map[string]interface{}{
"host": sshHost,
"user": c.username,
})
return sshClient, nil
}
func (c *client) socks5SSHClient(sshServerAddress string, sshConfig *ssh.ClientConfig) (*ssh.Client, error) {
dialer, err := proxy.SOCKS5("tcp", c.socks5Server, &proxy.Auth{
User: c.socks5Username,
Password: c.socks5Password,
}, proxy.Direct)
if err != nil {
return nil, fmt.Errorf("failed to create SOCKS5 proxy dialer: %w", err)
}
conn, err := dialer.Dial("tcp", sshServerAddress)
if err != nil {
return nil, fmt.Errorf("failed to dial %s via SOCKS5 proxy %s: %w", sshServerAddress, c.socks5Server, err)
}
sshConn, ch, reqs, err := ssh.NewClientConn(conn, sshServerAddress, sshConfig)
if err != nil {
return nil, fmt.Errorf("failed to create SSH client connection: %w", err)
}
return ssh.NewClient(sshConn, ch, reqs), nil
}