From 9fa92423b5b3960ee7f46fb66fc18d12fcc8af29 Mon Sep 17 00:00:00 2001 From: zoop <101409458+zoop-btc@users.noreply.github.com> Date: Mon, 22 May 2023 19:34:24 +0200 Subject: [PATCH] feat: SSH-Agent Support (#306) * chore: add agent configuration bool * feat: add ssh-agent authentication mechanism for linux * chore: make sure ssh-agent auth is only executed on linux * chore: add ssh user override * chore: add ssh configuration block, check ssh config during VirtualEnvironmentClient creation * fix: handle case of empty ssh config block * chore: add ssh password auth fallback logic * fix: remove not needed runtime * fix linter errors & re-format * allow ssh agent on all POSIX systems * add `agent_socket` parameter * update docs and examples --------- Co-authored-by: zoop Co-authored-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com> --- docs/index.md | 46 +++++++++++++- example/main.tf | 3 + proxmox/virtual_environment_client.go | 46 +++++++++++--- proxmox/virtual_environment_client_types.go | 20 +++--- proxmox/virtual_environment_nodes.go | 68 ++++++++++++++++++--- proxmoxtf/provider/provider.go | 34 +++++++++-- proxmoxtf/provider/schema.go | 59 ++++++++++++++++++ 7 files changed, 247 insertions(+), 29 deletions(-) diff --git a/docs/index.md b/docs/index.md index ff0b68ea2..e4887ceb0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,6 +67,35 @@ export PROXMOX_VE_PASSWORD="a-strong-password" terraform plan ``` +### SSH connection + +The Proxmox provider can connect to a Proxmox node via SSH. This is used in +the `proxmox_virtual_environment_vm` or `proxmox_virtual_environment_file` +resource to execute commands on the node to perform actions that are not +supported by Proxmox API. For example, to import VM disks, or to uploading +certain type of resources, such as snippets. + +The SSH connection configuration is provided via the optional `ssh` block in +the `provider` block: + +```terraform +provider "proxmox" { + endpoint = "https://10.0.0.2:8006/" + username = "username@realm" + password = "a-strong-password" + insecure = true + ssh { + agent = true + } +} +``` + +If no `ssh` block is provided, the provider will attempt to connect to the +target node using the credentials provided in the `username` and `password` fields. +Note that the target node is identified by the `node` argument in the resource, +and may be different from the Proxmox API endpoint. Please refer to the +section below for all the available arguments in the `ssh` block. + ## Argument Reference In addition @@ -85,5 +114,20 @@ Proxmox `provider` block: - `password` - (Required) The password for the Proxmox Virtual Environment API (can also be sourced from `PROXMOX_VE_PASSWORD`). - `username` - (Required) The username and realm for the Proxmox Virtual - Environment API (can also be sourced from `PROXMOX_VE_USERNAME`). For + Environment API (can also be sourced from `PROXMOX_VE_USERNAME`). For example, `root@pam`. +- `ssh` - (Optional) The SSH connection configuration to a Proxmox node. This is + a + block, whose fields are documented below. + - `username` - (Optional) The username to use for the SSH connection. + Defaults to the username used for the Proxmox API connection. Can also be + sourced from `PROXMOX_VE_SSH_USERNAME`. + - `password` - (Optional) The password to use for the SSH connection. + Defaults to the password used for the Proxmox API connection. Can also be + sourced from `PROXMOX_VE_SSH_PASSWORD`. + - `agent` - (Optional) Whether to use the SSH agent for the SSH + authentication. Defaults to `false`. Can also be sourced + from `PROXMOX_VE_SSH_AGENT`. + - `agent_socket` - (Optional) The path to the SSH agent socket. + Defaults to the value of the `SSH_AUTH_SOCK` environment variable. Can + also be sourced from `PROXMOX_VE_SSH_AUTH_SOCK`. diff --git a/example/main.tf b/example/main.tf index 5001e6447..08ace84b4 100644 --- a/example/main.tf +++ b/example/main.tf @@ -3,4 +3,7 @@ provider "proxmox" { username = var.virtual_environment_username password = var.virtual_environment_password insecure = true + ssh { + agent = true + } } diff --git a/proxmox/virtual_environment_client.go b/proxmox/virtual_environment_client.go index 810528044..e1e9ccc78 100644 --- a/proxmox/virtual_environment_client.go +++ b/proxmox/virtual_environment_client.go @@ -1,6 +1,8 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* + * 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/. */ + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package proxmox @@ -14,6 +16,7 @@ import ( "io" "net/http" "net/url" + "runtime" "strings" "github.com/google/go-querystring/query" @@ -24,7 +27,7 @@ import ( // NewVirtualEnvironmentClient creates and initializes a VirtualEnvironmentClient instance. func NewVirtualEnvironmentClient( endpoint, username, password, otp string, - insecure bool, + insecure bool, sshUsername string, sshPassword string, sshAgent bool, sshAgentSocket string, ) (*VirtualEnvironmentClient, error) { u, err := url.ParseRequestURI(endpoint) if err != nil { @@ -51,6 +54,12 @@ func NewVirtualEnvironmentClient( ) } + if !strings.Contains(username, "@") { + return nil, errors.New( + "make sure the username for the Proxmox Virtual Environment API ends in '@pve or @pam'", + ) + } + var pOTP *string if otp != "" { @@ -68,13 +77,32 @@ func NewVirtualEnvironmentClient( httpClient := &http.Client{Transport: transport} + if sshUsername == "" { + sshUsername = strings.Split(username, "@")[0] + } + + if sshPassword == "" { + sshPassword = password + } + + if sshAgent && runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "freebsd" { + return nil, errors.New( + "the ssh agent flag is only supported on POSIX systems, please set it to 'false'" + + " or remove it from your provider configuration", + ) + } + return &VirtualEnvironmentClient{ - Endpoint: strings.TrimRight(u.String(), "/"), - Insecure: insecure, - OTP: pOTP, - Password: password, - Username: username, - httpClient: httpClient, + Endpoint: strings.TrimRight(u.String(), "/"), + Insecure: insecure, + OTP: pOTP, + Password: password, + Username: username, + SSHUsername: sshUsername, + SSHPassword: sshPassword, + SSHAgent: sshAgent, + SSHAgentSocket: sshAgentSocket, + httpClient: httpClient, }, nil } diff --git a/proxmox/virtual_environment_client_types.go b/proxmox/virtual_environment_client_types.go index 4e0d8c915..8f37b2387 100644 --- a/proxmox/virtual_environment_client_types.go +++ b/proxmox/virtual_environment_client_types.go @@ -1,6 +1,8 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* + * 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/. */ + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package proxmox @@ -19,11 +21,15 @@ const ( // VirtualEnvironmentClient implements an API client for the Proxmox Virtual Environment API. type VirtualEnvironmentClient struct { - Endpoint string - Insecure bool - OTP *string - Password string - Username string + Endpoint string + Insecure bool + OTP *string + Password string + Username string + SSHUsername string + SSHPassword string + SSHAgent bool + SSHAgentSocket string authenticationData *VirtualEnvironmentAuthenticationResponseData httpClient *http.Client diff --git a/proxmox/virtual_environment_nodes.go b/proxmox/virtual_environment_nodes.go index 3487a444e..cacd16d9c 100644 --- a/proxmox/virtual_environment_nodes.go +++ b/proxmox/virtual_environment_nodes.go @@ -1,6 +1,8 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public +/* + * 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/. */ + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package proxmox @@ -21,6 +23,7 @@ import ( "github.com/skeema/knownhosts" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" ) // ExecuteNodeCommands executes commands on a given node. @@ -193,8 +196,6 @@ func (c *VirtualEnvironmentClient) OpenNodeShell( return nil, err } - ur := strings.Split(c.Username, "@") - homeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("failed to determine the home directory: %w", err) @@ -246,8 +247,61 @@ func (c *VirtualEnvironmentClient) OpenNodeShell( }) sshConfig := &ssh.ClientConfig{ - User: ur[0], - Auth: []ssh.AuthMethod{ssh.Password(c.Password)}, + User: c.SSHUsername, + Auth: []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, + HostKeyCallback: cb, + HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost), + } + + tflog.Info(ctx, fmt.Sprintf("Agent is set to %t", c.SSHAgent)) + + if c.SSHAgent { + sshClient, err := c.CreateSSHClientAgent(ctx, cb, kh, sshHost) + if err != nil { + tflog.Error(ctx, "Failed ssh connection through agent, "+ + "falling back to password authentication", + map[string]interface{}{ + "error": err, + }) + } else { + 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.SSHUsername, + }) + return sshClient, nil +} + +// CreateSSHClientAgent establishes an ssh connection through the agent authentication mechanism +func (c *VirtualEnvironmentClient) CreateSSHClientAgent( + ctx context.Context, + cb ssh.HostKeyCallback, + kh knownhosts.HostKeyCallback, + sshHost string, +) (*ssh.Client, error) { + if c.SSHAgentSocket == "" { + return nil, errors.New("failed connecting to SSH agent socket: the socket file is not defined, " + + "authentication will fall back to password") + } + + conn, err := net.Dial("unix", c.SSHAgentSocket) + if err != nil { + return nil, fmt.Errorf("failed connecting to SSH auth socket '%s': %w", c.SSHAgentSocket, err) + } + + ag := agent.NewClient(conn) + + sshConfig := &ssh.ClientConfig{ + User: c.SSHUsername, + Auth: []ssh.AuthMethod{ssh.PublicKeysCallback(ag.Signers), ssh.Password(c.SSHPassword)}, HostKeyCallback: cb, HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost), } @@ -259,7 +313,7 @@ func (c *VirtualEnvironmentClient) OpenNodeShell( tflog.Debug(ctx, "SSH connection established", map[string]interface{}{ "host": sshHost, - "user": ur[0], + "user": c.SSHUsername, }) return sshClient, nil } diff --git a/proxmoxtf/provider/provider.go b/proxmoxtf/provider/provider.go index ad73bb29b..60a667d05 100644 --- a/proxmoxtf/provider/provider.go +++ b/proxmoxtf/provider/provider.go @@ -17,14 +17,18 @@ import ( ) const ( - dvProviderOTP = "" - + dvProviderOTP = "" mkProviderVirtualEnvironment = "virtual_environment" mkProviderEndpoint = "endpoint" mkProviderInsecure = "insecure" mkProviderOTP = "otp" mkProviderPassword = "password" mkProviderUsername = "username" + mkProviderSSH = "ssh" + mkProviderSSHUsername = "username" + mkProviderSSHPassword = "password" + mkProviderSSHAgent = "agent" + mkProviderSSHAgentSocket = "agent_socket" ) // ProxmoxVirtualEnvironment returns the object for this provider. @@ -41,26 +45,46 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{}, var err error var veClient *proxmox.VirtualEnvironmentClient - // Initialize the client for the Virtual Environment, if required. + // Legacy configuration, wrapped in the deprecated `virtual_environment` block veConfigBlock := d.Get(mkProviderVirtualEnvironment).([]interface{}) - if len(veConfigBlock) > 0 { veConfig := veConfigBlock[0].(map[string]interface{}) + veSSHConfig := veConfig[mkProviderSSH].(map[string]interface{}) veClient, err = proxmox.NewVirtualEnvironmentClient( veConfig[mkProviderEndpoint].(string), veConfig[mkProviderUsername].(string), + veConfig[mkProviderSSH].(map[string]interface{})[mkProviderSSHUsername].(string), veConfig[mkProviderPassword].(string), - veConfig[mkProviderOTP].(string), veConfig[mkProviderInsecure].(bool), + veSSHConfig[mkProviderSSHUsername].(string), + veSSHConfig[mkProviderSSHPassword].(string), + veSSHConfig[mkProviderSSHAgent].(bool), + veSSHConfig[mkProviderSSHAgentSocket].(string), ) } else { + sshconf := map[string]interface{}{ + mkProviderSSHUsername: "", + mkProviderSSHPassword: "", + mkProviderSSHAgent: false, + mkProviderSSHAgentSocket: "", + } + + sshBlock, sshSet := d.GetOk(mkProviderSSH) + if sshSet { + sshconf = sshBlock.(*schema.Set).List()[0].(map[string]interface{}) + } + veClient, err = proxmox.NewVirtualEnvironmentClient( d.Get(mkProviderEndpoint).(string), d.Get(mkProviderUsername).(string), d.Get(mkProviderPassword).(string), d.Get(mkProviderOTP).(string), d.Get(mkProviderInsecure).(bool), + sshconf[mkProviderSSHUsername].(string), + sshconf[mkProviderSSHPassword].(string), + sshconf[mkProviderSSHAgent].(bool), + sshconf[mkProviderSSHAgentSocket].(string), ) } diff --git a/proxmoxtf/provider/schema.go b/proxmoxtf/provider/schema.go index b7b2142db..551e3ec04 100644 --- a/proxmoxtf/provider/schema.go +++ b/proxmoxtf/provider/schema.go @@ -98,5 +98,64 @@ func nestedProviderSchema() map[string]*schema.Schema { }, ValidateFunc: validation.StringIsNotEmpty, }, + mkProviderSSH: { + Type: schema.TypeSet, + Optional: true, + MaxItems: 1, + Description: "The SSH connection configuration to a Proxmox node", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + mkProviderSSHUsername: { + Type: schema.TypeString, + Optional: true, + Description: fmt.Sprintf("The username used for the SSH connection, "+ + "defaults to the user specified in '%s'", mkProviderUsername), + DefaultFunc: schema.MultiEnvDefaultFunc( + []string{"PROXMOX_VE_SSH_USERNAME", "PM_VE_SSH_USERNAME"}, + nil, + ), + ValidateFunc: validation.StringIsNotEmpty, + }, + mkProviderSSHPassword: { + Type: schema.TypeString, + Optional: true, + Description: fmt.Sprintf("The password used for the SSH connection, "+ + "defaults to the password specified in '%s'", mkProviderPassword), + DefaultFunc: schema.MultiEnvDefaultFunc( + []string{"PROXMOX_VE_SSH_PASSWORD", "PM_VE_SSH_PASSWORD"}, + nil, + ), + ValidateFunc: validation.StringIsNotEmpty, + }, + mkProviderSSHAgent: { + Type: schema.TypeBool, + Optional: true, + Description: "Whether to use the SSH agent for the SSH authentication. Defaults to false", + DefaultFunc: func() (interface{}, error) { + for _, k := range []string{"PROXMOX_VE_SSH_AGENT", "PM_VE_SSH_AGENT"} { + v := os.Getenv(k) + + if v == "true" || v == "1" { + return true, nil + } + } + + return false, nil + }, + }, + mkProviderSSHAgentSocket: { + Type: schema.TypeString, + Optional: true, + Description: "The path to the SSH agent socket. Defaults to the value of the `SSH_AUTH_SOCK` " + + "environment variable", + DefaultFunc: schema.MultiEnvDefaultFunc( + []string{"SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK", "PM_VE_SSH_AUTH_SOCK"}, + nil, + ), + ValidateFunc: validation.StringIsNotEmpty, + }, + }, + }, + }, } }