0
0
mirror of https://github.com/bpg/terraform-provider-proxmox.git synced 2025-06-30 02:31:10 +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 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.

View File

@ -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,

View File

@ -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(),

View File

@ -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
}

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")
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,

View File

@ -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,