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.
To deploy applications on our platform, people are using GIT over SSH. The front-end SSH server has the following responsabilities:
For each of these items, I will give an example about how it can be done with Go.
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 pairPasswordCallback
: authenticate with a simple passwordKeyboardInteractiveCallback
: create challenges that the user has to answer interactivelyThe 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.
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!
Getting the executable name is good, but not enough. When someone runs git push appsdeck master
with the following remote: ’git@appsdeck.eu: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.
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:
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.
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! ;-)
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>
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!
At Scalingo (with our partners) we use trackers on our website.
Some of those are mandatory for the use of our website and can't be refused.
Some others are used to measure our audience as well as to improve our relationship with you or to send you quality content and advertising.