Writing an SSH server in Go
When I’m working on the Gogs project, there is a need of builtin SSH server, which allows users to preform Git-only operations through key-based authentication.
The available resources on the web are all minimal examples and do not fit this specific requirement. Therefore, I think it’s worth sharing my experiences to make your life easier in case you just run into same problem as mine.
The code structure is pretty much same to the examples you can find on the web.
- Start a SSH listening host.
- Accept new requests and validate their public key with database.
- Preform Git operations.
- The most important part, return a status if no error occurs.
OK, before we get started, just note that the code examples are not supposed to be copy-paste and just work. It will make this post too long if involves all the details.
Prepare to start a SSH server
The server must have a private key in order to start a SSH server. This is for the purpose of preventing Man-in-the-middle attack.
This key does not need to be server-wide, just keep it somewhere but not in temporary
directory because users will add this key to their known_hosts
file.
|
|
This piece of code does three things:
Setup a callback for validating public key from database.
Function
ssh.MarshalAuthorizedKey
will return a string format of user’s public key with a line break, so we want to remove that by callingstrings.TrimSpace
, and then search in the database.After search, if we return any kind of error, it will produce
Permission denied
prompt on user side. If no error is returned, you can carry an instance of type*ssh.Permissions
to the corresponding request handler.In this case, we need to set which key ID is this request corresponding to in
Extensions
.Create a private key when there is no one exists.
This is done by calling a command
ssh-keygen -f keypath -t rsa -N ""
.Load private key and start listening on given port.
Start listening and accepting new requests
Like normal HTTP server, an SSH server needs to listen on a specific port as well.
The pattern is very similar:
|
|
- Accept requests inside an infinite
for
loop. - Preform handshakes for new SSH connections.
Discard all irrelevant incoming request but serve the one you really need to care.
At this point, you can see we use
Extensions
to pass the user’s public key ID in the database.
Handle connections
Finally, we’re going to really serve the SSH requests.
|
|
It is possible to have more than one channel inside one connection, so we need to loop over all of them.
Then, we need to make sure that it is a session
type channel, otherwise that’s useless
for performing Git operations (or other operations in general).
Next step, we need to accept requests from current channel, and serve them in separate goroutines so the connection won’t be blocked.
Finally, we’re getting into the most interesting part.
- There could be more than one request from single channel, we need to handle each of them.
The payload comes from request somehow is not always in a clean format, so we have to preform a clean operation to remove unless characters:
1 2 3 4 5 6 7
func cleanCommand(cmd string) string { i := strings.Index(cmd, "git") if i == -1 { return cmd } return cmd[i:] }
Check the type of request, the
exec
type is what we’re looking for.Clean payload again for strange characters, and call a specific command that handles Git operations.
We need to get all of three pipelines before actually start executing the command:
StdoutPipe
,StderrPipe
andStdinPipe
.Note that we have to put input pipeline in a goroutine because Git needs to write content after it receives information from server.
The most most most important thing at the end, is you must must must send a
exit-status
back to Git client side, otherwise, it just hangs forever.
This is the problem I’d been stuck for six months until someday someone somehow mentioned.
You can find complete code at SSH module file. Hope it helps you as well.