diff --git a/go-tests/mockssh/host_key b/go-tests/mockssh/host_key new file mode 100644 index 0000000000..9a83d42bd5 --- /dev/null +++ b/go-tests/mockssh/host_key @@ -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----- diff --git a/go-tests/mockssh/server.go b/go-tests/mockssh/server.go new file mode 100644 index 0000000000..b96b2b629c --- /dev/null +++ b/go-tests/mockssh/server.go @@ -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() + } +} diff --git a/go-tests/ssh_test.go b/go-tests/ssh_test.go new file mode 100644 index 0000000000..6c0e8568c3 --- /dev/null +++ b/go-tests/ssh_test.go @@ -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://app--0@ssh.cli-tests.example.com"} + mainEnv.Links["pf:ssh:app:1"] = mockapi.HALLink{HREF: "ssh://app--1@ssh.cli-tests.example.com"} + 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")) +} diff --git a/src/Service/SshConfig.php b/src/Service/SshConfig.php index 04445cc8b0..a63a6aff4b 100644 --- a/src/Service/SshConfig.php +++ b/src/Service/SshConfig.php @@ -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;