From 3b64620234fd35bce5ae1a73eaa87be9f00982fc Mon Sep 17 00:00:00 2001 From: Daniel Kimsey Date: Fri, 26 Jul 2019 16:11:52 -0500 Subject: [PATCH 1/3] SSH tunneling support Support for both local and remote TCP port tunneling. Includes updated docs and tests. Does not implement dynamic port forwarding (SSH's built-in SOCKS) (uncertain difficulty) nor unix socket (potentially easy). --- communicator/ssh/communicator.go | 79 +++++++++++++++++++ communicator/ssh/tunnel.go | 71 +++++++++++++++++ helper/communicator/config.go | 18 +++++ helper/communicator/step_connect_ssh.go | 20 +++++ helper/ssh/tunnel.go | 45 +++++++++++ helper/ssh/tunnel_test.go | 78 ++++++++++++++++++ test/communicator_ssh.bats | 30 +++++++ .../communicator-ssh/local-tunnel.json | 21 +++++ .../communicator-ssh/remote-tunnel.json | 22 ++++++ .../docs/templates/communicator.html.md | 11 +++ 10 files changed, 395 insertions(+) create mode 100644 communicator/ssh/tunnel.go create mode 100644 helper/ssh/tunnel.go create mode 100644 helper/ssh/tunnel_test.go create mode 100644 test/communicator_ssh.bats create mode 100644 test/fixtures/communicator-ssh/local-tunnel.json create mode 100644 test/fixtures/communicator-ssh/remote-tunnel.json diff --git a/communicator/ssh/communicator.go b/communicator/ssh/communicator.go index be2ef1fd5..e6fcfcdb0 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,69 @@ func (c *comm) reconnect() (err error) { c.client = ssh.NewClient(sshConn, sshChan, req) } c.connectToAgent() + c.connectTunnels(sshConn) return } +func (c *comm) connectTunnels(sshConn ssh.Conn) { + if c.client == nil { + 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{}) + 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) + // TODO How can we get this failure to ui.Error? + if err != nil { + log.Printf("[ERROR] Tunnel: unable to bind remote tunnel ('%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 { + // TODO How can we get this failure to ui.Error? + log.Printf("[ERROR] Tunnel: unable to bind local tunnel ('%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: + log.Printf("[ERROR] Tunnel: Unknown tunnel type ('%v'): %v", v, v.Direction) + continue + } + } + + 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..e3eaee510 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 destinations. Note unless `GatewayPorts=yes` is set + in SSHD dameon, the target *must* be `localhost`. Example value: + `8080:localhost:8000` + - `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 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`. From 543d09a29c7ed1b9062861c2227234bc17c8fd9b Mon Sep 17 00:00:00 2001 From: Daniel Kimsey Date: Tue, 20 Aug 2019 13:46:43 -0500 Subject: [PATCH 2/3] fixup: Propagate bind errors up --- communicator/ssh/communicator.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/communicator/ssh/communicator.go b/communicator/ssh/communicator.go index e6fcfcdb0..d2f2eaf6b 100644 --- a/communicator/ssh/communicator.go +++ b/communicator/ssh/communicator.go @@ -364,27 +364,35 @@ func (c *comm) reconnect() (err error) { c.client = ssh.NewClient(sshConn, sshChan, req) } c.connectToAgent() - c.connectTunnels(sshConn) + err = c.connectTunnels(sshConn) + if err != nil { + return + } return } -func (c *comm) connectTunnels(sshConn ssh.Conn) { +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) + 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) - // TODO How can we get this failure to ui.Error? + listener, err = c.client.Listen(v.ListenType, v.ListenAddr) if err != nil { - log.Printf("[ERROR] Tunnel: unable to bind remote tunnel ('%v'): %s", v, err) + 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) @@ -395,10 +403,9 @@ func (c *comm) connectTunnels(sshConn ssh.Conn) { 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) + listener, err = net.Listen(v.ListenType, v.ListenAddr) if err != nil { - // TODO How can we get this failure to ui.Error? - log.Printf("[ERROR] Tunnel: unable to bind local tunnel ('%v'): %s", v, err) + 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) @@ -410,8 +417,8 @@ func (c *comm) connectTunnels(sshConn ssh.Conn) { // FIXME: Is there a better "on-shutdown" we can wait on? go shutdownProxyTunnel(sshConn, done, listener) default: - log.Printf("[ERROR] Tunnel: Unknown tunnel type ('%v'): %v", v, v.Direction) - continue + err = fmt.Errorf("Tunnel: Unknown tunnel direction ('%v'): %v", v, v.Direction) + return } } From 887f3bb12ea996e991eee20d069922f99038effc Mon Sep 17 00:00:00 2001 From: Daniel Kimsey Date: Tue, 20 Aug 2019 13:55:54 -0500 Subject: [PATCH 3/3] fixup: doc words --- website/source/docs/templates/communicator.html.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/source/docs/templates/communicator.html.md b/website/source/docs/templates/communicator.html.md index e3eaee510..de5c91c43 100644 --- a/website/source/docs/templates/communicator.html.md +++ b/website/source/docs/templates/communicator.html.md @@ -108,9 +108,9 @@ The SSH communicator has the following options: - `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 destinations. Note unless `GatewayPorts=yes` is set - in SSHD dameon, the target *must* be `localhost`. Example value: - `8080:localhost:8000` + 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. @@ -139,7 +139,7 @@ The SSH communicator has the following options: 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 are + 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`