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).
This commit is contained in:
Daniel Kimsey 2019-07-26 16:11:52 -05:00 committed by Megan Marsh
parent f2a517dfd7
commit 3b64620234
10 changed files with 395 additions and 0 deletions

View File

@ -35,6 +35,24 @@ type comm struct {
address string 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. // Config is the structure used to configure the SSH communicator.
type Config struct { type Config struct {
// The configuration of the Go SSH connection // 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 is how long to wait for a read or write to succeed.
Timeout time.Duration Timeout time.Duration
Tunnels []TunnelSpec
} }
// Creates a new packer.Communicator implementation over SSH. This takes // 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.client = ssh.NewClient(sshConn, sshChan, req)
} }
c.connectToAgent() c.connectToAgent()
c.connectTunnels(sshConn)
return 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() { func (c *comm) connectToAgent() {
if c.client == nil { if c.client == nil {
return return

View File

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

View File

@ -44,6 +44,8 @@ type Config struct {
SSHBastionPassword string `mapstructure:"ssh_bastion_password"` SSHBastionPassword string `mapstructure:"ssh_bastion_password"`
SSHBastionPrivateKeyFile string `mapstructure:"ssh_bastion_private_key_file"` SSHBastionPrivateKeyFile string `mapstructure:"ssh_bastion_private_key_file"`
SSHFileTransferMethod string `mapstructure:"ssh_file_transfer_method"` 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"` SSHProxyHost string `mapstructure:"ssh_proxy_host"`
SSHProxyPort int `mapstructure:"ssh_proxy_port"` SSHProxyPort int `mapstructure:"ssh_proxy_port"`
SSHProxyUsername string `mapstructure:"ssh_proxy_username"` 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")) 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 return errs
} }

View File

@ -172,6 +172,25 @@ func (s *StepConnectSSH) waitForSSH(state multistep.StateBag, ctx context.Contex
} }
nc.Close() 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 // Then we attempt to connect via SSH
config := &ssh.Config{ config := &ssh.Config{
Connection: connFunc, Connection: connFunc,
@ -181,6 +200,7 @@ func (s *StepConnectSSH) waitForSSH(state multistep.StateBag, ctx context.Contex
UseSftp: s.Config.SSHFileTransferMethod == "sftp", UseSftp: s.Config.SSHFileTransferMethod == "sftp",
KeepAliveInterval: s.Config.SSHKeepAliveInterval, KeepAliveInterval: s.Config.SSHKeepAliveInterval,
Timeout: s.Config.SSHReadWriteTimeout, Timeout: s.Config.SSHReadWriteTimeout,
Tunnels: tunnels,
} }
log.Printf("[INFO] Attempting SSH connection to %s...", address) log.Printf("[INFO] Attempting SSH connection to %s...", address)

45
helper/ssh/tunnel.go Normal file
View File

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

78
helper/ssh/tunnel_test.go Normal file
View File

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

View File

@ -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"* ]]
}

View File

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

View File

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

View File

@ -106,6 +106,12 @@ The SSH communicator has the following options:
messages to the server. Set to a negative value (`-1s`) to disable. Example messages to the server. Set to a negative value (`-1s`) to disable. Example
value: `10s`. Defaults to `5s`. 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_password` (string) - A plaintext password to use to authenticate with
SSH. 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 command to end. This might be useful if, for example, packer hangs on a
connection after a reboot. Example: `5m`. Disabled by default. 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. - `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 Packer uses this to determine when the machine has booted so this is
usually quite long. Example value: `10m`. usually quite long. Example value: `10m`.