Not today...

comments

Snippet

Golang SSH tunneling

Tagged dev , golang

I already did an article around Golang and SSH previously, since it is a huge part of my current work. Today, I will share a small snippet which can be quite handy when automating stuff. This is how to perform a SSH tunnel using Golang. I will not explain what it is or what it is useful for, if you need more details please check the doc. In this tutorial, I will use the official SSH library from Google.

Setup remote

To be sure that our tunneling is working we will need a small service to reach. Here is a small shell snippet to display the current time over HTTP:

while true; do
  echo -e "HTTP/1.1 200 OK\n\n $(date)" | nc -l -p 1500
done

Just run this on your remote host. You can then check if it works using curl: curl <remote_host>:1500. You will also be sure to have a properly configured SSH server. Check this by using: ssh <remote_host>.

Connect to local agent

If you have some keys in your local agent you may want to use them to connect. This has to be done in the code, and I will use the agent module from the Golang SSH library.

Here is a quick way to instantiate an SSH-agent client connection, it re-uses the code from the client example, I am also directly casting it as an AuthMethod:

import (
	"net"
	"os"

	"golang.org/x/crypto/ssh/agent"
)

const EnvSSHAuthSock = "SSH_AUTH_SOCK"

func AuthAgent() (ssh.AuthMethod, error) {
	conn, err := net.Dial("unix", os.Getenv(EnvSSHAuthSock))
	if err != nil {
		return nil, err
	}
	client, err := agent.NewClient(conn), err
	if err != nil {
		return nil, err
	}
	return ssh.PublicKeysCallback(client.Signers), nil
}

Keyboard interactive

Another way of authenticating to a server is by using a keyboard-interactive challenge. This may be necessary for additional authentication methods like 2FA. For this snippet, we want to let the user enter its answers and pass it to the server. Since code may be a bit complicated to understand I will comment as best as I can to help understanding the flow. Also, check the documentation of the challenge function to understand the arguments.

import (
	"syscall"

	"golang.org/x/crypto/ssh"
	"golang.org/x/crypto/ssh/terminal"
)

func AuthInteractive() ssh.AuthMethod {
	return ssh.KeyboardInteractive(
		func(user, instruction string, questions []string, echos []bool) ([]string, error) {
			// this part is not really clear, as far as I get it we should print this
			// only if no question is provided
			if len(questions) == 0 {
				fmt.Printf("%s %s\n", user, instruction)
			}
			// instanciate the answers slice
			answers := make([]string, len(questions))

			// we iterate over each question and print it to the console
			for i, question := range questions {
				fmt.Print(question)

				// here is the trick, if the echo is true, we want to display user input
				// otherwise we want to hide it and use the terminal module to perform this
				if echos[i] {

					// simple scan over the console
					if _, err := fmt.Scan(&answers[i]); err != nil {
						return nil, err
					}
				} else {
					// here we use the ReadPassword function to hide user input
					answer, err := terminal.ReadPassword(syscall.Stdin)
					if err != nil {
						return nil, err
					}
					answers[i] = string(answer)
				}
			}
			return answers, nil
		})
}

NOTE: If you want to implement the password authentication method you should also use the terminal.ReadPassword function to hide the user input.

Initiate ssh connection

Now we are going to create our SSH connection which we will use to tunnel our traffic. We need a raw connection to perform byte copies. Looking at the SSH library we will use the Dial function. It takes three arguments, the network (which is TCP), an address (which is your remote SSH server), and a configuration. Let’s take a look at this structure.

import (
	"os/user"

	"golang.org/x/crypto/ssh"
)

func config(methods ...ssh.AuthMethod) (*ssh.ClientConfig, error) {
	// here I am retrieving user from current execution,
	// you can pass it as argument if you want
	current, err := user.Current()
	if err != nil {
		return nil, err
	}
	return &ssh.ClientConfig{
		User: current.Username,
		Auth: methods,
		// you should not pass this option, but for the sake of simplicity
		// we use it here
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
	}, nil
}

Once config is ready we can create our connection instance. Take a look at the last section to see how this is performed.

Tunnel

This is where the magic happens. We want to tunnel traffic from local to remote endpoint. The first step is to initialize a TCP server, it will listen on a specific port. Once we receive a connection to our local port, we open a connection over the SSH connection to the remote address. We now have two network connections, we just need to pipe each one to the other. NOTE: Most of the code has to be asynchronous to not block on one side or another, however, in this example I am taking shortcuts and this is not solid at all! Use with caution!


import (
	"io"
	"log"
	"net"

	"golang.org/x/crypto/ssh"
)

func tunnel(conn *ssh.Client, local, remote string) error {
	pipe := func(writer, reader net.Conn) {
		defer writer.Close()
		defer reader.Close()

		_, err := io.Copy(writer, reader)
		if err != nil {
			log.Printf("failed to copy: %s", err)
		}
	}
	listener, err := net.Listen("tcp", local)
	if err != nil {
		return err
	}
	for {
		here, err := listener.Accept()
		if err != nil {
			return err
		}
		go func(here net.Conn) {
			there, err := conn.Dial("tcp", remote)
			if err != nil {
				log.Fatalf("failed to dial to remote: %q", err)
			}
			go pipe(there, here)
			go pipe(here, there)
		}(here)
	}
}

Plugging everything together

It’s now time to write our main function to bring everything together. We set up our authentications methods, then initiate the SSH connection to the remote host. Once the link has been established we tunnel our traffic between our local and remote hosts.

import (
	"log"

	"golang.org/x/crypto/ssh"
)

func main() {
	// initiate auths methods
	authInteractive := AuthInteractive()
	authAgent, err := AuthAgent()
	if err != nil {
		log.Fatalf("failed to connect to the ssh agent: %q", err)
	}

	// initialize SSH connection
	clientConfig, err := config(authAgent, authInteractive)
	if err != nil {
		log.Fatalf("failed to create ssh config: %q", err)
	}
	clientConn, err := ssh.Dial("tcp", "<remote_host>:22", clientConfig)
	if err != nil {
		log.Fatalf("failed to connect to the ssh server: %q", err)
	}

	// tunnel traffic between local port 1600 and remote port 1500
	if err := tunnel(clientConn, "localhost:1600", "localhost:1500"); err != nil {
		log.Fatalf("failed to tunnel traffic: %q", err)
	}
}

You can now test the connection. Just run curl localhost:1600 and you should see the same thing as you would have by requesting the remote host with curl <remote_host>:1500.

As always this code is not production-ready by any means. It falls short on a lot of things, like error handling, or connection pool management. However, this is the basic you will need to iterate onto. As always you can find the code in the blog Github repository. Hope this will help.