0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-07-05 13:33:58 +00:00

feat(provider): add SOCKS5 proxy support for SSH connections (#970)

* feat(provider): add support for SOCKS5 proxy for SSH connection.

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

* fix: linter

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

---------

Signed-off-by: Pavel Boldyrev <627562+bpg@users.noreply.github.com>
This commit is contained in:
Pavel Boldyrev 2024-01-27 15:09:14 -05:00 committed by GitHub
parent 14836b6c50
commit da1d7804af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 230 additions and 62 deletions

View File

@ -213,9 +213,27 @@ provider "proxmox" {
} }
} }
} }
``` ```
### SSH Connection via SOCKS5 Proxy
The provider supports SSH connection to the target node via a SOCKS5 proxy.
To enable the SOCKS5 proxy, you need to configure the `ssh` block in the `provider` block, and specify the `socks5_server` argument:
```terraform
provider "proxmox" {
// ...
ssh {
// ...
socks5_server = "ip-or-fqdn-of-socks5-server:port"
socks5_username = "username" # optional
socks5_password = "password" # optional
}
}
If enabled, this method will be used for all SSH connections to the target nodes in the cluster.
## API Token Authentication ## API Token Authentication
API Token authentication can be used to authenticate with the Proxmox API without the need to provide a password. API Token authentication can be used to authenticate with the Proxmox API without the need to provide a password.
@ -296,6 +314,9 @@ 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`. - `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` - (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`. - `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`.
- `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`.
- `node` - (Optional) The node configuration for the SSH connection. Can be specified multiple times to provide configuration fo multiple nodes. - `node` - (Optional) The node configuration for the SSH connection. Can be specified multiple times to provide configuration fo multiple nodes.
- `name` - (Required) The name of the node. - `name` - (Required) The name of the node.
- `address` - (Required) The FQDN/IP address of the node. - `address` - (Required) The FQDN/IP address of the node.

View File

@ -61,10 +61,13 @@ type proxmoxProviderModel struct {
Username types.String `tfsdk:"username"` Username types.String `tfsdk:"username"`
Password types.String `tfsdk:"password"` Password types.String `tfsdk:"password"`
SSH []struct { SSH []struct {
Agent types.Bool `tfsdk:"agent"` Agent types.Bool `tfsdk:"agent"`
AgentSocket types.String `tfsdk:"agent_socket"` AgentSocket types.String `tfsdk:"agent_socket"`
Password types.String `tfsdk:"password"` Password types.String `tfsdk:"password"`
Username types.String `tfsdk:"username"` Username types.String `tfsdk:"username"`
Socks5Server types.String `tfsdk:"socks5_server"`
Socks5Username types.String `tfsdk:"socks5_username"`
Socks5Password types.String `tfsdk:"socks5_password"`
Nodes []struct { Nodes []struct {
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
@ -165,6 +168,22 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re
"`provider` block.", "`provider` block.",
Optional: true, Optional: true,
}, },
"socks5_server": schema.StringAttribute{
Description: "The address:port of the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_SERVER` environment variable.",
Optional: true,
},
"socks5_username": schema.StringAttribute{
Description: "The username for the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_USERNAME` environment variable.",
Optional: true,
},
"socks5_password": schema.StringAttribute{
Description: "The password for the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_PASSWORD` environment variable.",
Optional: true,
Sensitive: true,
},
}, },
Blocks: map[string]schema.Block{ Blocks: map[string]schema.Block{
"node": schema.ListNestedBlock{ "node": schema.ListNestedBlock{
@ -314,6 +333,9 @@ func (p *proxmoxProvider) Configure(
sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD") sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD")
sshAgent := utils.GetAnyBoolEnv("PROXMOX_VE_SSH_AGENT") sshAgent := utils.GetAnyBoolEnv("PROXMOX_VE_SSH_AGENT")
sshAgentSocket := utils.GetAnyStringEnv("SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK") 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")
sshSocks5Password := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_PASSWORD")
nodeOverrides := map[string]ssh.ProxmoxNode{} nodeOverrides := map[string]ssh.ProxmoxNode{}
//nolint: nestif //nolint: nestif
@ -334,6 +356,18 @@ func (p *proxmoxProvider) Configure(
sshAgentSocket = config.SSH[0].AgentSocket.ValueString() sshAgentSocket = config.SSH[0].AgentSocket.ValueString()
} }
if !config.SSH[0].Socks5Server.IsNull() {
sshSocks5Server = config.SSH[0].Socks5Server.ValueString()
}
if !config.SSH[0].Socks5Username.IsNull() {
sshSocks5Username = config.SSH[0].Socks5Username.ValueString()
}
if !config.SSH[0].Socks5Password.IsNull() {
sshSocks5Password = config.SSH[0].Socks5Password.ValueString()
}
for _, n := range config.SSH[0].Nodes { for _, n := range config.SSH[0].Nodes {
nodePort := int32(n.Port.ValueInt64()) nodePort := int32(n.Port.ValueInt64())
if nodePort == 0 { if nodePort == 0 {
@ -357,6 +391,7 @@ func (p *proxmoxProvider) Configure(
sshClient, err := ssh.NewClient( sshClient, err := ssh.NewClient(
sshUsername, sshPassword, sshAgent, sshAgentSocket, sshUsername, sshPassword, sshAgent, sshAgentSocket,
sshSocks5Server, sshSocks5Username, sshSocks5Password,
&apiResolverWithOverrides{ &apiResolverWithOverrides{
ar: apiResolver{c: apiClient}, ar: apiResolver{c: apiClient},
overrides: nodeOverrides, overrides: nodeOverrides,

View File

@ -129,6 +129,7 @@ func uploadSnippetFile(t *testing.T, file *os.File) {
sshClient, err := ssh.NewClient( sshClient, err := ssh.NewClient(
sshUsername, "", true, sshAgentSocket, sshUsername, "", true, sshAgentSocket,
"", "", "",
&nodeResolver{ &nodeResolver{
node: ssh.ProxmoxNode{ node: ssh.ProxmoxNode{
Address: u.Hostname(), Address: u.Hostname(),

View File

@ -22,6 +22,7 @@ import (
"github.com/skeema/knownhosts" "github.com/skeema/knownhosts"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent" "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/proxmox/api"
"github.com/bpg/terraform-provider-proxmox/utils" "github.com/bpg/terraform-provider-proxmox/utils"
@ -41,17 +42,21 @@ type Client interface {
} }
type client struct { type client struct {
username string username string
password string password string
agent bool agent bool
agentSocket string agentSocket string
nodeResolver NodeResolver socks5Server string
socks5Username string
socks5Password string
nodeResolver NodeResolver
} }
// NewClient creates a new SSH client. // NewClient creates a new SSH client.
func NewClient( func NewClient(
username string, password string, username string, password string,
agent bool, agentSocket string, agent bool, agentSocket string,
socks5Server string, socks5Username string, socks5Password string,
nodeResolver NodeResolver, nodeResolver NodeResolver,
) (Client, error) { ) (Client, error) {
if agent && runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "freebsd" { if agent && runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "freebsd" {
@ -61,16 +66,23 @@ func NewClient(
) )
} }
if (socks5Username != "" || socks5Password != "") && socks5Server == "" {
return nil, errors.New("socks5 server is required when socks5 username or password is set")
}
if nodeResolver == nil { if nodeResolver == nil {
return nil, errors.New("node resolver is required") return nil, errors.New("node resolver is required")
} }
return &client{ return &client{
username: username, username: username,
password: password, password: password,
agent: agent, agent: agent,
agentSocket: agentSocket, agentSocket: agentSocket,
nodeResolver: nodeResolver, socks5Server: socks5Server,
socks5Username: socks5Username,
socks5Password: socks5Password,
nodeResolver: nodeResolver,
}, nil }, nil
} }
@ -267,6 +279,41 @@ func (c *client) openNodeShell(ctx context.Context, node ProxmoxNode) (*ssh.Clie
return kherr 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, falling back to password authentication",
map[string]interface{}{
"error": err,
})
}
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{ sshConfig := &ssh.ClientConfig{
User: c.username, User: c.username,
Auth: []ssh.AuthMethod{ssh.Password(c.password)}, Auth: []ssh.AuthMethod{ssh.Password(c.password)},
@ -274,39 +321,7 @@ func (c *client) openNodeShell(ctx context.Context, node ProxmoxNode) (*ssh.Clie
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost), HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost),
} }
tflog.Info(ctx, fmt.Sprintf("agent is set to %t", c.agent)) return c.connect(ctx, sshHost, sshConfig)
var sshClient *ssh.Client
if c.agent {
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 {
if c.password == "" {
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 golang's ssh implementation). The exact error from ssh.Dial: %w", c.username, sshHost, err)
}
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
} }
// createSSHClientAgent establishes an ssh connection through the agent authentication mechanism. // createSSHClientAgent establishes an ssh connection through the agent authentication mechanism.
@ -335,6 +350,25 @@ func (c *client) createSSHClientAgent(
HostKeyAlgorithms: kh.HostKeyAlgorithms(sshHost), 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) sshClient, err := ssh.Dial("tcp", sshHost, sshConfig)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to dial %s: %w", sshHost, err) return nil, fmt.Errorf("failed to dial %s: %w", sshHost, err)
@ -347,3 +381,25 @@ func (c *client) createSSHClientAgent(
return sshClient, nil 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, chans, 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, chans, reqs), nil
}

View File

@ -111,6 +111,9 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD", "PM_VE_SSH_PASSWORD") sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD", "PM_VE_SSH_PASSWORD")
sshAgent := utils.GetAnyBoolEnv("PROXMOX_VE_SSH_AGENT", "PM_VE_SSH_AGENT") sshAgent := utils.GetAnyBoolEnv("PROXMOX_VE_SSH_AGENT", "PM_VE_SSH_AGENT")
sshAgentSocket := utils.GetAnyStringEnv("SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK", "PM_VE_SSH_AUTH_SOCK") sshAgentSocket := utils.GetAnyStringEnv("SSH_AUTH_SOCK", "PROXMOX_VE_SSH_AUTH_SOCK", "PM_VE_SSH_AUTH_SOCK")
sshSocks5Server := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_SERVER")
sshSocks5Username := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_USERNAME")
sshSocks5Password := utils.GetAnyStringEnv("PROXMOX_VE_SSH_SOCKS5_PASSWORD")
if v, ok := sshConf[mkProviderSSHUsername]; !ok || v.(string) == "" { if v, ok := sshConf[mkProviderSSHUsername]; !ok || v.(string) == "" {
if sshUsername != "" { if sshUsername != "" {
@ -136,6 +139,18 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
sshConf[mkProviderSSHAgentSocket] = sshAgentSocket sshConf[mkProviderSSHAgentSocket] = sshAgentSocket
} }
if _, ok := sshConf[mkProviderSSHSocks5Server]; !ok {
sshConf[mkProviderSSHSocks5Server] = sshSocks5Server
}
if _, ok := sshConf[mkProviderSSHSocks5Username]; !ok {
sshConf[mkProviderSSHSocks5Username] = sshSocks5Username
}
if _, ok := sshConf[mkProviderSSHSocks5Password]; !ok {
sshConf[mkProviderSSHSocks5Password] = sshSocks5Password
}
nodeOverrides := map[string]ssh.ProxmoxNode{} nodeOverrides := map[string]ssh.ProxmoxNode{}
if ns, ok := sshConf[mkProviderSSHNode]; ok { if ns, ok := sshConf[mkProviderSSHNode]; ok {
@ -153,6 +168,9 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{},
sshConf[mkProviderSSHPassword].(string), sshConf[mkProviderSSHPassword].(string),
sshConf[mkProviderSSHAgent].(bool), sshConf[mkProviderSSHAgent].(bool),
sshConf[mkProviderSSHAgentSocket].(string), sshConf[mkProviderSSHAgentSocket].(string),
sshConf[mkProviderSSHSocks5Server].(string),
sshConf[mkProviderSSHSocks5Username].(string),
sshConf[mkProviderSSHSocks5Password].(string),
&apiResolverWithOverrides{ &apiResolverWithOverrides{
ar: apiResolver{c: apiClient}, ar: apiResolver{c: apiClient},
overrides: nodeOverrides, overrides: nodeOverrides,

View File

@ -15,20 +15,23 @@ import (
) )
const ( const (
dvProviderOTP = "" dvProviderOTP = ""
mkProviderEndpoint = "endpoint" mkProviderEndpoint = "endpoint"
mkProviderInsecure = "insecure" mkProviderInsecure = "insecure"
mkProviderMinTLS = "min_tls" mkProviderMinTLS = "min_tls"
mkProviderOTP = "otp" mkProviderOTP = "otp"
mkProviderPassword = "password" mkProviderPassword = "password"
mkProviderUsername = "username" mkProviderUsername = "username"
mkProviderAPIToken = "api_token" mkProviderAPIToken = "api_token"
mkProviderTmpDir = "tmp_dir" mkProviderTmpDir = "tmp_dir"
mkProviderSSH = "ssh" mkProviderSSH = "ssh"
mkProviderSSHUsername = "username" mkProviderSSHUsername = "username"
mkProviderSSHPassword = "password" mkProviderSSHPassword = "password"
mkProviderSSHAgent = "agent" mkProviderSSHAgent = "agent"
mkProviderSSHAgentSocket = "agent_socket" mkProviderSSHAgentSocket = "agent_socket"
mkProviderSSHSocks5Server = "socks5_server"
mkProviderSSHSocks5Username = "socks5_username"
mkProviderSSHSocks5Password = "socks5_password"
mkProviderSSHNode = "node" mkProviderSSHNode = "node"
mkProviderSSHNodeName = "name" mkProviderSSHNodeName = "name"
@ -145,6 +148,40 @@ func createSchema() map[string]*schema.Schema {
), ),
ValidateFunc: validation.StringIsNotEmpty, ValidateFunc: validation.StringIsNotEmpty,
}, },
mkProviderSSHSocks5Server: {
Type: schema.TypeString,
Optional: true,
Description: "The address:port of the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_SERVER` environment variable.",
DefaultFunc: schema.MultiEnvDefaultFunc(
[]string{"PROXMOX_VE_SSH_SOCKS5_SERVER"},
nil,
),
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderSSHSocks5Username: {
Type: schema.TypeString,
Optional: true,
Description: "The username for the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_USERNAME` environment variable.",
DefaultFunc: schema.MultiEnvDefaultFunc(
[]string{"PROXMOX_VE_SSH_SOCKS5_USERNAME"},
nil,
),
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderSSHSocks5Password: {
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: "The password for the SOCKS5 proxy server. " +
"Defaults to the value of the `PROXMOX_VE_SSH_SOCKS5_PASSWORD` environment variable.",
DefaultFunc: schema.MultiEnvDefaultFunc(
[]string{"PROXMOX_VE_SSH_SOCKS5_PASSWORD"},
nil,
),
ValidateFunc: validation.StringIsNotEmpty,
},
mkProviderSSHNode: { mkProviderSSHNode: {
Type: schema.TypeList, Type: schema.TypeList,
Optional: true, Optional: true,