Skip to content

Commit

Permalink
Test SSH using a mock server
Browse files Browse the repository at this point in the history
  • Loading branch information
pjcdawkins committed Dec 28, 2024
1 parent 03a53f1 commit 7e021b4
Show file tree
Hide file tree
Showing 4 changed files with 277 additions and 1 deletion.
7 changes: 7 additions & 0 deletions go-tests/mockssh/host_key
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAPsAgW/8+C7W/lzt2aKkDBKEvL6JkFh/jQ2ESzBQ6GeAAAAJhvzH6gb8x+
oAAAAAtzc2gtZWQyNTUxOQAAACAPsAgW/8+C7W/lzt2aKkDBKEvL6JkFh/jQ2ESzBQ6GeA
AAAECYlCpSV7VEkZ3BGK80K6LR64DVHl7qtrL8oVFJg4BrCw+wCBb/z4Ltb+XO3ZoqQMEo
S8vomQWH+NDYRLMFDoZ4AAAAE3BhdHJpY2tAcGQtdGhpbmtwYWQBAg==
-----END OPENSSH PRIVATE KEY-----
203 changes: 203 additions & 0 deletions go-tests/mockssh/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package mockssh

import (
_ "embed"
"encoding/base64"
"errors"
"fmt"
"log"
"net"
"strings"
"sync"
"testing"
"time"

"golang.org/x/crypto/ssh"
)

//go:embed host_key
var hostKey []byte

func newServerConfig(t *testing.T) *ssh.ServerConfig {
return &ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if cert, ok := key.(*ssh.Certificate); ok {
t.Log("SSH certificate received with key ID:", cert.KeyId)
return &ssh.Permissions{CriticalOptions: cert.CriticalOptions, Extensions: cert.Extensions}, nil
}
return nil, fmt.Errorf("not accepting public key type: %s", key.Type())
},
AuthLogCallback: func(conn ssh.ConnMetadata, method string, err error) {
if err == nil {
t.Logf("SSH authenticated with user %s and method %s", conn.User(), method)
}
},
}
}

type MockSSHServer struct {
t *testing.T
validCommands map[string]string
listener net.Listener
port int
hostKey ssh.Signer
}

// StartServer creates and starts a local SSH server.
// The server will automatically be stopped when the test completes.
func StartServer(t *testing.T, validCommands map[string]string) (*MockSSHServer, error) {
hk, err := ssh.ParsePrivateKey(hostKey)
if err != nil {
return nil, fmt.Errorf("failed to parse host key: %v", err)
}

s := &MockSSHServer{
t: t,
validCommands: validCommands,
hostKey: hk,
}
if err := s.start(); err != nil {
return nil, err
}

t.Cleanup(func() {
if err := s.listener.Close(); err != nil {
t.Fatal(err)
}
})

return s, nil
}

func (s *MockSSHServer) Port() int {
return s.port
}

func (s *MockSSHServer) HostKeyConfig() string {
return fmt.Sprintf("[127.0.0.1]:%d %s %s",
s.port,
s.hostKey.PublicKey().Type(),
base64.StdEncoding.EncodeToString(s.hostKey.PublicKey().Marshal()),
)
}

func (s *MockSSHServer) start() error {
t := s.t

config := newServerConfig(t)
config.AddHostKey(s.hostKey)

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
s.listener = listener
s.port = listener.Addr().(*net.TCPAddr).Port
t.Logf("Test SSH server listening at %s", listener.Addr())

go func(l net.Listener, validCommands map[string]string) {
for {
conn, err := l.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
break
}
t.Errorf("Failed to accept connection: %v", err)
continue
}

sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
log.Printf("Handshake failed: %v", err)
return
}

log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())

wg := sync.WaitGroup{}
wg.Add(1)
go func() {
ssh.DiscardRequests(reqs)
wg.Done()
}()
wg.Add(1)
go func() {
s.handleChannels(chans)
wg.Done()
}()
wg.Wait()
}
}(s.listener, s.validCommands)

return nil
}

