Building SSH Applications
So you want to learn about SSH as a protocol? Great!
SSH (Secure Shell) is one of those under-the-radar technologies which is the backbone of the modern internet. The internet won’t be what it is today if SSH didn’t exist. If you’re not familiar with it, you might be wondering what all the fuss is about.
What is SSH, anyway?
In simple words, its a protocol which defines how computers can communicate with each other securely over the internet. For technical readers, It’s an application layer protocol, similar to http but with a few differences in what the main purpose of the protocol is.
Imagine you are working remotely, and need to connect to the production shell because you need access production database1. I’m sure you want such a connection between you computer and the server to be secure. That’s where SSH comes in – it encrypts the conversation between your computer and the remote server, so even if someone is tracking your network connection, they won’t be able to decipher what is being communicated. If you’re familiar with ssh
command, you have already used the protocol.
When should you use SSH instead of HTTP?
So when should you use SSH instead of good ol’ HTTP? Here are some scenarios where SSH is the way to go:
- Remote Access: When you need to access a remote server or network, SSH provides a secure connection.
- Secure file transfers: If you’re transferring sensitive files between servers, SSH ensures they stay confidential and tamper-proof.
- Automation and scripting: SSH makes it easy to automate tasks that require secure connections to remote systems.
So, let’s create a simple application
Let’s look at some pre-requisites before we start writing anything.
Create a new directory, say ssh-lab
and run the following command within the directory to initialise a go module:
go mod init github.com/<your-handle>/<your-project-name>
Then we would need to add github.com/charmbracelet/wish
package as a dependency to the project by running the following command:
go get github.com/charmbracelet/wish
Now, we begin writing a main
application which looks like the following:
package main
import (
"context"
"errors"
"net"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/logging"
)
const (
host = "localhost"
port = "23234"
)
var users = map[string]string{
"<user>": "<public-key>",
// You can add add your name and public key here :)
}
func main() {
s, err := wish.NewServer(
wish.WithAddress(net.JoinHostPort(host, port)),
wish.WithHostKeyPath(".ssh/id_ed25519"),
wish.WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool {
log.Info("public-key")
for _, pubkey := range users {
parsed, _, _, _, _ := ssh.ParseAuthorizedKey(
[]byte(pubkey),
)
if ssh.KeysEqual(key, parsed) {
log.Info("It's a match!")
return true
}
}
return false
}),
wish.WithMiddleware(
logging.Middleware(),
func(next ssh.Handler) ssh.Handler {
return func(sess ssh.Session) {
wish.Println(sess, "Authorized!")
// Do what you want to do here on the server specifically
data := sess.Command()
log.Info("COMMAND", data) // these are the arguments
}
},
),
)
if err != nil {
log.Error("Could not start server", "error", err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
log.Info("Starting SSH server", "host", host, "port", port)
go func() {
if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("Could not start server", "error", err)
done <- nil
}
}()
<-done
log.Info("Stopping SSH server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer func() { cancel() }()
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("Could not stop server", "error", err)
}
}
A simple application which authorises the user which is based on the public key provided in the users
map
var users = map[string]string{
"<user>": "<public-key>",
// You can add add your name and public key here :)
}
For your local testing, you should put your public key in here after your username
The authorisation logic lives in the following code which acts as a middleware:
wish.WithPublicKeyAuth(func(_ ssh.Context, key ssh.PublicKey) bool {
log.Info("public-key")
for _, pubkey := range users {
parsed, _, _, _, _ := ssh.ParseAuthorizedKey(
[]byte(pubkey),
)
if ssh.KeysEqual(key, parsed) {
log.Info("It's a match!")
return true
}
}
return false
}),
And the main logic for the application lives in the middlewares (which are similar to handlers from net/http
package) such as the following:
wish.WithMiddleware(
logging.Middleware(),
func(next ssh.Handler) ssh.Handler {
return func(sess ssh.Session) {
wish.Println(sess, "Authorized!")
// Do what you want to do here on the server specifically
data := sess.Command()
log.Info("COMMAND", data) // these are the arguments
}
},
),
Now you can imagine, how this could be used directly from your command line executing what you programmed on a “bastion” server by just running the
ssh <user>@<ip-address> -p 23234
Applications with a TUI or something that could do some administrative tasks over command line could be infinitely useful for people managing servers themselves. I personally use it to deploy applications directly from CI, run migrations on those applications also from CI.
-
Lets not discuss about whether this is a good idea or not because that by itself is another blog. ↩︎