Writing a replacement to OpenSSH using Go (2/2)

Last week, we have seen that tools exist to build SSH based applications with Go: Writing a replacement to OpenSSH using Go (1/2).

This week, we are going to give advanced usage examples of the golang.org/x/crypto/ssh package.

Appsdeck use case

To deploy applications on our platform, people are using GIT over SSH. The front-end SSH server has the following responsabilities:

  • Authenticate the user
  • Check that the user want to execute a git command
  • Ensure the user can access the requested repository
  • Forward the connection to a server which will manage the application deployment

For each of these items, I will give an example about how it can be done with Go.

Authentication

During the server setup, it is required to build a ssh.ServerConfig struct defining the different accepted ways to authenticate:

  • PublicKeyCallback authenticate with a private/public key pair
  • PasswordCallback: authenticate with a simple password
  • KeyboardInteractiveCallback: create challenges that the user has to answer interactively

The one we are using is PublicKey authentication, the callback handler is straightforward:

// Server setup:
config := ssh.ServerConfig{
PublicKeyCallback: keyAuthCallback,
}

// keyAuthCallback is called when a user tries to authenticate with a private/public key pair.
func keyAuthCallback(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
user, err := auth.AuthenticateUser(conn.User(), key)
if err != nil {
log.Println("Fail to authenticate", conn, ":", err)
return nil, errors.New("invalid authentication")
}

return &ssh.Permissions{Extensions: map[string]string{"user_id": user.Id}}, nil
}

All the cryptographic functions are handled by the package, the callback is used to identify users against any data source. It is possible to build anything behind auth.AuthenticateUser, a redis backend, a specific API. The first goal has been reached, we can use our own authentication system.

As you can see, I’m using the ssh.Permissions.Extensions map to keep track of the real user ID which has been sucessfully authenticated, this struct will be attached to the ssh.Conn object, so it’s a good way to pass metadata.

Limit user actions

The second goal is to check if the user is doing something he is allowed to. In our case, we reduce the scope to the execution of git-receive-pack and git-upload-pack.

To allow multiplexing into a single connection, SSH has a ‘channel’ system, multiple channels can exist concurrently. Each channel has a type and can request ‘actions’.

The type of channel which interests us is the ‘session’ channel, doing an ‘exec’ action. A complete example can be found on Github:

https://github.com/Scalingo/go-ssh-examples/blob/master/server_git.go

In the first function handleChanReq, we are filtering channel creation requests from the client and then read the first action request which should be ‘exec’.

func handleChanReq(chanReq ssh.NewChannel) {
if chanReq.ChannelType() != "session" {
chanReq.Reject(ssh.Prohibited, "channel type is not a session")
return
}

ch, reqs, err := chanReq.Accept()
if err != nil {
log.Println("fail to accept channel request", err)
return
}

req := <-reqs
if req.Type != "exec" {
ch.Write([]byte("request type '" + req.Type + "' is not 'exec'\r\n"))
ch.Close()
return
}

handleExec(ch, req)
}

When calling the second function, we get the action payload and check if the requested command is ’git-receive-pack’ (git push) or ’git-upload-pack’ (git fetch). If it is not, the channel is closed.

// handleExec filter the command which can be run.// Payload: string: command
func handleExec(ch ssh.Channel, req *ssh.Request) {
command := string(req.Payload)
gitCmds := []string{"git-receive-pack", "git-upload-pack"}

valid := false
for _, cmd := range gitCmds {
if strings.HasPrefix(command, cmd) {
valid = true
}
}
if !valid {
ch.Write([]byte("command is not a GIT command\r\n"))
ch.Close()
return
}

ch.Write([]byte("well done!\r\n"))
ch.Close()
}

Now we are sure that the user tries to run a git command.

Results:

└> ssh localhost -p 2222
request type 'pty-req' is not 'exec'
Connection to localhost closed.
└> ssh localhost -p 2222 ls
command is not a GIT command
└> ssh localhost -p 2222 git-receive-pack
well done!

Ensure the user can access the requested repository

Getting the executable name is good, but not enough. When someone runs git push appsdeck master with the following remote: ’[email protected]:myapp.git’ the command is ’git-receive-pack myapp.git’, so there is a bit more work to do to analyse the command line.

But there is nothing here related SSH, we just have to parse the string and contact a backend to check if the user is allowed to deploy/fetch the given application.

Forward connection to the next host

The last step of our SSH server is to forward the connection to a host able to achieve the deployment. To do that, our server has to be invisible for the client. It does all the checking then, pipes the connection to the next host, as a simple reverse proxy.

So there are two steps:

  • Create a SSH connection to the target server
  • Forward the connection

SSH connection

In this part, we have to use golang.org/x/crypto/ssh package again, but this time from the client perspective:

func connectToHost(user, host string, key ssh.Signer) (*ssh.Client, *ssh.Session, error) {
sshConfig := &ssh.ClientConfig{
User: use,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(key),
},
}

client, err := ssh.Dial("tcp", host, sshConfig)
if err != nil {
return err
}

session, err := client.NewSession()
if err != nil {
client.Close()
return err
}

return client, session, nil
}

The previous example connects to a server using the given SSH key and user, and creates a new ‘session’ channel, then, it returns this session which will be used forward and the associated connection, in order to be correctly closed later.

Connection forwarding

Go provides a really nice mecanism to do this pipe: io.Copy

targetStderr, _ := targetSession.StderrPipe()
targetStdout, _ := targetSession.StdoutPipe()
targetStdin, _ := targetSession.StdinPipe()

wg := &sync.WaitGroup{}
wg.Add(3)

go func() {
defer wg.Done()
io.Copy(targetStdin, userChannel)
}()

go func() {
defer wg.Done()
io.Copy(userChannel.Stderr(), targetStderr)
}()

go func() {
defer wg.Done()
io.Copy(userChannel, targetStdout)
}()

wg.Wait()

That’s it, the session has been completely piped from the user to the next server. When either the client or the upstream server closes the connection, the goroutines stop and the wg.Wait() instruction will be passed. This code does not have all the error checks, but do it on any program you intend to run in production, period! ;-)

Testing the proxy with OpenSSH

git clone https://github.com/Scalingo/go-ssh-examples
cd go-ssh-examples

# Generate host and user keypairs
bash init.sh

# Run an openssh server on the port 2223
/sbin/sshd -D -o Port=2223 -h `pwd`/host_key -o AuthorizedKeysFile=`pwd`/user_key.pub &

go run proxy/server.go proxy/connect_upstream.go &
ssh localhost -p 2222 <any command>

Conclusion

This article has been more technical than the previous one, We hope you enjoyed it. we wanted to share how Go could be used to work with SSH, in a fairly simple manner.

Don’t hesitate to ask questions if you need more details, we’ll be glad to answer them.

This article was the 3rd post of the serie #FridayTechnical, see you next week!

What are you waiting to try Scalingo?
30 days free trial. No credit card required. Deploy now, scale later.