Skip to content

Commit

Permalink
feat(provider): add support for private key authentication for SSH (#…
Browse files Browse the repository at this point in the history
…1076)

* feat(provider): add support for private key authentication for SSH

Also fix bunch of issues with acceptance tests

---------

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
  • Loading branch information
bpg committed Mar 2, 2024
1 parent 66ec9f4 commit 2c6d3ad
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 39 deletions.
27 changes: 7 additions & 20 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ on:
push:
branches:
- main
- "release/**"

jobs:
build:
Expand All @@ -30,9 +29,7 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
cache-dependency-path: |
go.sum
tools/go.sum
cache-dependency-path: "**/*.sum"

- name: Get dependencies
if: steps.filter.outputs.go == 'true'
Expand Down Expand Up @@ -63,9 +60,7 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
cache-dependency-path: |
go.sum
tools/go.sum
cache-dependency-path: "**/*.sum"

- name: Get dependencies
if: steps.filter.outputs.go == 'true'
Expand All @@ -88,9 +83,6 @@ jobs:
terraform: [ 1.6 ]
runs-on: ${{ matrix.os }}
environment: pve-acc
concurrency:
group: acceptance
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -101,18 +93,11 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
cache-dependency-path: |
go.sum
tools/go.sum
cache-dependency-path: "**/*.sum"

- name: Get dependencies
run: go mod download

- name: Setup ssh-agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROXMOX_VE_SSH_USER_PRIVATE_KEY }}

- uses: hashicorp/setup-terraform@a1502cd9e758c50496cc9ac5308c4843bcd56d36 # v3.0.0
with:
terraform_version: ${{ matrix.terraform }}.*
Expand All @@ -122,9 +107,11 @@ jobs:
timeout-minutes: 10
env:
TF_ACC: 1
PROXMOX_VE_INSECURE: false
PROXMOX_VE_API_TOKEN: "${{ secrets.PROXMOX_VE_API_TOKEN }}"
PROXMOX_VE_ENDPOINT: "https://${{ secrets.PROXMOX_VE_HOST }}:8006/"
PROXMOX_VE_SSH_AGENT: false
PROXMOX_VE_SSH_USERNAME: "terraform"
PROXMOX_VE_SSH_AGENT: true
PROXMOX_VE_INSECURE: false
PROXMOX_VE_SSH_PRIVATE_KEY: "${{ secrets.PROXMOX_VE_SSH_PRIVATE_KEY }}"
run: make testacc

