From 4cfb6bc8938b96ba2fa0faef135b342ee1b7cefc Mon Sep 17 00:00:00 2001 From: htamakos Date: Thu, 28 Apr 2022 05:59:17 +0900 Subject: [PATCH] communicator/ssh: Add support SSH over HTTP Proxy (#30274) Terraform's remote-exec provision hangs out when it execs on HTTP Proxy bacause it dosen't support SSH over HTTP Proxy. This commits enables Terraform's remote-exec to support SSH over HTTP Proxy. * adds `proxy_*` fields to `connection` which add configuration for a proxy host * if `proxy_host` set, connect to that proxy host via CONNECT method, then make the SSH connection to `host` or `bastion_host` --- internal/communicator/shared/shared.go | 20 +++ internal/communicator/ssh/communicator.go | 60 ++++++- internal/communicator/ssh/http_proxy.go | 152 ++++++++++++++++++ internal/communicator/ssh/provisioner.go | 33 +++- internal/communicator/ssh/provisioner_test.go | 46 ++++++ internal/terraform/node_resource_validate.go | 20 +++ .../resources/provisioners/connection.mdx | 12 ++ 7 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 internal/communicator/ssh/http_proxy.go diff --git a/internal/communicator/shared/shared.go b/internal/communicator/shared/shared.go index 2bc1fb5fde..5990807a78 100644 --- a/internal/communicator/shared/shared.go +++ b/internal/communicator/shared/shared.go @@ -72,6 +72,26 @@ var ConnectionBlockSupersetSchema = &configschema.Block{ Type: cty.String, Optional: true, }, + "proxy_scheme": { + Type: cty.String, + Optional: true, + }, + "proxy_host": { + Type: cty.String, + Optional: true, + }, + "proxy_port": { + Type: cty.Number, + Optional: true, + }, + "proxy_user_name": { + Type: cty.String, + Optional: true, + }, + "proxy_user_password": { + Type: cty.String, + Optional: true, + }, "bastion_host": { Type: cty.String, Optional: true, diff --git a/internal/communicator/ssh/communicator.go b/internal/communicator/ssh/communicator.go index 6dff03367c..6200f4fa59 100644 --- a/internal/communicator/ssh/communicator.go +++ b/internal/communicator/ssh/communicator.go @@ -172,6 +172,20 @@ func (c *Communicator) Connect(o provisioners.UIOutput) (err error) { } } + if c.connInfo.ProxyHost != "" { + o.Output(fmt.Sprintf( + "Using configured proxy host...\n"+ + " ProxyHost: %s\n"+ + " ProxyPort: %d\n"+ + " ProxyUserName: %s\n"+ + " ProxyUserPassword: %t", + c.connInfo.ProxyHost, + c.connInfo.ProxyPort, + c.connInfo.ProxyUserName, + c.connInfo.ProxyUserPassword != "", + )) + } + hostAndPort := fmt.Sprintf("%s:%d", c.connInfo.Host, c.connInfo.Port) log.Printf("[DEBUG] Connecting to %s for SSH", hostAndPort) c.conn, err = c.config.connection() @@ -770,9 +784,19 @@ func scpUploadDir(root string, fs []os.FileInfo, w io.Writer, r *bufio.Reader) e // ConnectFunc is a convenience method for returning a function // that just uses net.Dial to communicate with the remote end that // is suitable for use with the SSH communicator configuration. -func ConnectFunc(network, addr string) func() (net.Conn, error) { +func ConnectFunc(network, addr string, p *proxyInfo) func() (net.Conn, error) { return func() (net.Conn, error) { - c, err := net.DialTimeout(network, addr, 15*time.Second) + var c net.Conn + var err error + + // Wrap connection to host if proxy server is configured + if p != nil { + RegisterDialerType() + c, err = newHttpProxyConn(p, addr) + } else { + c, err = net.DialTimeout(network, addr, 15*time.Second) + } + if err != nil { return nil, err } @@ -792,10 +816,38 @@ func BastionConnectFunc( bAddr string, bConf *ssh.ClientConfig, proto string, - addr string) func() (net.Conn, error) { + addr string, + p *proxyInfo) func() (net.Conn, error) { return func() (net.Conn, error) { log.Printf("[DEBUG] Connecting to bastion: %s", bAddr) - bastion, err := ssh.Dial(bProto, bAddr, bConf) + var bastion *ssh.Client + var err error + + // Wrap connection to bastion server if proxy server is configured + if p != nil { + var pConn net.Conn + var bConn ssh.Conn + var bChans <-chan ssh.NewChannel + var bReq <-chan *ssh.Request + + RegisterDialerType() + pConn, err = newHttpProxyConn(p, bAddr) + + if err != nil { + return nil, fmt.Errorf("Error connecting to proxy: %s", err) + } + + bConn, bChans, bReq, err = ssh.NewClientConn(pConn, bAddr, bConf) + + if err != nil { + return nil, fmt.Errorf("Error creating new client connection via proxy: %s", err) + } + + bastion = ssh.NewClient(bConn, bChans, bReq) + } else { + bastion, err = ssh.Dial(bProto, bAddr, bConf) + } + if err != nil { return nil, fmt.Errorf("Error connecting to bastion: %s", err) } diff --git a/internal/communicator/ssh/http_proxy.go b/internal/communicator/ssh/http_proxy.go new file mode 100644 index 0000000000..883dada50a --- /dev/null +++ b/internal/communicator/ssh/http_proxy.go @@ -0,0 +1,152 @@ +package ssh + +import ( + "bufio" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "golang.org/x/net/proxy" +) + +// Dialer implements for SSH over HTTP Proxy. +type proxyDialer struct { + proxy proxyInfo + // forwarding Dialer + forward proxy.Dialer +} + +type proxyInfo struct { + // HTTP Proxy host or host:port + host string + // HTTP Proxy scheme + scheme string + // An immutable encapsulation of username and password details for a URL + userInfo *url.Userinfo +} + +func newProxyInfo(host, scheme, username, password string) *proxyInfo { + p := &proxyInfo{ + host: host, + scheme: scheme, + } + + p.userInfo = url.UserPassword(username, password) + + if p.scheme == "" { + p.scheme = "http" + } + + return p +} + +func (p *proxyInfo) url() *url.URL { + return &url.URL{ + Scheme: p.scheme, + User: p.userInfo, + Host: p.host, + } +} + +func (p *proxyDialer) Dial(network, addr string) (net.Conn, error) { + // Dial the proxy host + c, err := p.forward.Dial(network, p.proxy.host) + + if err != nil { + return nil, err + } + + err = c.SetDeadline(time.Now().Add(15 * time.Second)) + if err != nil { + return nil, err + } + + // Generate request URL to host accessed through the proxy + reqUrl := &url.URL{ + Scheme: "", + Host: addr, + } + + // Create a request object using the CONNECT method to instruct the proxy server to tunnel a protocol other than HTTP. + req, err := http.NewRequest("CONNECT", reqUrl.String(), nil) + if err != nil { + c.Close() + return nil, err + } + + // If http proxy requires authentication, configure settings for basic authentication. + if p.proxy.userInfo.String() != "" { + username := p.proxy.userInfo.Username() + password, _ := p.proxy.userInfo.Password() + req.SetBasicAuth(username, password) + req.Header.Add("Proxy-Authorization", req.Header.Get("Authorization")) + } + + // Do not close the connection after sending this request and reading its response. + req.Close = false + + // Writes the request in the form expected by an HTTP proxy. + err = req.Write(c) + if err != nil { + c.Close() + return nil, err + } + + res, err := http.ReadResponse(bufio.NewReader(c), req) + + if err != nil { + res.Body.Close() + c.Close() + return nil, err + } + + res.Body.Close() + + if res.StatusCode != http.StatusOK { + c.Close() + return nil, fmt.Errorf("Connection Error: StatusCode: %d", res.StatusCode) + } + + return c, nil +} + +// NewHttpProxyDialer generate Http Proxy Dialer +func newHttpProxyDialer(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) { + var proxyUserName, proxyPassword string + if u.User != nil { + proxyUserName = u.User.Username() + proxyPassword, _ = u.User.Password() + } + + pd := &proxyDialer{ + proxy: *newProxyInfo(u.Host, u.Scheme, proxyUserName, proxyPassword), + forward: forward, + } + + return pd, nil +} + +// RegisterDialerType register schemes used by `proxy.FromURL` +func RegisterDialerType() { + proxy.RegisterDialerType("http", newHttpProxyDialer) + proxy.RegisterDialerType("https", newHttpProxyDialer) +} + +// NewHttpProxyConn create a connection to connect through the proxy server. +func newHttpProxyConn(p *proxyInfo, targetAddr string) (net.Conn, error) { + pd, err := proxy.FromURL(p.url(), proxy.Direct) + + if err != nil { + return nil, err + } + + proxyConn, err := pd.Dial("tcp", targetAddr) + + if err != nil { + return nil, err + } + + return proxyConn, err +} diff --git a/internal/communicator/ssh/provisioner.go b/internal/communicator/ssh/provisioner.go index 438ba93e24..b98ee9f5d2 100644 --- a/internal/communicator/ssh/provisioner.go +++ b/internal/communicator/ssh/provisioner.go @@ -62,6 +62,12 @@ type connectionInfo struct { Timeout string TimeoutVal time.Duration + ProxyScheme string + ProxyHost string + ProxyPort uint16 + ProxyUserName string + ProxyUserPassword string + BastionUser string BastionPassword string BastionPrivateKey string @@ -112,6 +118,18 @@ func decodeConnInfo(v cty.Value) (*connectionInfo, error) { connInfo.TargetPlatform = v.AsString() case "timeout": connInfo.Timeout = v.AsString() + case "proxy_scheme": + connInfo.ProxyScheme = v.AsString() + case "proxy_host": + connInfo.ProxyHost = v.AsString() + case "proxy_port": + if err := gocty.FromCtyValue(v, &connInfo.ProxyPort); err != nil { + return nil, err + } + case "proxy_user_name": + connInfo.ProxyUserName = v.AsString() + case "proxy_user_password": + connInfo.ProxyUserPassword = v.AsString() case "bastion_user": connInfo.BastionUser = v.AsString() case "bastion_password": @@ -254,7 +272,18 @@ func prepareSSHConfig(connInfo *connectionInfo) (*sshConfig, error) { return nil, err } - connectFunc := ConnectFunc("tcp", host) + var p *proxyInfo + + if connInfo.ProxyHost != "" { + p = newProxyInfo( + fmt.Sprintf("%s:%d", connInfo.ProxyHost, connInfo.ProxyPort), + connInfo.ProxyScheme, + connInfo.ProxyUserName, + connInfo.ProxyUserPassword, + ) + } + + connectFunc := ConnectFunc("tcp", host, p) var bastionConf *ssh.ClientConfig if connInfo.BastionHost != "" { @@ -273,7 +302,7 @@ func prepareSSHConfig(connInfo *connectionInfo) (*sshConfig, error) { return nil, err } - connectFunc = BastionConnectFunc("tcp", bastionHost, bastionConf, "tcp", host) + connectFunc = BastionConnectFunc("tcp", bastionHost, bastionConf, "tcp", host, p) } config := &sshConfig{ diff --git a/internal/communicator/ssh/provisioner_test.go b/internal/communicator/ssh/provisioner_test.go index 05d8179c52..1ee0cf8aa3 100644 --- a/internal/communicator/ssh/provisioner_test.go +++ b/internal/communicator/ssh/provisioner_test.go @@ -137,6 +137,52 @@ func TestProvisioner_connInfoEmptyHostname(t *testing.T) { } } +func TestProvisioner_connInfoProxy(t *testing.T) { + v := cty.ObjectVal(map[string]cty.Value{ + "type": cty.StringVal("ssh"), + "user": cty.StringVal("root"), + "password": cty.StringVal("supersecret"), + "private_key": cty.StringVal("someprivatekeycontents"), + "host": cty.StringVal("example.com"), + "port": cty.StringVal("22"), + "timeout": cty.StringVal("30s"), + "proxy_scheme": cty.StringVal("http"), + "proxy_host": cty.StringVal("proxy.example.com"), + "proxy_port": cty.StringVal("80"), + "proxy_user_name": cty.StringVal("proxyuser"), + "proxy_user_password": cty.StringVal("proxyuser_password"), + }) + + conf, err := parseConnectionInfo(v) + if err != nil { + t.Fatalf("err: %v", err) + } + + if conf.Host != "example.com" { + t.Fatalf("bad: %v", conf) + } + + if conf.ProxyScheme != "http" { + t.Fatalf("bad: %v", conf) + } + + if conf.ProxyHost != "proxy.example.com" { + t.Fatalf("bad: %v", conf) + } + + if conf.ProxyPort != 80 { + t.Fatalf("bad: %v", conf) + } + + if conf.ProxyUserName != "proxyuser" { + t.Fatalf("bad: %v", conf) + } + + if conf.ProxyUserPassword != "proxyuser_password" { + t.Fatalf("bad: %v", conf) + } +} + func TestProvisioner_stringBastionPort(t *testing.T) { v := cty.ObjectVal(map[string]cty.Value{ "type": cty.StringVal("ssh"), diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index 867cfc2572..a70bcdc4bc 100644 --- a/internal/terraform/node_resource_validate.go +++ b/internal/terraform/node_resource_validate.go @@ -191,6 +191,26 @@ var connectionBlockSupersetSchema = &configschema.Block{ Type: cty.String, Optional: true, }, + "proxy_scheme": { + Type: cty.String, + Optional: true, + }, + "proxy_host": { + Type: cty.String, + Optional: true, + }, + "proxy_port": { + Type: cty.Number, + Optional: true, + }, + "proxy_user_name": { + Type: cty.String, + Optional: true, + }, + "proxy_user_password": { + Type: cty.String, + Optional: true, + }, "bastion_host": { Type: cty.String, Optional: true, diff --git a/website/docs/language/resources/provisioners/connection.mdx b/website/docs/language/resources/provisioners/connection.mdx index 8821599619..655f617e3d 100644 --- a/website/docs/language/resources/provisioners/connection.mdx +++ b/website/docs/language/resources/provisioners/connection.mdx @@ -113,6 +113,18 @@ indirectly with a [bastion host](https://en.wikipedia.org/wiki/Bastion_host). | `bastion_private_key` | The contents of an SSH key file to use for the bastion host. These can be loaded from a file on disk using [the `file` function](/language/functions/file). | The value of the `private_key` field. | | `bastion_certificate` | The contents of a signed CA Certificate. The certificate argument must be used in conjunction with a `bastion_private_key`. These can be loaded from a file on disk using the [the `file` function](/language/functions/file). | +## Connection through a HTTP Proxy with SSH + +The `ssh` connection also supports the following fields to facilitate connections by SSH over HTTP proxy. + +| Argument | Description | Default | +|---------------|-------------|---------| +| `proxy_scheme` | http or https | | +| `proxy_host` | Setting this enables the SSH over HTTP connection. This host will be connected to first, and then the `host` or `bastion_host` connection will be made from there. | | +| `proxy_port` | The port to use connect to the proxy host. | | +| `proxy_user_name` | The username to use connect to the private proxy host. This argument should be specified only if authentication is required for the HTTP Proxy server. | | +| `proxy_user_password` | The password to use connect to the private proxy host. This argument should be specified only if authentication is required for the HTTP Proxy server. | | + ## How Provisioners Execute Remote Scripts Provisioners which execute commands on a remote system via a protocol such as