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`
This commit is contained in:
htamakos 2022-04-28 05:59:17 +09:00 committed by GitHub
parent de65cc43f3
commit 4cfb6bc893
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 337 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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