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:
parent
f2a517dfd7
commit
3b64620234
|
@ -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
|
||||
|
|
|
@ -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{}{}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"* ]]
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
Loading…
Reference in New Issue