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

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"`
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
}

View File

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

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
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`.