36 changes: 36 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,41 @@ The provider does not use OS-specific SSH configuration files, such as `~/.ssh/c
Instead, it uses the SSH protocol directly, and supports the `SSH_AUTH_SOCK` environment variable (or `agent_socket` argument) to connect to the `ssh-agent`.
This allows the provider to use the SSH agent configured by the user, and to support multiple SSH agents running on the same machine.
You can find more details on the SSH Agent [here](https://www.digitalocean.com/community/tutorials/ssh-essentials-working-with-ssh-servers-clients-and-keys#adding-your-ssh-keys-to-an-ssh-agent-to-avoid-typing-the-passphrase).
The SSH agent authentication takes precedence over the `private_key` and `password` authentication.

### SSH Private Key

In some cases where SSH agent is not available, for example when running Terraform from a Windows machine, or when using a CI/CD pipeline that does not support SSH agent forwarding,
you can use the `private_key` argument in the `ssh` block (or alternatively `PROXMOX_VE_SSH_PRIVATE_KEY` environment variable) to provide the private key for the SSH connection.

The private key must be in PEM format, and can be loaded from a file:

```terraform
provider "proxmox" {
...
ssh {
agent = false
private_key = file("~/.ssh/id_rsa")
}
}
```
Not recommended, but you can also use a heredoc syntax to provide the private key as a string (note that the private key content must not be indented):
```terraform
provider "proxmox" {
...
ssh {
agent = false
private_key = <<EOF
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
<SKIPPED>
DMUWUEaH7yMCKl7uCZ9xAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----
}
}
```

### SSH User

Expand Down Expand Up @@ -317,6 +352,7 @@ In addition to [generic provider arguments](https://www.terraform.io/docs/config
- `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`.
- `private_key` - (Optional) The private key to use for the SSH connection. Can also be sourced from `PROXMOX_VE_SSH_PRIVATE_KEY`. The private key must be in PEM format.
- `socks5_server` - (Optional) The address of the SOCKS5 proxy server to use for the SSH connection. Can also be sourced from `PROXMOX_VE_SSH_SOCKS5_SERVER`.
- `socks5_username` - (Optional) The username to use for the SOCKS5 proxy server. Can also be sourced from `PROXMOX_VE_SSH_SOCKS5_USERNAME`.
- `socks5_password` - (Optional) The password to use for the SOCKS5 proxy server. Can also be sourced from `PROXMOX_VE_SSH_SOCKS5_PASSWORD`.
Expand Down
19 changes: 16 additions & 3 deletions fwprovider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type proxmoxProviderModel struct {
SSH []struct {
Agent types.Bool `tfsdk:"agent"`
AgentSocket types.String `tfsdk:"agent_socket"`
PrivateKey types.String `tfsdk:"private_key"`
Password types.String `tfsdk:"password"`
Username types.String `tfsdk:"username"`
Socks5Server types.String `tfsdk:"socks5_server"`
Expand Down Expand Up @@ -145,8 +146,9 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
NestedObject: schema.NestedBlockObject{
Attributes: map[string]schema.Attribute{
"agent": schema.BoolAttribute{
Description: "Whether to use the SSH agent for authentication. " +
"Defaults to `false`.",
Description: "Whether to use the SSH agent for authentication. Takes precedence over " +
"the `private_key` and `password` fields. Defaults to the value of the " +
"`PROXMOX_VE_SSH_AGENT` environment variable, or `false` if not set.",
Optional: true,
},
"agent_socket": schema.StringAttribute{
Expand All @@ -155,6 +157,12 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
"environment variable.",
Optional: true,
},
"private_key": schema.StringAttribute{
Description: "The unencrypted private key (in PEM format) used for the SSH connection. " +
"Defaults to the value of the `PROXMOX_VE_SSH_PRIVATE_KEY` environment variable.",
Optional: true,
Sensitive: true,
},
"password": schema.StringAttribute{
Description: "The password used for the SSH connection. " +
"Defaults to the value of the `password` field of the " +
Expand Down Expand Up @@ -332,6 +340,7 @@ func (p *proxmoxProvider) Configure(
sshUsername := utils.GetAnyStringEnv("PROXMOX_VE_SSH_USERNAME")
sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD")
sshAgent := utils.GetAnyBoolEnv("PROXMOX_VE_SSH_AGENT")
sshPrivateKey := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PRIVATE_KEY")
sshAgentSocket := utils.GetAnyStringEnv("SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK")
sshSocks5Server := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_SERVER")
sshSocks5Username := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_USERNAME")
Expand All @@ -356,6 +365,10 @@ func (p *proxmoxProvider) Configure(
sshAgentSocket = config.SSH[0].AgentSocket.ValueString()
}

if !config.SSH[0].PrivateKey.IsNull() {
sshPrivateKey = config.SSH[0].PrivateKey.ValueString()
}

if !config.SSH[0].Socks5Server.IsNull() {
sshSocks5Server = config.SSH[0].Socks5Server.ValueString()
}
Expand Down Expand Up @@ -390,7 +403,7 @@ func (p *proxmoxProvider) Configure(
}

sshClient, err := ssh.NewClient(
sshUsername, sshPassword, sshAgent, sshAgentSocket,
sshUsername, sshPassword, sshAgent, sshAgentSocket, sshPrivateKey,
sshSocks5Server, sshSocks5Username, sshSocks5Password,
&apiResolverWithOverrides{
ar: apiResolver{c: apiClient},
Expand Down
23 changes: 16 additions & 7 deletions fwprovider/tests/resource_container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package tests
import (
"context"
"fmt"
"math/rand"
"testing"

"github.com/hashicorp/terraform-plugin-testing/helper/resource"
Expand All @@ -21,6 +22,12 @@ const (
accTestContainerCloneName = "proxmox_virtual_environment_container.test_container_clone"
)

//nolint:gochecknoglobals
var (
accTestContainerID = 100000 + rand.Intn(99999) //nolint:gosec
accCloneContainerID = 200000 + rand.Intn(99999) //nolint:gosec
)

func TestAccResourceContainer(t *testing.T) {
accProviders := testAccMuxProviders(context.Background(), t)

Expand Down Expand Up @@ -50,7 +57,7 @@ resource "proxmox_virtual_environment_download_file" "ubuntu_container_template"
}
resource "proxmox_virtual_environment_container" "test_container" {
node_name = "%s"
vm_id = 1100
vm_id = %d
template = %t
disk {
Expand Down Expand Up @@ -83,7 +90,7 @@ resource "proxmox_virtual_environment_container" "test_container" {
type = "ubuntu"
}
}
`, accTestNodeName, isTemplate)
`, accTestNodeName, accTestContainerID, isTemplate)
}

func testAccResourceContainerCreateCheck(t *testing.T) resource.TestCheckFunc {
Expand All @@ -92,8 +99,9 @@ func testAccResourceContainerCreateCheck(t *testing.T) resource.TestCheckFunc {
return resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(accTestContainerName, "description", "my\ndescription\nvalue\n"),
func(*terraform.State) error {
err := getNodesClient().Container(1100).WaitForContainerStatus(context.Background(), "running", 10, 1)
err := getNodesClient().Container(accTestContainerID).WaitForContainerStatus(context.Background(), "running", 10, 1)
require.NoError(t, err, "container did not start")

return nil
},
)
Expand All @@ -105,26 +113,27 @@ resource "proxmox_virtual_environment_container" "test_container_clone" {
depends_on = [proxmox_virtual_environment_container.test_container]
node_name = "%s"
vm_id = 1101
vm_id = %d
clone {
vm_id = 1100
vm_id = proxmox_virtual_environment_container.test_container.id
}
initialization {
hostname = "test-clone"
}
}
`, accTestNodeName)
`, accTestNodeName, accCloneContainerID)
}

func testAccResourceContainerCreateCloneCheck(t *testing.T) resource.TestCheckFunc {
t.Helper()

return resource.ComposeTestCheckFunc(
func(*terraform.State) error {
err := getNodesClient().Container(1101).WaitForContainerStatus(context.Background(), "running", 10, 1)
err := getNodesClient().Container(accCloneContainerID).WaitForContainerStatus(context.Background(), "running", 10, 1)
require.NoError(t, err, "container did not start")

return nil
},
)
Expand Down
4 changes: 2 additions & 2 deletions fwprovider/tests/resource_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ func uploadSnippetFile(t *testing.T, file *os.File) {

sshUsername := utils.GetAnyStringEnv("PROXMOX_VE_SSH_USERNAME")
sshAgentSocket := utils.GetAnyStringEnv("SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK", "PM_VE_SSH_AUTH_SOCK")

sshPrivateKey := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PRIVATE_KEY")
sshClient, err := ssh.NewClient(
sshUsername, "", true, sshAgentSocket,
sshUsername, "", true, sshAgentSocket, sshPrivateKey,
"", "", "",
&nodeResolver{
node: ssh.ProxmoxNode{
Expand Down
40 changes: 39 additions & 1 deletion proxmox/ssh/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type client struct {
password string
agent bool
agentSocket string
privateKey string
socks5Server string
socks5Username string
socks5Password string
Expand All @@ -68,6 +69,7 @@ type client struct {
func NewClient(
username string, password string,
agent bool, agentSocket string,
privateKey string,
socks5Server string, socks5Username string, socks5Password string,
nodeResolver NodeResolver,
) (Client, error) {
Expand All @@ -91,6 +93,7 @@ func NewClient(
password: password,
agent: agent,
agentSocket: agentSocket,
privateKey: privateKey,
socks5Server: socks5Server,
socks5Username: socks5Username,
socks5Password: socks5Password,
Expand Down Expand Up @@ -309,12 +312,26 @@ func (c *client) openNodeShell(ctx context.Context, node ProxmoxNode) (*ssh.Clie
return sshClient, nil
}

tflog.Error(ctx, "Failed ssh connection through agent, falling back to password authentication",
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 "+
Expand Down Expand Up @@ -374,6 +391,27 @@ func (c *client) createSSHClientAgent(
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)
Expand Down

0 comments on commit 2c6d3ad

Please sign in to comment.