func (s *MockSSHServer) handleChannels(chans <-chan ssh.NewChannel) {
t := s.t

for newChannel := range chans {
if newChannel.ChannelType() != "session" {
err := newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
if err != nil {
t.Errorf("Failed to reject channel: %v", err)
}
continue
}

channel, requests, err := newChannel.Accept()
if err != nil {
t.Errorf("Failed to accept channel: %v", err)
return
}

timer := time.NewTimer(time.Second * 10)

var exitWithStatus = make(chan uint32, 1)
go func(in <-chan *ssh.Request) {
for req := range in {
if !req.WantReply {
continue
}
switch req.Type {
case "exec":
err := req.Reply(true, nil)
if err != nil {
t.Errorf("Failed to reply to command: %v", err)
}
cmd := strings.TrimLeft(string(req.Payload), "\x00\x03")
t.Logf("Command received: %s", cmd)
if output := s.validCommands[cmd]; output != "" {
_, err = channel.Write([]byte(output))
if err != nil {
t.Errorf("Failed to write to channel: %v", err)
}
exitWithStatus <- 0
} else {
_, _ = channel.Stderr().Write([]byte(fmt.Sprintf("Invalid command: %s", cmd)))
exitWithStatus <- 1
}
return
default:
_ = req.Reply(false, nil)
}
}
}(requests)

for {
select {
case s := <-exitWithStatus:
_, err = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{s}))
if err != nil {
t.Errorf("Failed to send exit status: %v", err)
}
goto close
case <-timer.C:
t.Error("Timed out")
goto close
}
}

close:
_ = channel.Close()
}
}
66 changes: 66 additions & 0 deletions go-tests/ssh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package tests

import (
"net/http/httptest"
"strconv"
"testing"

"github.com/platformsh/cli/pkg/mockapi"
"github.com/platformsh/legacy-cli/tests/mockssh"
)

func TestSSH(t *testing.T) {
authServer := mockapi.NewAuthServer(t)
defer authServer.Close()

myUserID := "my-user-id"

sshServer, err := mockssh.StartServer(t, map[string]string{
"pwd": "/mock/path",
})
if err != nil {
t.Fatal(err)
}

projectID := "aiyaikii1uere"

apiHandler := mockapi.NewHandler(t)
apiHandler.SetMyUser(&mockapi.User{ID: myUserID})
apiHandler.SetProjects([]*mockapi.Project{
{
ID: projectID,
Links: mockapi.MakeHALLinks(
"self=/projects/"+projectID,
"environments=/projects/"+projectID+"/environments",
),
DefaultBranch: "main",
},
})
mainEnv := makeEnv(projectID, "main", "production", "active", nil)
mainEnv.SetCurrentDeployment(&mockapi.Deployment{
WebApps: map[string]mockapi.App{
"app": {Name: "app", Type: "golang:1.23", Size: "M", Disk: 2048, Mounts: map[string]mockapi.Mount{}},
},
Services: map[string]mockapi.App{},
Workers: map[string]mockapi.Worker{},
Routes: mockRoutes(),
Links: mockapi.MakeHALLinks("self=/projects/" + projectID + "/environments/main/deployment/current"),
})
mainEnv.Links["pf:ssh:app:0"] = mockapi.HALLink{HREF: "ssh://[email protected]"}
mainEnv.Links["pf:ssh:app:1"] = mockapi.HALLink{HREF: "ssh://[email protected]"}
apiHandler.SetEnvironments([]*mockapi.Environment{
mainEnv,
})

apiServer := httptest.NewServer(apiHandler)
defer apiServer.Close()

f := newCommandFactory(t, apiServer.URL, authServer.URL)
f.extraEnv = []string{
EnvPrefix + "SSH_OPTIONS=HostName 127.0.0.1\nPort " + strconv.Itoa(sshServer.Port()),
EnvPrefix + "SSH_HOST_KEYS=" + sshServer.HostKeyConfig(),
}

f.Run("cc")
assertTrimmed(t, "/mock/path", f.Run("ssh", "-p", projectID, "-e", ".", "pwd"))
}
2 changes: 1 addition & 1 deletion src/Service/SshConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function configureHostKeys()
if (!is_array($additionalKeys)) {
$additionalKeys = explode("\n", $additionalKeys);
}
$hostKeys = rtrim($hostKeys, "\n") . "\n" . $additionalKeys;
$hostKeys = rtrim($hostKeys, "\n") . "\n" . implode("\n", $additionalKeys);
}
if (empty($hostKeys)) {
return null;
Expand Down

0 comments on commit 7e021b4

Please sign in to comment.