La semaine dernière, nous avons vu que des outils existent pour créer des applications basées sur SSH avec Go : Écrire un remplacement d'OpenSSH en utilisant Go (1/2).
Cette semaine, nous allons donner des exemples d'utilisation avancée du package golang.org/x/crypto/ssh .
Cas d'utilisation de Scalingo
Pour déployer des applications sur notre plateforme, les gens utilisent GIT via SSH. Le serveur SSH frontal a les responsabilités suivantes :
Authentifier l'utilisateur
Vérifier que l'utilisateur souhaite exécuter une commande git
Assurer que l'utilisateur peut accéder au dépôt demandé
Transférer la connexion à un serveur qui gérera le déploiement de l'application
Pour chacun de ces éléments, je vais donner un exemple de la manière dont cela peut être réalisé avec Go.
Authentification
Lors de la configuration du serveur, il est nécessaire de construire une structure ssh.ServerConfig définissant les différentes méthodes d'authentification acceptées :
PublicKeyCallback s'authentifie avec une paire de clés privée/publique
PasswordCallback : s'authentifie avec un mot de passe simple
KeyboardInteractiveCallback : créer des défis auxquels l'utilisateur doit répondre de manière interactive
Celui que nous utilisons est l'authentification par clé publique, le gestionnaire de rappel est simple :
config := ssh.ServerConfig{
PublicKeyCallback: keyAuthCallback,
}
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
config := ssh.ServerConfig{
PublicKeyCallback: keyAuthCallback,
}
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
config := ssh.ServerConfig{
PublicKeyCallback: keyAuthCallback,
}
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
config := ssh.ServerConfig{
PublicKeyCallback: keyAuthCallback,
}
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
Toutes les fonctions cryptographiques sont gérées par le package, le rappel est utilisé pour identifier les utilisateurs par rapport à n'importe quelle source de données. Il est possible de construire n'importe quoi derrière auth.AuthenticateUser, un backend redis, une API spécifique. Le premier objectif a été atteint, nous pouvons utiliser notre propre système d'authentification.
Comme vous pouvez le voir, j’utilise la carte ssh.Permissions.Extensions pour garder une trace de l'identifiant réel de l'utilisateur qui a été authentifié avec succès, cette structure sera jointe à l'objet ssh.Conn, donc c'est un bon moyen de transmettre des métadonnées.
Limiter les actions des utilisateurs
Le deuxième objectif est de vérifier si l'utilisateur fait quelque chose qu'il est autorisé à faire. Dans notre cas, nous réduisons le cadre à l'exécution de git-receive-pack et git-upload-pack.
Pour permettre le multiplexage dans une seule connexion, SSH a un système de 'channel', plusieurs canaux peuvent exister simultanément. Chaque canal a un type et peut demander des 'actions'.
Le type de canal qui nous intéresse est le canal 'session', effectuant une action 'exec'. Un exemple complet peut être trouvé sur Github :
https://github.com/Scalingo/go-ssh-examples/blob/master/server_git.go
Dans la première fonction handleChanReq, nous filtrons les demandes de création de canal du client et ensuite nous lisons la première demande d'action qui doit être '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
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
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
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
Lors de l'appel de la deuxième fonction, nous obtenons la charge utile de l'action et vérifions si la commande demandée est ' git-receive-pack' (git push) ou ' git-upload-pack' (git fetch). Si ce n'est pas le cas, le canal est fermé.
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
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
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
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
Maintenant, nous sommes sûrs que l'utilisateur essaie d'exécuter une commande git.
Résultats :
└> 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
└> 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
└> 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
└> 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
Assurer que l'utilisateur peut accéder au dépôt demandé
Obtenir le nom exécutable est bon, mais pas suffisant. Lorsque quelqu'un exécute git push appsdeck master avec le remote suivant : 'git@appsdeck.eu:myapp.git', la commande est 'git-receive-pack myapp.git', donc il y a un peu plus de travail à faire pour analyser la ligne de commande.
Mais il n'y a rien ici lié à SSH, nous devons simplement analyser la chaîne et contacter un backend pour vérifier si l'utilisateur est autorisé à déployer/récupérer l'application donnée.
Transférer la connexion au prochain hôte
La dernière étape de notre serveur SSH est de transférer la connexion à un hôte capable de réaliser le déploiement. Pour cela, notre serveur doit être invisible pour le client. Il effectue toutes les vérifications, puis redirige la connexion vers le prochain hôte, comme un simple proxy inverse.
Il y a donc deux étapes :
Créer une connexion SSH au serveur cible
Transférer la connexion
Connexion SSH
Dans cette partie, nous devons à nouveau utiliser le package golang.org/x/crypto/ssh, mais cette fois du point de vue du client :
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
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
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
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
L'exemple précédent se connecte à un serveur en utilisant la clé SSH et l'utilisateur donnés, et crée un nouveau canal de 'session', puis, il renvoie cette session qui sera utilisée pour transférer et la connexion associée, afin d'être correctement fermée plus tard.
Transfert de connexion
Go fournit un mécanisme vraiment agréable pour réaliser ce transfert : 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()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()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()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()C'est tout, la session a été complètement transférée de l’utilisateur au serveur suivant. Lorsque le client ou le serveur en amont ferme la connexion, les goroutines s'arrêtent et l'instruction wg.Wait() sera exécutée. Ce code n'a pas toutes les vérifications d'erreurs, mais n'hésitez pas à le faire sur tout programme que vous prévoyez d'exécuter en production, point final ! ;-)
Tester le proxy avec OpenSSH
git clone https://github.com/Scalingo/go-ssh-examples
cd go-ssh-examples
bash init.sh
/sbin/sshd -D -o Port=2223 -h /host_key -o AuthorizedKeysFile=/user_key.pub &
go run proxy/server.go proxy/connect_upstream.go &
ssh localhost -p 2222
git clone https://github.com/Scalingo/go-ssh-examples
cd go-ssh-examples
bash init.sh
/sbin/sshd -D -o Port=2223 -h /host_key -o AuthorizedKeysFile=/user_key.pub &
go run proxy/server.go proxy/connect_upstream.go &
ssh localhost -p 2222
git clone https://github.com/Scalingo/go-ssh-examples
cd go-ssh-examples
bash init.sh
/sbin/sshd -D -o Port=2223 -h /host_key -o AuthorizedKeysFile=/user_key.pub &
go run proxy/server.go proxy/connect_upstream.go &
ssh localhost -p 2222
git clone https://github.com/Scalingo/go-ssh-examples
cd go-ssh-examples
bash init.sh
/sbin/sshd -D -o Port=2223 -h /host_key -o AuthorizedKeysFile=/user_key.pub &
go run proxy/server.go proxy/connect_upstream.go &
ssh localhost -p 2222
Conclusion
Cet article a été plus technique que le précédent, nous espérons que vous l'avez apprécié. Nous voulions partager comment Go peut être utilisé pour travailler avec SSH, de manière assez simple.
N'hésitez pas à poser des questions si vous avez besoin de plus de détails, nous serons ravis d'y répondre.
Cet article était le 3ème post de la série #FridayTechnical, à la semaine prochaine !
Liens