From da1d7804af6b2ad6d6a1d698e52d19de3c1d5cb6 Mon Sep 17 00:00:00 2001 From: Pavel Boldyrev <627562+bpg@users.noreply.github.com> Date: Sat, 27 Jan 2024 15:09:14 -0500 Subject: [PATCH] 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> --- docs/index.md | 23 +++- fwprovider/provider.go | 43 +++++++- fwprovider/tests/resource_file_test.go | 1 + proxmox/ssh/client.go | 142 +++++++++++++++++-------- proxmoxtf/provider/provider.go | 18 ++++ proxmoxtf/provider/schema.go | 65 ++++++++--- 6 files changed, 230 insertions(+), 62 deletions(-) diff --git a/docs/index.md b/docs/index.md index e41e1a82..72c36dfe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 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`. - `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`. + - `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. - `name` - (Required) The name of the node. - `address` - (Required) The FQDN/IP address of the node. diff --git a/fwprovider/provider.go b/fwprovider/provider.go index 659b6c1f..106789fc 100644 --- a/fwprovider/provider.go +++ b/fwprovider/provider.go @@ -61,10 +61,13 @@ type proxmoxProviderModel struct { Username types.String `tfsdk:"username"` Password types.String `tfsdk:"password"` SSH []struct { - Agent types.Bool `tfsdk:"agent"` - AgentSocket types.String `tfsdk:"agent_socket"` - Password types.String `tfsdk:"password"` - Username types.String `tfsdk:"username"` + Agent types.Bool `tfsdk:"agent"` + AgentSocket types.String `tfsdk:"agent_socket"` + Password types.String `tfsdk:"password"` + 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 { Name types.String `tfsdk:"name"` @@ -165,6 +168,22 @@ func (p *proxmoxProvider) Schema(_ context.Context, _ provider.SchemaRequest, re "`provider` block.", 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{ "node": schema.ListNestedBlock{ @@ -314,6 +333,9 @@ func (p *proxmoxProvider) Configure( sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD") sshAgent := utils.GetAnyBoolEnv("PROXMOX_VE_SSH_AGENT") 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{} //nolint: nestif @@ -334,6 +356,18 @@ func (p *proxmoxProvider) Configure( 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 { nodePort := int32(n.Port.ValueInt64()) if nodePort == 0 { @@ -357,6 +391,7 @@ func (p *proxmoxProvider) Configure( sshClient, err := ssh.NewClient( sshUsername, sshPassword, sshAgent, sshAgentSocket, + sshSocks5Server, sshSocks5Username, sshSocks5Password, &apiResolverWithOverrides{ ar: apiResolver{c: apiClient}, overrides: nodeOverrides, diff --git a/fwprovider/tests/resource_file_test.go b/fwprovider/tests/resource_file_test.go index 1bbf9369..466b201b 100644 --- a/fwprovider/tests/resource_file_test.go +++ b/fwprovider/tests/resource_file_test.go @@ -129,6 +129,7 @@ func uploadSnippetFile(t *testing.T, file *os.File) { sshClient, err := ssh.NewClient( sshUsername, "", true, sshAgentSocket, + "", "", "", &nodeResolver{ node: ssh.ProxmoxNode{ Address: u.Hostname(), diff --git a/proxmox/ssh/client.go b/proxmox/ssh/client.go index 1722196c..30f7adc3 100644 --- a/proxmox/ssh/client.go +++ b/proxmox/ssh/client.go @@ -22,6 +22,7 @@ import ( "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" @@ -41,17 +42,21 @@ type Client interface { } type client struct { - username string - password string - agent bool - agentSocket string - nodeResolver NodeResolver + username string + password string + agent bool + agentSocket 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, + socks5Server string, socks5Username string, socks5Password string, nodeResolver NodeResolver, ) (Client, error) { 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 { return nil, errors.New("node resolver is required") } return &client{ - username: username, - password: password, - agent: agent, - agentSocket: agentSocket, - nodeResolver: nodeResolver, + username: username, + password: password, + agent: agent, + agentSocket: agentSocket, + socks5Server: socks5Server, + socks5Username: socks5Username, + socks5Password: socks5Password, + nodeResolver: nodeResolver, }, nil } @@ -267,6 +279,41 @@ func (c *client) openNodeShell(ctx context.Context, node ProxmoxNode) (*ssh.Clie 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{ User: c.username, 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), } - 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 { - 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 + return c.connect(ctx, sshHost, sshConfig) } // createSSHClientAgent establishes an ssh connection through the agent authentication mechanism. @@ -335,6 +350,25 @@ func (c *client) createSSHClientAgent( 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) @@ -347,3 +381,25 @@ func (c *client) createSSHClientAgent( 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 +} diff --git a/proxmoxtf/provider/provider.go b/proxmoxtf/provider/provider.go index 29ecfe29..c15e3d99 100644 --- a/proxmoxtf/provider/provider.go +++ b/proxmoxtf/provider/provider.go @@ -111,6 +111,9 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{}, sshPassword := utils.GetAnyStringEnv("PROXMOX_VE_SSH_PASSWORD", "PM_VE_SSH_PASSWORD") 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") + 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 sshUsername != "" { @@ -136,6 +139,18 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{}, 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{} if ns, ok := sshConf[mkProviderSSHNode]; ok { @@ -153,6 +168,9 @@ func providerConfigure(_ context.Context, d *schema.ResourceData) (interface{}, sshConf[mkProviderSSHPassword].(string), sshConf[mkProviderSSHAgent].(bool), sshConf[mkProviderSSHAgentSocket].(string), + sshConf[mkProviderSSHSocks5Server].(string), + sshConf[mkProviderSSHSocks5Username].(string), + sshConf[mkProviderSSHSocks5Password].(string), &apiResolverWithOverrides{ ar: apiResolver{c: apiClient}, overrides: nodeOverrides, diff --git a/proxmoxtf/provider/schema.go b/proxmoxtf/provider/schema.go index 7936b038..94fc4d60 100644 --- a/proxmoxtf/provider/schema.go +++ b/proxmoxtf/provider/schema.go @@ -15,20 +15,23 @@ import ( ) const ( - dvProviderOTP = "" - mkProviderEndpoint = "endpoint" - mkProviderInsecure = "insecure" - mkProviderMinTLS = "min_tls" - mkProviderOTP = "otp" - mkProviderPassword = "password" - mkProviderUsername = "username" - mkProviderAPIToken = "api_token" - mkProviderTmpDir = "tmp_dir" - mkProviderSSH = "ssh" - mkProviderSSHUsername = "username" - mkProviderSSHPassword = "password" - mkProviderSSHAgent = "agent" - mkProviderSSHAgentSocket = "agent_socket" + dvProviderOTP = "" + mkProviderEndpoint = "endpoint" + mkProviderInsecure = "insecure" + mkProviderMinTLS = "min_tls" + mkProviderOTP = "otp" + mkProviderPassword = "password" + mkProviderUsername = "username" + mkProviderAPIToken = "api_token" + mkProviderTmpDir = "tmp_dir" + mkProviderSSH = "ssh" + mkProviderSSHUsername = "username" + mkProviderSSHPassword = "password" + mkProviderSSHAgent = "agent" + mkProviderSSHAgentSocket = "agent_socket" + mkProviderSSHSocks5Server = "socks5_server" + mkProviderSSHSocks5Username = "socks5_username" + mkProviderSSHSocks5Password = "socks5_password" mkProviderSSHNode = "node" mkProviderSSHNodeName = "name" @@ -145,6 +148,40 @@ func createSchema() map[string]*schema.Schema { ), 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: { Type: schema.TypeList, Optional: true,