diff --git a/communicator/ssh/communicator.go b/communicator/ssh/communicator.go index be2ef1fd5..d2f2eaf6b 100644 --- a/communicator/ssh/communicator.go +++ b/communicator/ssh/communicator.go @@ -35,6 +35,24 @@ type comm struct { address string } +// TunnelDirection is the supported tunnel directions +type TunnelDirection int + +const ( + UnsetTunnel TunnelDirection = iota + RemoteTunnel + LocalTunnel +) + +// TunnelSpec represents a request to map a port on one side of the SSH connection to the other +type TunnelSpec struct { + Direction TunnelDirection + ListenType string + ListenAddr string + ForwardType string + ForwardAddr string +} + // Config is the structure used to configure the SSH communicator. type Config struct { // The configuration of the Go SSH connection @@ -64,6 +82,8 @@ type Config struct { // Timeout is how long to wait for a read or write to succeed. Timeout time.Duration + + Tunnels []TunnelSpec } // Creates a new packer.Communicator implementation over SSH. This takes @@ -344,10 +364,76 @@ func (c *comm) reconnect() (err error) { c.client = ssh.NewClient(sshConn, sshChan, req) } c.connectToAgent() + err = c.connectTunnels(sshConn) + if err != nil { + return + } return } +func (c *comm) connectTunnels(sshConn ssh.Conn) (err error) { + if c.client == nil { + return + } + + if len(c.config.Tunnels) == 0 { + // No Tunnels to configure + return + } + + // Start remote forwards of ports to ourselves. + log.Printf("[DEBUG] Tunnel configuration: %v", c.config.Tunnels) + for _, v := range c.config.Tunnels { + done := make(chan struct{}) + var listener net.Listener + switch v.Direction { + case RemoteTunnel: + // This requests the sshd Host to bind a port and send traffic back to us + listener, err = c.client.Listen(v.ListenType, v.ListenAddr) + if err != nil { + err = fmt.Errorf("Tunnel: Failed to bind remote ('%v'): %s", v, err) + return + } + log.Printf("[INFO] Tunnel: Remote bound on %s forwarding to %s", v.ListenAddr, v.ForwardAddr) + connectFunc := ConnectFunc(v.ForwardType, v.ForwardAddr) + go ProxyServe(listener, done, connectFunc) + // Wait for our sshConn to be shutdown + // FIXME: Is there a better "on-shutdown" we can wait on? + go shutdownProxyTunnel(sshConn, done, listener) + case LocalTunnel: + // This binds locally and sends traffic back to the sshd host + listener, err = net.Listen(v.ListenType, v.ListenAddr) + if err != nil { + err = fmt.Errorf("Tunnel: Failed to bind local ('%v'): %s", v, err) + return + } + log.Printf("[INFO] Tunnel: Local bound on %s forwarding to %s", v.ListenAddr, v.ForwardAddr) + connectFunc := func() (net.Conn, error) { + // This Dial occurs on the SSH server's side + return c.client.Dial(v.ForwardType, v.ForwardAddr) + } + go ProxyServe(listener, done, connectFunc) + // FIXME: Is there a better "on-shutdown" we can wait on? + go shutdownProxyTunnel(sshConn, done, listener) + default: + err = fmt.Errorf("Tunnel: Unknown tunnel direction ('%v'): %v", v, v.Direction) + return + } + } + + return +} + +// shutdownProxyTunnel waits for our sshConn to be shutdown and closes the listeners +func shutdownProxyTunnel(sshConn ssh.Conn, done chan struct{}, listener net.Listener) { + sshConn.Wait() + log.Printf("[INFO] Tunnel: Shutting down listener %v", listener) + done <- struct{}{} + close(done) + listener.Close() +} + func (c *comm) connectToAgent() { if c.client == nil { return diff --git a/communicator/ssh/tunnel.go b/communicator/ssh/tunnel.go new file mode 100644 index 000000000..c99a1d1d8 --- /dev/null +++ b/communicator/ssh/tunnel.go @@ -0,0 +1,71 @@ +package ssh + +import ( + "io" + "log" + "net" +) + +// ProxyServe starts Accepting connections +func ProxyServe(l net.Listener, done <-chan struct{}, dialer func() (net.Conn, error)) { + for { + // Accept will return if either the underlying connection is closed or if a connection is made. + // after returning, check to see if c.done can be received. If so, then Accept() returned because + // the connection has been closed. + client, err := l.Accept() + select { + case <-done: + log.Printf("[WARN] Tunnel: received Done event: %v", err) + return + default: + if err != nil { + log.Printf("[ERROR] Tunnel: listen.Accept failed: %v", err) + continue + } + log.Printf("[DEBUG] Tunnel: client '%s' accepted", client.RemoteAddr()) + // Proxy bytes from one side to the other + go handleProxyClient(client, dialer) + } + } +} + +// handleProxyClient will open a connection using the dialer, and ensure close events propagate to the brokers +func handleProxyClient(clientConn net.Conn, dialer func() (net.Conn, error)) { + //We have a client connected, open an upstream connection to the destination + upstreamConn, err := dialer() + if err != nil { + log.Printf("[ERROR] Tunnel: failed to open connection to upstream: %v", err) + clientConn.Close() + return + } + + // channels to wait on the close event for each connection + serverClosed := make(chan struct{}, 1) + upstreamClosed := make(chan struct{}, 1) + + go brokerData(clientConn, upstreamConn, upstreamClosed) + go brokerData(upstreamConn, clientConn, serverClosed) + + // Now we wait for the connections to close and notify the other side of the event + select { + case <-upstreamClosed: + clientConn.Close() + <-serverClosed + case <-serverClosed: + upstreamConn.Close() + <-upstreamClosed + } + log.Printf("[DEBUG] Tunnel: client ('%s') proxy closed", clientConn.RemoteAddr()) +} + +// brokerData is responsible for copying data src => dest. It will also close the src when there are no more bytes to transfer +func brokerData(src net.Conn, dest net.Conn, srcClosed chan struct{}) { + _, err := io.Copy(src, dest) + if err != nil { + log.Printf("[ERROR] Tunnel: Copy error: %s", err) + } + if err := src.Close(); err != nil { + log.Printf("[ERROR] Tunnel: Close error: %s", err) + } + srcClosed <- struct{}{} +} diff --git a/helper/communicator/config.go b/helper/communicator/config.go index d7a4c51c8..986741db4 100644 --- a/helper/communicator/config.go +++ b/helper/communicator/config.go @@ -44,6 +44,8 @@ type Config struct { SSHBastionPassword string `mapstructure:"ssh_bastion_password"` SSHBastionPrivateKeyFile string `mapstructure:"ssh_bastion_private_key_file"` SSHFileTransferMethod string `mapstructure:"ssh_file_transfer_method"` + SSHRemoteTunnels []string `mapstructure:"ssh_remote_tunnels"` + SSHLocalTunnels []string `mapstructure:"ssh_local_tunnels"` SSHProxyHost string `mapstructure:"ssh_proxy_host"` SSHProxyPort int `mapstructure:"ssh_proxy_port"` SSHProxyUsername string `mapstructure:"ssh_proxy_username"` @@ -305,6 +307,22 @@ func (c *Config) prepareSSH(ctx *interpolate.Context) []error { errs = append(errs, errors.New("please specify either ssh_bastion_host or ssh_proxy_host, not both")) } + for _, v := range c.SSHLocalTunnels { + _, err := helperssh.ParseTunnelArgument(v, packerssh.UnsetTunnel) + if err != nil { + errs = append(errs, fmt.Errorf( + "ssh_local_tunnels ('%s') is invalid: %s", v, err)) + } + } + + for _, v := range c.SSHRemoteTunnels { + _, err := helperssh.ParseTunnelArgument(v, packerssh.UnsetTunnel) + if err != nil { + errs = append(errs, fmt.Errorf( + "ssh_remote_tunnels ('%s') is invalid: %s", v, err)) + } + } + return errs } diff --git a/helper/communicator/step_connect_ssh.go b/helper/communicator/step_connect_ssh.go index 28347fe95..3d806672e 100644 --- a/helper/communicator/step_connect_ssh.go +++ b/helper/communicator/step_connect_ssh.go @@ -172,6 +172,25 @@ func (s *StepConnectSSH) waitForSSH(state multistep.StateBag, ctx context.Contex } nc.Close() + // Parse out all the requested Port Tunnels that will go over our SSH connection + var tunnels []ssh.TunnelSpec + for _, v := range s.Config.SSHLocalTunnels { + t, err := helperssh.ParseTunnelArgument(v, ssh.LocalTunnel) + if err != nil { + return nil, fmt.Errorf( + "Error parsing port forwarding: %s", err) + } + tunnels = append(tunnels, t) + } + for _, v := range s.Config.SSHRemoteTunnels { + t, err := helperssh.ParseTunnelArgument(v, ssh.RemoteTunnel) + if err != nil { + return nil, fmt.Errorf( + "Error parsing port forwarding: %s", err) + } + tunnels = append(tunnels, t) + } + // Then we attempt to connect via SSH config := &ssh.Config{ Connection: connFunc, @@ -181,6 +200,7 @@ func (s *StepConnectSSH) waitForSSH(state multistep.StateBag, ctx context.Contex UseSftp: s.Config.SSHFileTransferMethod == "sftp", KeepAliveInterval: s.Config.SSHKeepAliveInterval, Timeout: s.Config.SSHReadWriteTimeout, + Tunnels: tunnels, } log.Printf("[INFO] Attempting SSH connection to %s...", address) diff --git a/helper/ssh/tunnel.go b/helper/ssh/tunnel.go new file mode 100644 index 000000000..eeb45b51e --- /dev/null +++ b/helper/ssh/tunnel.go @@ -0,0 +1,45 @@ +package ssh + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/hashicorp/packer/communicator/ssh" +) + +// ParseTunnelArgument parses an SSH tunneling argument compatible with the openssh client form. +// Valid formats: +// `port:host:hostport` +// NYI `[bind_address:]port:host:hostport` +func ParseTunnelArgument(forward string, direction ssh.TunnelDirection) (ssh.TunnelSpec, error) { + parts := strings.SplitN(forward, ":", 2) + if len(parts) != 2 { + return ssh.TunnelSpec{}, fmt.Errorf("Error parsing tunnel '%s': %v", forward, parts) + } + listeningPort, forwardingAddr := parts[0], parts[1] + + _, sPort, err := net.SplitHostPort(forwardingAddr) + if err != nil { + return ssh.TunnelSpec{}, fmt.Errorf("Error parsing forwarding, must be a tcp address: %s", err) + } + _, err = strconv.Atoi(sPort) + if err != nil { + return ssh.TunnelSpec{}, fmt.Errorf("Error parsing forwarding port, must be a valid port: %s", err) + } + _, err = strconv.Atoi(listeningPort) + if err != nil { + return ssh.TunnelSpec{}, fmt.Errorf("Error parsing listening port, must be a valid port: %s", err) + } + + return ssh.TunnelSpec{ + Direction: direction, + ForwardAddr: forwardingAddr, + ForwardType: "tcp", + ListenAddr: fmt.Sprintf("localhost:%s", listeningPort), + ListenType: "tcp", + }, nil + // So we parsed all that, and are just going to ignore it now. We would + // have used the information to set the type here. +} diff --git a/helper/ssh/tunnel_test.go b/helper/ssh/tunnel_test.go new file mode 100644 index 000000000..79c015e84 --- /dev/null +++ b/helper/ssh/tunnel_test.go @@ -0,0 +1,78 @@ +package ssh + +import ( + "github.com/hashicorp/packer/communicator/ssh" + "testing" +) + +const ( + tunnel8080ToLocal = "8080:localhost:1234" + tunnel8080ToRemote = "8080:example.com:80" + bindRemoteAddress_NYI = "redis:6379:localhost:6379" +) + +func TestTCPToLocalTCP(t *testing.T) { + tun, err := ParseTunnelArgument(tunnel8080ToLocal, ssh.UnsetTunnel) + if err != nil { + t.Fatal(err.Error()) + } + expectedTun := ssh.TunnelSpec{ + Direction: ssh.UnsetTunnel, + ForwardAddr: "localhost:1234", + ForwardType: "tcp", + ListenAddr: "localhost:8080", + ListenType: "tcp", + } + if tun != expectedTun { + t.Errorf("Parsed tunnel (%v), want %v", tun, expectedTun) + } +} + +func TestTCPToRemoteTCP(t *testing.T) { + tun, err := ParseTunnelArgument(tunnel8080ToRemote, ssh.UnsetTunnel) + if err != nil { + t.Fatal(err.Error()) + } + expectedTun := ssh.TunnelSpec{ + Direction: ssh.UnsetTunnel, + ForwardAddr: "example.com:80", + ForwardType: "tcp", + ListenAddr: "localhost:8080", + ListenType: "tcp", + } + if tun != expectedTun { + t.Errorf("Parsed tunnel (%v), want %v", tun, expectedTun) + } +} + +func TestBindAddress_NYI(t *testing.T) { + tun, err := ParseTunnelArgument(bindRemoteAddress_NYI, ssh.UnsetTunnel) + if err == nil { + t.Fatal(err.Error()) + } + expectedTun := ssh.TunnelSpec{ + Direction: ssh.UnsetTunnel, + ForwardAddr: "redis:6379", + ForwardType: "tcp", + ListenAddr: "localhost:6379", + ListenType: "tcp", + } + if tun == expectedTun { + t.Errorf("Parsed tunnel (%v), want %v", tun, expectedTun) + } +} + +func TestInvalidTunnels(t *testing.T) { + invalids := []string{ + "nope:8080", // insufficient parts + "nope:localhost:8080", // listen port is not a number + "8080:localhost:nope", // forwarding port is not a number + "/unix/is/no/go:/path/to/nowhere", // unix socket is unsupported + } + for _, tunnelStr := range invalids { + tun, err := ParseTunnelArgument(tunnelStr, ssh.UnsetTunnel) + if err == nil { + t.Errorf("Parsed tunnel %v, want error", tun) + } + } +} diff --git a/test/communicator_ssh.bats b/test/communicator_ssh.bats new file mode 100644 index 000000000..eb466c9c3 --- /dev/null +++ b/test/communicator_ssh.bats @@ -0,0 +1,30 @@ +#!/usr/bin/env bats +# +# This tests the ssh communicator using AWS builder. The teardown function will automatically +# delete any AMIs with a tag of `packer-test` being equal to "true" so +# be sure any test cases set this. + +load test_helper +verify_aws_cli +fixtures communicator-ssh + +setup() { + cd $FIXTURE_ROOT +} + +teardown() { + aws_ami_cleanup +} + +@test "shell provisioner: local port tunneling" { + run packer build $FIXTURE_ROOT/local-tunnel.json + [ "$status" -eq 0 ] + [[ "$output" == *"Connection to localhost port 10022 [tcp/*] succeeded"* ]] +} + +@test "shell provisioner: remote port tunneling" { + run packer build $FIXTURE_ROOT/remote-tunnel.json + [ "$status" -eq 0 ] + MY_LOCAL_IP=$(curl -s https://ifconfig.co/) + [[ "$output" == *"$MY_LOCAL_IP"* ]] +} diff --git a/test/fixtures/communicator-ssh/local-tunnel.json b/test/fixtures/communicator-ssh/local-tunnel.json new file mode 100644 index 000000000..2db4ebcac --- /dev/null +++ b/test/fixtures/communicator-ssh/local-tunnel.json @@ -0,0 +1,21 @@ +{ + "builders": [{ + "type": "amazon-ebs", + "ami_name": "packer-test {{timestamp}}", + "instance_type": "m1.small", + "region": "us-east-1", + "ssh_username": "ubuntu", + "ssh_local_tunnels": ["10022:localhost:22"], + "source_ami": "ami-0568456c", + "tags": { + "packer-test": "true" + } + }], + + "provisioners": [{ + "type": "shell-local", + "inline": [ + "echo | nc -G 5 -w 5 -v localhost 10022 2>&1" + ] + }] +} diff --git a/test/fixtures/communicator-ssh/remote-tunnel.json b/test/fixtures/communicator-ssh/remote-tunnel.json new file mode 100644 index 000000000..8f69be84a --- /dev/null +++ b/test/fixtures/communicator-ssh/remote-tunnel.json @@ -0,0 +1,22 @@ +{ + "builders": [{ + "type": "amazon-ebs", + "ami_name": "packer-test {{timestamp}}", + "instance_type": "t2.micro", + "region": "us-east-1", + "ssh_username": "ubuntu", + "ssh_remote_tunnels": ["8443:ifconfig.co:443"], + "source_ami": "ami-0111e8c43a763eb71", + "tags": { + "packer-test": "true" + } + }], + "provisioners": [{ + "inline": [ + "curl -kvs --connect-to ifconfig.co:443:localhost:8443 https://ifconfig.co/" + ], + "type": "shell" + } + ] + } + \ No newline at end of file diff --git a/website/source/docs/templates/communicator.html.md b/website/source/docs/templates/communicator.html.md index 4114233fa..de5c91c43 100644 --- a/website/source/docs/templates/communicator.html.md +++ b/website/source/docs/templates/communicator.html.md @@ -106,6 +106,12 @@ The SSH communicator has the following options: messages to the server. Set to a negative value (`-1s`) to disable. Example value: `10s`. Defaults to `5s`. +- `ssh_local_tunnels` (array of strings) - An array of OpenSSH-style tunnels to + create. The port is bound on the *local packer host* and connections are + forwarded to the remote destination. Note unless `GatewayPorts=yes` is set + in SSHD daemon, the target *must* be `localhost`. Example value: + `3306:localhost:3306` + - `ssh_password` (string) - A plaintext password to use to authenticate with SSH. @@ -132,6 +138,11 @@ The SSH communicator has the following options: command to end. This might be useful if, for example, packer hangs on a connection after a reboot. Example: `5m`. Disabled by default. +- `ssh_remote_tunnels` (array of strings) - An array of OpenSSH-style tunnels + to create. The port is bound on the *remote build host* and connections to it are + forwarded to the packer host's network. Non-localhost destinations may be set here. + Example value: `8443:git.example.com:443` + - `ssh_timeout` (string) - The time to wait for SSH to become available. Packer uses this to determine when the machine has booted so this is usually quite long. Example value: `10m`.