├── .gitignore ├── .travis.yml ├── exec ├── doc.go ├── free_port.go ├── config.go ├── command.go ├── listen.go └── dial.go ├── doc.go ├── host_port.go ├── Gopkg.lock ├── Gopkg.toml ├── channel_conn.go ├── connpipe └── connpipe.go ├── backoff └── backoff.go ├── LICENSE ├── redial.go ├── dial.go ├── listen.go ├── config.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - "1.11" 4 | script: go build . 5 | -------------------------------------------------------------------------------- /exec/doc.go: -------------------------------------------------------------------------------- 1 | // Package sshtunnel lets you dial (and re-publish locally) SSH-tunneled TCP 2 | // and Unix domain socket connections using external SSH client processes. 3 | package sshtunnel 4 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package sshtunnel lets you dial (and re-publish locally) SSH-tunneled TCP. 2 | // and Unix domain socket connections using the native Go SSH client golang.org/x/crypto/ssh. 3 | package sshtunnel 4 | -------------------------------------------------------------------------------- /host_port.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | ) 7 | 8 | func withDefaultPort(addr, port string) string { 9 | if _, _, err := net.SplitHostPort(addr); err == nil { 10 | return addr 11 | } 12 | return addr + ":" + port 13 | } 14 | 15 | func splitHostPortInt(addr string) (string, uint32, error) { 16 | host, portString, errSplit := net.SplitHostPort(addr) 17 | port, errPort := strconv.ParseUint(portString, 10, 32) 18 | if errSplit != nil { 19 | return host, uint32(port), errSplit 20 | } 21 | if errPort != nil { 22 | return host, uint32(port), errPort 23 | } 24 | return host, uint32(port), nil 25 | } 26 | -------------------------------------------------------------------------------- /exec/free_port.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strconv" 7 | ) 8 | 9 | func guessFreePortTCP(ip net.IP) (string, int, error) { 10 | const tcpNet = "tcp" 11 | listener, err := net.ListenTCP(tcpNet, &net.TCPAddr{IP: ip}) 12 | if err != nil { 13 | return "", 0, fmt.Errorf("open temporary listener: %v", err) 14 | } 15 | _, port, _ := net.SplitHostPort(listener.Addr().String()) 16 | if err := listener.Close(); err != nil { 17 | return "", 0, fmt.Errorf("close temporary listener: %v", err) 18 | } 19 | portInt64, err := strconv.ParseInt(port, 10, 64) 20 | if err != nil { 21 | return port, 0, err 22 | } 23 | return port, int(portInt64), nil 24 | } 25 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/google/shlex" 7 | packages = ["."] 8 | revision = "c34317bd91bf98fab745d77b03933cf8769299fe" 9 | 10 | [[projects]] 11 | branch = "master" 12 | name = "golang.org/x/crypto" 13 | packages = ["curve25519","ed25519","ed25519/internal/edwards25519","internal/chacha20","internal/subtle","poly1305","ssh","ssh/agent"] 14 | revision = "505ab145d0a99da450461ae2c1a9f6cd10d1f447" 15 | 16 | [solve-meta] 17 | analyzer-name = "dep" 18 | analyzer-version = 1 19 | inputs-digest = "5193461e837959f3be0accae4ecb0055e1eff0c48399cc3a07f1d48f75eaea8d" 20 | solver-name = "gps-cdcl" 21 | solver-version = 1 22 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | branch = "master" 26 | name = "github.com/google/shlex" 27 | 28 | [[constraint]] 29 | branch = "master" 30 | name = "golang.org/x/crypto" 31 | -------------------------------------------------------------------------------- /channel_conn.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "time" 7 | 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | type channelConn struct { 12 | ssh.Channel 13 | laddr, raddr net.TCPAddr 14 | } 15 | 16 | func (t *channelConn) LocalAddr() net.Addr { 17 | return &t.laddr 18 | } 19 | 20 | func (t *channelConn) RemoteAddr() net.Addr { 21 | return &t.raddr 22 | } 23 | 24 | func (t *channelConn) SetDeadline(deadline time.Time) error { 25 | if err := t.SetReadDeadline(deadline); err != nil { 26 | return err 27 | } 28 | return t.SetWriteDeadline(deadline) 29 | } 30 | 31 | func (t *channelConn) SetReadDeadline(deadline time.Time) error { 32 | return errors.New("ssh: channelConn: deadline not supported") 33 | } 34 | 35 | func (t *channelConn) SetWriteDeadline(deadline time.Time) error { 36 | return errors.New("ssh: channelConn: deadline not supported") 37 | } 38 | -------------------------------------------------------------------------------- /connpipe/connpipe.go: -------------------------------------------------------------------------------- 1 | package connpipe 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "sync" 8 | ) 9 | 10 | // Run starts a two-way copy between the two connections. 11 | func Run(ctx context.Context, a net.Conn, b net.Conn) { 12 | var wg sync.WaitGroup 13 | ctxAB, cancelAB := context.WithCancel(ctx) 14 | copyAB := make(chan error) 15 | go func() { 16 | _, err := io.Copy(a, b) 17 | copyAB <- err 18 | }() 19 | ctxBA, cancelBA := context.WithCancel(ctx) 20 | copyBA := make(chan error) 21 | go func() { 22 | _, err := io.Copy(b, a) 23 | copyBA <- err 24 | }() 25 | wg.Add(1) 26 | go func() { 27 | defer cancelBA() 28 | defer wg.Done() 29 | select { 30 | case <-ctxAB.Done(): 31 | case <-copyAB: 32 | } 33 | }() 34 | wg.Add(1) 35 | go func() { 36 | defer cancelAB() 37 | defer wg.Done() 38 | select { 39 | case <-ctxBA.Done(): 40 | case <-copyBA: 41 | } 42 | }() 43 | wg.Wait() 44 | } 45 | -------------------------------------------------------------------------------- /exec/config.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "net" 5 | "os/exec" 6 | "text/template" 7 | 8 | "github.com/sgreben/sshtunnel/backoff" 9 | ) 10 | 11 | // Config is an SSH tunnel configuration using an external SSH client command. 12 | type Config struct { 13 | // SSH user 14 | User string 15 | // SSH server host 16 | SSHHost string 17 | // SSH server port 18 | SSHPort string 19 | // SSH client command template. 20 | // A template that may refer to fields from struct commandTemplateData. 21 | // Its output is split according to shell splitting rules and executed. 22 | CommandTemplate *template.Template 23 | // This value will be passed to the CommandTemplate in the ExtraArgs field. 24 | CommandExtraArgs string 25 | // Optional callback to preform any additional configuration of the SSH client command. 26 | CommandConfig func(*exec.Cmd) error 27 | // Backoff config used when connecting to the external client. 28 | Backoff backoff.Config 29 | // Local IP address to listen on (optional). 30 | LocalIP *net.IP 31 | } 32 | -------------------------------------------------------------------------------- /backoff/backoff.go: -------------------------------------------------------------------------------- 1 | package backoff 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Config is an exponential back-off configuration 9 | // The back-off factor is currently fixed at 2. 10 | type Config struct { 11 | // Min is the minimum back-off delay (required) 12 | Min time.Duration 13 | // Max is the maximum back-off delay (required) 14 | Max time.Duration 15 | // MaxAttempts is the maximum total number of attempts (required) 16 | MaxAttempts int 17 | } 18 | 19 | // Run tries to run func f with the configured back-off until it either 20 | // returns a nil error, or the maximum number of attempts is reached. 21 | func (config Config) Run(ctx context.Context, f func() error) error { 22 | const backOffFactor = 2 23 | delay := config.Min 24 | for i := 1; true; i++ { 25 | err := f() 26 | if err == nil { 27 | return nil 28 | } 29 | if i > config.MaxAttempts { 30 | return err 31 | } 32 | delay *= backOffFactor 33 | if delay > config.Max { 34 | delay = config.Max 35 | } 36 | select { 37 | case <-time.After(delay): 38 | case <-ctx.Done(): 39 | return ctx.Err() 40 | } 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sergey Grebenshchikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /exec/command.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "text/template" 7 | 8 | "github.com/google/shlex" 9 | ) 10 | 11 | // CommandTemplateOpenSSHText is a command template text for the openssh `ssh` client binary. 12 | const CommandTemplateOpenSSHText = `ssh -nNT -L "{{.LocalIP}}:{{.LocalPort}}:{{.RemoteAddr}}" -p "{{.SSHPort}}" "{{.User}}@{{.SSHHost}}" {{.ExtraArgs}}` 13 | 14 | // CommandTemplateOpenSSH is a command template for the openssh `ssh` client binary. 15 | var CommandTemplateOpenSSH = mustParse(CommandTemplateOpenSSHText) 16 | 17 | // CommandTemplatePuTTYText is a command template text for the PuTTY client. 18 | const CommandTemplatePuTTYText = `putty -ssh -NT "{{.User}}@{{.SSHHost}}" -P "{{.SSHPort}}" -L "{{.LocalIP}}:{{.LocalPort}}:{{.RemoteAddr}}" {{.ExtraArgs}}` 19 | 20 | // CommandTemplatePuTTY is a command template for the PuTTY client. 21 | var CommandTemplatePuTTY = mustParse(CommandTemplatePuTTYText) 22 | 23 | type commandTemplateData struct { 24 | LocalIP string 25 | LocalPort string 26 | RemoteAddr string 27 | User string 28 | SSHHost string 29 | SSHPort string 30 | ExtraArgs string 31 | } 32 | 33 | func mustParse(t string) *template.Template { 34 | return template.Must(template.New("").Parse(t)) 35 | } 36 | 37 | func commandForTemplate(t *template.Template, data commandTemplateData) (string, []string, error) { 38 | var buf bytes.Buffer 39 | err := t.Execute(&buf, data) 40 | if err != nil { 41 | return "", nil, fmt.Errorf("execute command template %q: %v", t.Root.String(), err) 42 | } 43 | commandText := buf.String() 44 | var name string 45 | var args []string 46 | tokens, err := shlex.Split(commandText) 47 | if err != nil { 48 | return "", nil, fmt.Errorf("tokenize command %q: %v", commandText, err) 49 | } 50 | if len(tokens) == 0 { 51 | return "", nil, fmt.Errorf("empty command: %v", commandText) 52 | } 53 | name = tokens[0] 54 | if len(tokens) > 1 { 55 | args = tokens[1:] 56 | } 57 | return name, args, nil 58 | } 59 | -------------------------------------------------------------------------------- /redial.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/sgreben/sshtunnel/backoff" 8 | ) 9 | 10 | // ReDial opens a tunnelled connection to the address on the named network. 11 | // 12 | // Failed connections are re-dialled following the given back-off configuration. 13 | // Dropped connections are immediately re-dialed. 14 | // 15 | // Supported networks are "tcp", "tcp4" (IPv4-only), "tcp6" (IPv6-only), 16 | // "unix", "unixgram" and "unixpacket". 17 | func ReDial(network, addr string, config *Config, backoffConfig backoff.Config) (<-chan net.Conn, <-chan error) { 18 | return ReDialContext(context.Background(), network, addr, config, backoffConfig) 19 | } 20 | 21 | // ReDialContext opens a tunnelled connection to the address on the named network using 22 | // the provided context. 23 | // 24 | // Failed connections are re-dialled following the given back-off configuration. 25 | // Dropped connections are immediately re-dialed. 26 | // 27 | // See func ReDial for a description of the network and address 28 | // parameters. 29 | func ReDialContext(ctx context.Context, network, addr string, config *Config, backoffConfig backoff.Config) (<-chan net.Conn, <-chan error) { 30 | dial := func() (net.Conn, <-chan error, error) { 31 | return DialContext(ctx, network, addr, config) 32 | } 33 | dialBackOff := func() (net.Conn, <-chan error, error) { 34 | return dialBackOff(ctx, dial, backoffConfig) 35 | } 36 | connCh := make(chan net.Conn) 37 | errCh := make(chan error) 38 | go func() { 39 | defer close(connCh) 40 | defer close(errCh) 41 | for { 42 | conn, closedCh, err := dialBackOff() 43 | if err != nil { 44 | errCh <- err 45 | return 46 | } 47 | select { 48 | case connCh <- conn: 49 | case <-closedCh: 50 | case <-ctx.Done(): 51 | errCh <- ctx.Err() 52 | return 53 | } 54 | } 55 | }() 56 | return connCh, errCh 57 | } 58 | 59 | func dialBackOff(ctx context.Context, dial func() (net.Conn, <-chan error, error), config backoff.Config) (net.Conn, <-chan error, error) { 60 | var conn net.Conn 61 | var connClosedCh <-chan error 62 | errOut := config.Run(ctx, func() error { 63 | var err error 64 | conn, connClosedCh, err = dial() 65 | return err 66 | }) 67 | return conn, connClosedCh, errOut 68 | } 69 | -------------------------------------------------------------------------------- /exec/listen.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/sgreben/sshtunnel/connpipe" 9 | ) 10 | 11 | // Listen is ListenContext with context.Background() 12 | func Listen(laddr net.Addr, raddr string, config *Config) (net.Listener, <-chan error, error) { 13 | return ListenContext(context.Background(), laddr, raddr, config) 14 | } 15 | 16 | // ListenContext serves an SSH tunnel to a remote address on the given local network address `laddr`. 17 | // The remote endpoint of the tunneled connections is given by the network and addr parameters. 18 | func ListenContext(ctx context.Context, laddr net.Addr, raddr string, config *Config) (net.Listener, <-chan error, error) { 19 | listener, err := net.Listen(laddr.Network(), laddr.String()) 20 | if err != nil { 21 | return nil, nil, fmt.Errorf("listen on %s://%s: %v", laddr.Network(), laddr.String(), err) 22 | } 23 | listenerConnsCh, _ := listenerConns(ctx, listener) 24 | tunnelConn := func(ctx context.Context) (net.Conn, <-chan error, error) { 25 | return DialContext(ctx, raddr, config) 26 | } 27 | errCh := make(chan error, 1) 28 | handleListenerConn := func(listenerConn net.Conn) { 29 | ctxConn, cancel := context.WithCancel(ctx) 30 | defer listenerConn.Close() 31 | defer cancel() 32 | tunnelConn, tunnelConnErrCh, err := tunnelConn(ctxConn) 33 | if err != nil { 34 | errCh <- err 35 | return 36 | } 37 | pipeDone := make(chan bool, 1) 38 | go func() { 39 | connpipe.Run(ctxConn, tunnelConn, listenerConn) 40 | pipeDone <- true 41 | }() 42 | select { 43 | case <-pipeDone: 44 | return 45 | case err, ok := <-tunnelConnErrCh: 46 | if !ok { 47 | return 48 | } 49 | errCh <- err 50 | case <-ctx.Done(): 51 | errCh <- ctx.Err() 52 | } 53 | } 54 | go func() { 55 | defer listener.Close() 56 | defer close(errCh) 57 | for { 58 | select { 59 | case <-ctx.Done(): 60 | errCh <- ctx.Err() 61 | return 62 | case listenerConn, ok := <-listenerConnsCh: 63 | if !ok { 64 | return 65 | } 66 | go handleListenerConn(listenerConn) 67 | } 68 | } 69 | }() 70 | return listener, errCh, err 71 | } 72 | 73 | func listenerConns(ctx context.Context, listener net.Listener) (<-chan net.Conn, <-chan error) { 74 | connCh := make(chan net.Conn) 75 | errCh := make(chan error) 76 | go func() { 77 | defer close(connCh) 78 | defer close(errCh) 79 | for { 80 | conn, err := listener.Accept() 81 | if err != nil { 82 | return 83 | } 84 | select { 85 | case <-ctx.Done(): 86 | errCh <- ctx.Err() 87 | return 88 | case connCh <- conn: 89 | } 90 | } 91 | }() 92 | return connCh, errCh 93 | } 94 | -------------------------------------------------------------------------------- /dial.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "golang.org/x/crypto/ssh" 8 | ) 9 | 10 | // DialFunc is a dialler for tunneled connections. 11 | type DialFunc func(*ssh.Client, string) (net.Conn, error) 12 | 13 | // Dial opens a tunnelled connection to the address on the named network. 14 | // Supported networks are "tcp", "tcp4" (IPv4-only), "tcp6" (IPv6-only), 15 | // "unix", "unixgram" and "unixpacket". 16 | func Dial(network, addr string, config *Config) (net.Conn, <-chan error, error) { 17 | return DialContext(context.Background(), network, addr, config) 18 | } 19 | 20 | // DialContext opens a tunnelled connection to the address on the named network using 21 | // the provided context. 22 | // 23 | // See func Dial for a description of the network and address parameters. 24 | func DialContext(ctx context.Context, network, addr string, config *Config) (net.Conn, <-chan error, error) { 25 | if ctx == nil { 26 | panic("nil context") 27 | } 28 | sshAddr := withDefaultPort(config.SSHAddr, "22") 29 | sshConfig := config.SSHClient 30 | connectSSH := func(ctx context.Context) (*ssh.Client, chan error, error) { 31 | return dialSSH(ctx, sshAddr, sshConfig) 32 | } 33 | if config.SSHConn != nil { 34 | connectSSH = func(ctx context.Context) (*ssh.Client, chan error, error) { 35 | return dialConnSSH(ctx, config.SSHConn, sshAddr, sshConfig) 36 | } 37 | } 38 | select { 39 | case <-ctx.Done(): 40 | return nil, nil, ctx.Err() 41 | default: 42 | } 43 | client, wait, err := connectSSH(ctx) 44 | if err != nil { 45 | return nil, nil, err 46 | } 47 | select { 48 | case <-ctx.Done(): 49 | return nil, nil, ctx.Err() 50 | default: 51 | } 52 | conn, err := client.Dial(network, addr) 53 | if err != nil { 54 | return nil, nil, err 55 | } 56 | return conn, wait, nil 57 | } 58 | 59 | func dialConnSSH(ctx context.Context, conn net.Conn, sshAddr string, sshConfig *ssh.ClientConfig) (*ssh.Client, chan error, error) { 60 | c, chans, reqs, err := ssh.NewClientConn(conn, sshAddr, sshConfig) 61 | if err != nil { 62 | return nil, nil, err 63 | } 64 | client := ssh.NewClient(c, chans, reqs) 65 | wait := make(chan error) 66 | go func() { 67 | wait <- client.Wait() 68 | }() 69 | go func() { 70 | <-ctx.Done() 71 | client.Close() 72 | }() 73 | return client, wait, nil 74 | } 75 | 76 | func dialSSH(ctx context.Context, sshAddr string, sshConfig *ssh.ClientConfig) (*ssh.Client, chan error, error) { 77 | client, err := ssh.Dial("tcp", sshAddr, sshConfig) 78 | if err != nil { 79 | return nil, nil, err 80 | } 81 | wait := make(chan error) 82 | go func() { 83 | wait <- client.Wait() 84 | }() 85 | go func() { 86 | <-ctx.Done() 87 | client.Close() 88 | }() 89 | return client, wait, nil 90 | } 91 | -------------------------------------------------------------------------------- /listen.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/sgreben/sshtunnel/backoff" 9 | "github.com/sgreben/sshtunnel/connpipe" 10 | ) 11 | 12 | // Listen is ListenContext with context.Background() 13 | func Listen(laddr net.Addr, network, addr string, config *Config, reconnectBackoff backoff.Config) (net.Listener, chan error, error) { 14 | return ListenContext(context.Background(), laddr, network, addr, config, reconnectBackoff) 15 | } 16 | 17 | // ListenContext serves an SSH tunnel to a remote address on the given local network address `laddr`. 18 | // The remote endpoint of the tunneled connections is given by the network and addr parameters. 19 | // 20 | // See func ReDial for a description of the network, addr, config and reconnectBackoff 21 | // parameters. 22 | func ListenContext(ctx context.Context, laddr net.Addr, network, addr string, config *Config, reconnectBackoff backoff.Config) (net.Listener, chan error, error) { 23 | listener, err := net.Listen(laddr.Network(), laddr.String()) 24 | if err != nil { 25 | return nil, nil, fmt.Errorf("listen on %s://%s: %v", laddr.Network(), laddr.String(), err) 26 | } 27 | tunnelConnsCh, tunnelConnsErrCh := ReDialContext(ctx, network, addr, config, reconnectBackoff) 28 | listenerConnsCh, _ := listenerConns(ctx, listener) 29 | errCh := make(chan error, 1) 30 | handleListenerConn := func(listenerConn net.Conn) { 31 | ctxConn, cancel := context.WithCancel(ctx) 32 | defer listenerConn.Close() 33 | defer cancel() 34 | for listenerConn.RemoteAddr() != net.Addr(nil) { 35 | select { 36 | case err := <-tunnelConnsErrCh: 37 | errCh <- err 38 | return 39 | case <-ctx.Done(): 40 | errCh <- ctx.Err() 41 | return 42 | case tunnelConn, ok := <-tunnelConnsCh: 43 | if !ok { 44 | return 45 | } 46 | connpipe.Run(ctxConn, tunnelConn, listenerConn) 47 | } 48 | } 49 | } 50 | go func() { 51 | defer listener.Close() 52 | defer close(errCh) 53 | for { 54 | select { 55 | case <-ctx.Done(): 56 | errCh <- ctx.Err() 57 | return 58 | case listenerConn, ok := <-listenerConnsCh: 59 | if !ok { 60 | return 61 | } 62 | go handleListenerConn(listenerConn) 63 | } 64 | } 65 | }() 66 | return listener, errCh, err 67 | } 68 | 69 | func listenerConns(ctx context.Context, listener net.Listener) (<-chan net.Conn, chan error) { 70 | connCh := make(chan net.Conn) 71 | errCh := make(chan error) 72 | go func() { 73 | defer close(connCh) 74 | defer close(errCh) 75 | for { 76 | conn, err := listener.Accept() 77 | if err != nil { 78 | return 79 | } 80 | select { 81 | case <-ctx.Done(): 82 | errCh <- ctx.Err() 83 | return 84 | case connCh <- conn: 85 | } 86 | } 87 | }() 88 | return connCh, errCh 89 | } 90 | -------------------------------------------------------------------------------- /exec/dial.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "os/exec" 8 | 9 | "github.com/sgreben/sshtunnel/backoff" 10 | ) 11 | 12 | // Dial opens a tunnelled connection to the given address using the configured 13 | // external SSH client. 14 | func Dial(remoteAddr string, config *Config) (net.Conn, <-chan error, error) { 15 | return DialContext(context.Background(), remoteAddr, config) 16 | 17 | } 18 | 19 | // DialContext opens a tunnelled connection to the given address using the configured 20 | // external SSH client and the provided context. 21 | func DialContext(ctx context.Context, remoteAddr string, config *Config) (net.Conn, <-chan error, error) { 22 | var localIP net.IP 23 | if config.LocalIP != nil { 24 | localIP = *config.LocalIP 25 | } else { 26 | localIP = net.ParseIP("127.0.0.1") 27 | } 28 | portString, port, err := guessFreePortTCP(localIP) 29 | if err != nil { 30 | return nil, nil, err 31 | } 32 | dial := func() (net.Conn, error) { 33 | return net.DialTCP("tcp", nil, &net.TCPAddr{IP: localIP, Port: port}) 34 | } 35 | name, args, err := commandForTemplate(config.CommandTemplate, commandTemplateData{ 36 | LocalIP: localIP.String(), 37 | LocalPort: portString, 38 | User: config.User, 39 | SSHHost: config.SSHHost, 40 | SSHPort: config.SSHPort, 41 | RemoteAddr: remoteAddr, 42 | }) 43 | if err != nil { 44 | return nil, nil, err 45 | } 46 | 47 | ctxCmd, cancelCmd := context.WithCancel(ctx) 48 | cmd := exec.CommandContext(ctxCmd, name, args...) 49 | if config.CommandConfig != nil { 50 | if err := config.CommandConfig(cmd); err != nil { 51 | cancelCmd() 52 | return nil, nil, err 53 | } 54 | } 55 | if err := cmd.Start(); err != nil { 56 | cancelCmd() 57 | return nil, nil, fmt.Errorf("exec: %v", err) 58 | } 59 | 60 | cmdErrCh := make(chan error, 1) 61 | errCh := make(chan error, 1) 62 | go func() { cmdErrCh <- cmd.Wait() }() 63 | go func() { 64 | defer cancelCmd() 65 | select { 66 | case err := <-cmdErrCh: 67 | errCh <- err 68 | case <-ctx.Done(): 69 | errCh <- ctx.Err() 70 | } 71 | }() 72 | 73 | connCh := make(chan net.Conn) 74 | go func() { 75 | conn, err := dialBackOff(ctx, dial, config.Backoff) 76 | if err != nil { 77 | cancelCmd() 78 | errCh <- err 79 | return 80 | } 81 | connCh <- conn 82 | }() 83 | select { 84 | case conn := <-connCh: 85 | return conn, errCh, nil 86 | case err := <-errCh: 87 | return nil, nil, err 88 | } 89 | } 90 | 91 | func dialBackOff(ctx context.Context, dial func() (net.Conn, error), config backoff.Config) (net.Conn, error) { 92 | var conn net.Conn 93 | errOut := config.Run(ctx, func() error { 94 | var err error 95 | conn, err = dial() 96 | return err 97 | }) 98 | return conn, errOut 99 | } 100 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package sshtunnel 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net" 7 | 8 | "golang.org/x/crypto/ssh" 9 | "golang.org/x/crypto/ssh/agent" 10 | ) 11 | 12 | // Config is an SSH tunnel configuration. 13 | // 14 | // When `SSHConn` is set to a non-nil net.Conn, that connection is reused instead of opening a new one. 15 | type Config struct { 16 | // SSHAddr is the host:port address of the SSH server (required). 17 | SSHAddr string 18 | // SSHClient is the ssh.Client config (required). 19 | SSHClient *ssh.ClientConfig 20 | // SSHConn is a pre-existing connection to an SSH server (optional). 21 | SSHConn net.Conn 22 | } 23 | 24 | // ConfigAuth is an authentication configuration for an SSH tunnel. 25 | type ConfigAuth struct { 26 | Password *string 27 | SSHAgent *ConfigSSHAgent 28 | Keys []KeySource 29 | } 30 | 31 | // ConfigSSHAgent is the configuration for an ssh-agent connection. 32 | type ConfigSSHAgent struct { 33 | Addr net.Addr 34 | Passphrase *[]byte 35 | } 36 | 37 | // KeySource is the configuration of an ssh key. 38 | // 39 | // Either Signer, or one of PEM and Path must be set. 40 | // If PEM or Path are set and the referred key is encrypted, Passphrase must also be set. 41 | type KeySource struct { 42 | PEM *[]byte 43 | Path *string 44 | Passphrase *[]byte 45 | Signer ssh.Signer 46 | } 47 | 48 | // Methods returns the configured SSH auth methods. 49 | func (a ConfigAuth) Methods() (out []ssh.AuthMethod, err error) { 50 | if a.Password != nil { 51 | out = append(out, ssh.Password(*a.Password)) 52 | } 53 | var keys []ssh.Signer 54 | if a.SSHAgent != nil { 55 | agentKeys, err := a.SSHAgent.Keys() 56 | if err != nil { 57 | return nil, err 58 | } 59 | keys = append(keys, agentKeys...) 60 | } 61 | if a.Keys != nil { 62 | for _, k := range a.Keys { 63 | key, err := k.Key() 64 | if err != nil { 65 | return nil, err 66 | } 67 | keys = append(keys, key) 68 | } 69 | } 70 | if len(keys) > 0 { 71 | out = append(out, ssh.PublicKeys(keys...)) 72 | } 73 | return 74 | } 75 | 76 | // Keys obtains and returns all keys from the configured ssh agent. 77 | func (a ConfigSSHAgent) Keys() ([]ssh.Signer, error) { 78 | conn, err := net.Dial(a.Addr.Network(), a.Addr.String()) 79 | if err != nil { 80 | return nil, err 81 | } 82 | sshAgent := agent.NewClient(conn) 83 | signers, err := sshAgent.Signers() 84 | if err == nil { 85 | return signers, nil 86 | } 87 | if a.Passphrase == nil { 88 | return nil, err 89 | } 90 | if err := sshAgent.Unlock(*a.Passphrase); err != nil { 91 | return nil, err 92 | } 93 | return sshAgent.Signers() 94 | } 95 | 96 | // Key obtains and returns the configured key. 97 | func (a KeySource) Key() (ssh.Signer, error) { 98 | switch { 99 | case a.Signer != nil: 100 | return a.Signer, nil 101 | case a.PEM != nil && a.Passphrase != nil: 102 | return ssh.ParsePrivateKeyWithPassphrase(*a.PEM, *a.Passphrase) 103 | case a.PEM != nil: 104 | return ssh.ParsePrivateKey(*a.PEM) 105 | case a.Path != nil && a.Passphrase != nil: 106 | buf, err := ioutil.ReadFile(*a.Path) 107 | if err != nil { 108 | return nil, err 109 | } 110 | return ssh.ParsePrivateKeyWithPassphrase(buf, *a.Passphrase) 111 | case a.Path != nil: 112 | buf, err := ioutil.ReadFile(*a.Path) 113 | if err != nil { 114 | return nil, err 115 | } 116 | return ssh.ParsePrivateKey(buf) 117 | default: 118 | return nil, fmt.Errorf("no ssh key defined") 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sshtunnel 2 | 3 | [![](https://godoc.org/github.com/sgreben/sshtunnel?status.svg)](http://godoc.org/github.com/sgreben/sshtunnel) [![](https://goreportcard.com/badge/github.com/sgreben/sshtunnel/goreportcard)](https://goreportcard.com/report/github.com/sgreben/sshtunnel) [![cover.run](https://cover.run/go/github.com/sgreben/sshtunnel.svg?style=flat&tag=golang-1.10)](https://cover.run/go?tag=golang-1.10&repo=github.com%2Fsgreben%2Fsshtunnel) [![Build Status](https://travis-ci.org/sgreben/sshtunnel.svg?branch=master)](https://travis-ci.org/sgreben/sshtunnel) 4 | 5 | Go library providing a dialer for SSH-tunneled TCP and Unix domain socket connections. Please note the [**limitations**](#limitations) below. 6 | 7 | The underlying package `golang.org/x/crypto/ssh` already provides a dialer `ssh.Client.Dial` that can establish `direct-tcpip` (TCP) and `direct-streamlocal` (Unix domain socket) connections via SSH. 8 | 9 | In comparison, the functions `Dial/DialContext`, `ReDial/ReDialContext`, `Listen/ListenContext` in this package provide additional convenience features such as redialling dropped connections, and serving the tunnel locally. 10 | 11 | Furthermore, a wrapper [`github.com/sgreben/sshtunnel/exec`](http://godoc.org/github.com/sgreben/sshtunnel/exec) around (`exec`'d) external clients, with a similar interface as the native client, is provided. 12 | 13 | - [Get it](#get-it) 14 | - [Use it](#use-it) 15 | - [Docs](#docs) 16 | - [Toy example (native)](#toy-example-native) 17 | - [Toy example (external client)](#toy-example-external-client) 18 | - [Bigger examples](#bigger-examples) 19 | - [Limitations](#limitations) 20 | 21 | ## Get it 22 | 23 | ```sh 24 | go get -u "github.com/sgreben/sshtunnel" 25 | ``` 26 | 27 | ## Use it 28 | 29 | ```go 30 | import "github.com/sgreben/sshtunnel" 31 | ``` 32 | 33 | ### Docs 34 | 35 | [![](https://godoc.org/github.com/sgreben/sshtunnel?status.svg)](http://godoc.org/github.com/sgreben/sshtunnel) 36 | 37 | 38 | ### Toy example (native) 39 | 40 | ```go 41 | package main 42 | 43 | import ( 44 | "fmt" 45 | "io" 46 | "os" 47 | 48 | "golang.org/x/crypto/ssh" 49 | "github.com/sgreben/sshtunnel" 50 | ) 51 | 52 | func main() { 53 | // Connect to "google.com:80" via a tunnel to "ubuntu@my-ssh-server-host:22" 54 | keyPath := "private-key.pem" 55 | authConfig := sshtunnel.ConfigAuth{ 56 | Keys: []sshtunnel.KeySource{{Path: &keyPath}}, 57 | } 58 | sshAuthMethods, _ := authConfig.Methods() 59 | clientConfig := ssh.ClientConfig{ 60 | User: "ubuntu", 61 | Auth: sshAuthMethods, 62 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 63 | } 64 | tunnelConfig := sshtunnel.Config{ 65 | SSHAddr: "my-ssh-server-host:22", 66 | SSHClient: &clientConfig, 67 | } 68 | conn, _, err := sshtunnel.Dial("tcp", "google.com:80", &tunnelConfig) 69 | if err != nil { 70 | panic(err) 71 | } 72 | // Do things with conn 73 | fmt.Fprintln(conn, "GET /") 74 | io.Copy(os.Stdout, conn) 75 | } 76 | ``` 77 | 78 | ### Toy example (external client) 79 | 80 | ```go 81 | package main 82 | 83 | import ( 84 | "fmt" 85 | "io" 86 | "log" 87 | "os" 88 | "os/exec" 89 | "time" 90 | 91 | "github.com/sgreben/sshtunnel/exec" 92 | ) 93 | 94 | func main() { 95 | // Connect to "google.com:80" via a tunnel to "ubuntu@my-ssh-server-host:22" 96 | // 97 | // Unlike the "native" example above, here a binary named `ssh` (which must be in $PATH) 98 | // is used to set up the tunnel. 99 | tunnelConfig := sshtunnel.Config{ 100 | User: "ubuntu", 101 | SSHHost: "my-ssh-server-host", 102 | SSHPort: "22", 103 | CommandTemplate: sshtunnel.CommandTemplateOpenSSH, 104 | CommandConfig: func(cmd *exec.Cmd) error { 105 | cmd.Stdout = os.Stdout 106 | cmd.Stderr = os.Stderr 107 | cmd.Stdin = os.Stdin 108 | return nil 109 | }, 110 | } 111 | 112 | tunnelConfig.Backoff.Min = 50 * time.Millisecond 113 | tunnelConfig.Backoff.Max = 1 * time.Second 114 | tunnelConfig.Backoff.MaxAttempts = 8 115 | 116 | conn, _, err := sshtunnel.Dial("google.com:80", &tunnelConfig) 117 | if err != nil { 118 | panic(err) 119 | } 120 | // Do things with conn 121 | fmt.Fprintln(conn, "GET /") 122 | io.Copy(os.Stdout, conn) 123 | } 124 | ``` 125 | 126 | ### Bigger examples 127 | 128 | Projects using this library: 129 | 130 | 131 | - [docker-compose-hosts](https://github.com/sgreben/docker-compose-hosts). 132 | - [with-ssh-docker-socket](https://github.com/sgreben/with-ssh-docker-socket). 133 | 134 | ## Limitations 135 | 136 | - **No tests**; want some - write some. 137 | --------------------------------------------------------------------------------