ssh: add openssh controlmaster socket support
Adds support for establishing SSH sessions over an existing "ControlMaster" [unix domain] socket in proxy mode. Details of the protocol can be found here: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.mux More details about ControlMaster sockets can be found here: https://linux.die.net/man/5/ssh_config Fixes golang/go#32958 Co-authored-by: Cyrus Katrak <ckatrak@slack-corp.com> Change-Id: Ia3ae8893262f5060ed3fadfcbe97619c9659145b Reviewed-on: https://go-review.googlesource.com/c/crypto/+/733040 Reviewed-by: Cherry Mui <cherryyz@google.com> Reviewed-by: Nicola Murino <nicola.murino@gmail.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Michael Pratt <mpratt@google.com>
This commit is contained in:
committed by
Nicola Murino
co-authored by
Cyrus Katrak
parent
d59570d1a3
commit
d2fe592df1
@@ -88,6 +88,32 @@ func NewClientConn(c net.Conn, addr string, config *ClientConfig) (Conn, <-chan
|
||||
return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil
|
||||
}
|
||||
|
||||
// NewControlClientConn establishes an SSH connection over an OpenSSH
|
||||
// ControlMaster socket c in proxy mode.
|
||||
//
|
||||
// Note that this package only implements the client side of the multiplexing
|
||||
// protocol. The provided net.Conn must be a local, secure connection (such as a
|
||||
// Unix domain socket) connected to an already-running OpenSSH process acting as
|
||||
// the ControlMaster.
|
||||
//
|
||||
// WARNING: Because proxy mode bypasses the standard cryptographic handshake
|
||||
// passing a standard network connection (e.g., TCP) will result in plaintext
|
||||
// data leakage.
|
||||
//
|
||||
// The Request and NewChannel channels must be serviced or the connection
|
||||
// will hang.
|
||||
func NewControlClientConn(c net.Conn) (Conn, <-chan NewChannel, <-chan *Request, error) {
|
||||
conn := &connection{
|
||||
sshConn: sshConn{conn: c},
|
||||
}
|
||||
var err error
|
||||
if conn.transport, err = handshakeControlProxy(c); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("ssh: control proxy handshake failed: %w", err)
|
||||
}
|
||||
conn.mux = newMux(conn.transport)
|
||||
return conn, conn.mux.incomingChannels, conn.mux.incomingRequests, nil
|
||||
}
|
||||
|
||||
// clientHandshake performs the client side key exchange. See RFC 4253 Section
|
||||
// 7.
|
||||
func (c *connection) clientHandshake(dialAddress string, config *ClientConfig) error {
|
||||
|
||||
+9
-1
@@ -91,9 +91,17 @@ func DiscardRequests(in <-chan *Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// A connTransport represents the transport for a connection.
|
||||
type connTransport interface {
|
||||
packetConn
|
||||
getAlgorithms() NegotiatedAlgorithms
|
||||
getSessionID() []byte
|
||||
waitSession() error
|
||||
}
|
||||
|
||||
// A connection represents an incoming connection.
|
||||
type connection struct {
|
||||
transport *handshakeTransport
|
||||
transport connTransport
|
||||
sshConn
|
||||
|
||||
// The connection protocol.
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/cryptobyte"
|
||||
)
|
||||
|
||||
const (
|
||||
muxProtocolVersion = 4
|
||||
|
||||
muxMsgHello = 0x00000001
|
||||
muxCProxy = 0x1000000f
|
||||
muxSProxy = 0x8000000f
|
||||
)
|
||||
|
||||
const controlProxyRequestID = 0
|
||||
|
||||
// handshakeControlProxy attempts to establish a transport connection with an
|
||||
// OpenSSH ControlMaster socket in proxy mode. For details see:
|
||||
// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.mux
|
||||
func handshakeControlProxy(rw io.ReadWriteCloser) (connTransport, error) {
|
||||
if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint32(muxMsgHello)
|
||||
b.AddUint32(muxProtocolVersion)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mux hello write failed: %w", err)
|
||||
}
|
||||
if err := controlProxyWritePacket(rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint32(muxCProxy)
|
||||
b.AddUint32(controlProxyRequestID)
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mux client proxy write failed: %w", err)
|
||||
}
|
||||
|
||||
messageType, body, err := controlProxyReadMessage(rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mux hello read failed: %w", err)
|
||||
}
|
||||
if messageType != muxMsgHello {
|
||||
return nil, fmt.Errorf("expected hello response, got %v", messageType)
|
||||
}
|
||||
var v uint32
|
||||
if !body.ReadUint32(&v) {
|
||||
return nil, errors.New("EOF reading mux protocol version")
|
||||
}
|
||||
if v != muxProtocolVersion {
|
||||
return nil, fmt.Errorf("mux server has unsupported version %v", v)
|
||||
}
|
||||
messageType, body, err = controlProxyReadMessage(rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mux server proxy read failed: %w", err)
|
||||
}
|
||||
if messageType != muxSProxy {
|
||||
return nil, fmt.Errorf("expected server proxy response, got %v", messageType)
|
||||
}
|
||||
var reqID uint32
|
||||
if !body.ReadUint32(&reqID) {
|
||||
return nil, errors.New("EOF reading request id")
|
||||
}
|
||||
if reqID != controlProxyRequestID {
|
||||
return nil, fmt.Errorf("expected request id %v, got %v", controlProxyRequestID, reqID)
|
||||
}
|
||||
return &controlProxyTransport{rw}, nil
|
||||
}
|
||||
|
||||
// controlProxyTransport implements the connTransport interface for
|
||||
// ControlMaster connections. Each controlMessage has zero length padding and
|
||||
// no MAC.
|
||||
type controlProxyTransport struct {
|
||||
rw io.ReadWriteCloser
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) Close() error {
|
||||
return p.rw.Close()
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) writePacket(controlMessage []byte) error {
|
||||
return controlProxyWritePacket(p.rw, func(b *cryptobyte.Builder) {
|
||||
b.AddUint8(0) // Padding length.
|
||||
b.AddBytes(controlMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) readPacket() ([]byte, error) {
|
||||
buf, err := controlProxyReadPacket(p.rw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh: error reading control message: %w", err)
|
||||
}
|
||||
// Discard the padding length.
|
||||
if len(buf) < 1 {
|
||||
return nil, errors.New("ssh: EOF reading padding length")
|
||||
}
|
||||
if buf[0] != 0 {
|
||||
return nil, errors.New("ssh: unexpected non-zero padding in control message")
|
||||
}
|
||||
return buf[1:], nil
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) getAlgorithms() NegotiatedAlgorithms {
|
||||
return NegotiatedAlgorithms{}
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) getSessionID() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *controlProxyTransport) waitSession() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func controlProxyWritePacket(w io.Writer, f cryptobyte.BuilderContinuation) error {
|
||||
var buf []byte
|
||||
b := cryptobyte.NewBuilder(buf)
|
||||
b.AddUint32LengthPrefixed(f)
|
||||
out, err := b.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func controlProxyReadPacket(r io.Reader) (cryptobyte.String, error) {
|
||||
var l uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l > maxPacket {
|
||||
return nil, fmt.Errorf("message length %v exceeds maximum %v", l, maxPacket)
|
||||
}
|
||||
buf := make([]byte, l)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func controlProxyReadMessage(r io.Reader) (messageType uint32, body cryptobyte.String, err error) {
|
||||
body, err = controlProxyReadPacket(r)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("error reading message body: %w", err)
|
||||
}
|
||||
if !body.ReadUint32(&messageType) {
|
||||
return 0, nil, errors.New("EOF reading message type")
|
||||
}
|
||||
return messageType, body, nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright 2026 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControlClientHandshake(t *testing.T) {
|
||||
reqs := [][]byte{
|
||||
// Hello request.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04},
|
||||
// Client proxy request.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x10, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00},
|
||||
}
|
||||
respsNormal := [][]byte{
|
||||
// Hello response.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04},
|
||||
// Server proxy response.
|
||||
{0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00},
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
resps [][]byte
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "normal handshake",
|
||||
resps: respsNormal,
|
||||
},
|
||||
{
|
||||
name: "length greater than max",
|
||||
resps: [][]byte{
|
||||
{0xff, 0xff, 0xff, 0xff},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "message length 4294967295 exceeds maximum",
|
||||
},
|
||||
{
|
||||
name: "missing hello response",
|
||||
resps: [][]byte{
|
||||
{},
|
||||
},
|
||||
expectedErr: "use of closed network connection",
|
||||
},
|
||||
{
|
||||
name: "hello response too short",
|
||||
resps: [][]byte{
|
||||
{0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "EOF",
|
||||
},
|
||||
{
|
||||
name: "bad hello response type",
|
||||
resps: [][]byte{
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "expected hello response, got 0",
|
||||
},
|
||||
{
|
||||
name: "bad protocol version",
|
||||
resps: [][]byte{
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00},
|
||||
respsNormal[1],
|
||||
},
|
||||
expectedErr: "mux server has unsupported version 0",
|
||||
},
|
||||
{
|
||||
name: "missing server proxy response",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
},
|
||||
expectedErr: "use of closed network connection",
|
||||
},
|
||||
{
|
||||
name: "server proxy response too short",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
{0x00, 0x00, 0x00, 0x06, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00},
|
||||
},
|
||||
expectedErr: "EOF",
|
||||
},
|
||||
{
|
||||
name: "bad server proxy response type",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
{0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
},
|
||||
expectedErr: "expected server proxy response, got 0",
|
||||
},
|
||||
{
|
||||
name: "bad request id",
|
||||
resps: [][]byte{
|
||||
respsNormal[0],
|
||||
{0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x01},
|
||||
},
|
||||
expectedErr: "expected request id 0, got 1",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
done := make(chan error, 1)
|
||||
|
||||
ok := func() bool {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _, _, err := NewControlClientConn(c2)
|
||||
c2.Write([]byte{0}) // Dummy message to unblock the final read.
|
||||
done <- err
|
||||
}()
|
||||
|
||||
i := 0
|
||||
for ; i < len(reqs) && i < len(tt.resps); i++ {
|
||||
expected := reqs[i]
|
||||
buf := make([]byte, len(expected))
|
||||
if _, err := io.ReadFull(c1, buf); err != nil {
|
||||
t.Errorf("error reading message %d: %v", i+1, err)
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(buf, expected) {
|
||||
t.Errorf(
|
||||
"unexpected message %d: got %v, want %v",
|
||||
i+1, buf, expected,
|
||||
)
|
||||
return false
|
||||
}
|
||||
_, err = c1.Write(tt.resps[i])
|
||||
if err != nil {
|
||||
t.Errorf("error writing message %d: %v", i+1, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Wait for the next message so that the final response can be read.
|
||||
buf := make([]byte, 1)
|
||||
c1.Read(buf)
|
||||
return true
|
||||
}()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := <-done
|
||||
if tt.expectedErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.expectedErr) {
|
||||
t.Fatalf("got err %q; want err containing %q", err, tt.expectedErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("got err %q; want no err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlClientTransport(t *testing.T) {
|
||||
type response struct {
|
||||
status bool
|
||||
payload []byte
|
||||
err error
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
resp []byte
|
||||
respStatus bool
|
||||
respPayload []byte
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "successful request",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x51},
|
||||
respStatus: true,
|
||||
},
|
||||
{
|
||||
name: "failed request",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x02, 0x00, 0x52},
|
||||
respStatus: false,
|
||||
},
|
||||
{
|
||||
name: "short response",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x00},
|
||||
expectedErr: "EOF",
|
||||
},
|
||||
{
|
||||
name: "response with payload",
|
||||
resp: []byte{0x00, 0x00, 0x00, 0x05, 0x00, 0x51, 0x01, 0x02, 0x03},
|
||||
respStatus: true,
|
||||
respPayload: []byte{1, 2, 3},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c1, c2, err := netPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("netPipe: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
|
||||
// Handshake responses.
|
||||
c1.Write([]byte{
|
||||
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04,
|
||||
0x00, 0x00, 0x00, 0x08, 0x80, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00,
|
||||
})
|
||||
|
||||
conn, chans, reqs, err := NewControlClientConn(c2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := NewClient(conn, chans, reqs)
|
||||
|
||||
done := make(chan response, 1)
|
||||
go func() {
|
||||
defer close(done)
|
||||
status, payload, err := client.SendRequest("hello", true, nil)
|
||||
if err != nil {
|
||||
done <- response{err: err}
|
||||
return
|
||||
}
|
||||
done <- response{
|
||||
status: status,
|
||||
payload: payload,
|
||||
}
|
||||
}()
|
||||
|
||||
// Discard handshake.
|
||||
io.CopyN(io.Discard, c1, 24)
|
||||
|
||||
expectedReq := []byte{
|
||||
0x00, 0x00, 0x00, 0x0c, 0x00, 0x50,
|
||||
0x00, 0x00, 0x00, 0x05, 'h', 'e', 'l', 'l', 'o',
|
||||
0x01,
|
||||
}
|
||||
buf := make([]byte, len(expectedReq))
|
||||
if _, err := io.ReadFull(c1, buf); err != nil {
|
||||
t.Fatalf("reading request: %v", err)
|
||||
}
|
||||
if !bytes.Equal(buf, expectedReq) {
|
||||
t.Fatalf("got request %v; want %v", buf, expectedReq)
|
||||
}
|
||||
|
||||
c1.Write(tt.resp)
|
||||
|
||||
resp := <-done
|
||||
if tt.expectedErr != "" {
|
||||
if resp.err == nil || !strings.Contains(resp.err.Error(), tt.expectedErr) {
|
||||
t.Fatalf("got err %q; want err containing %q", resp.err, tt.expectedErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if resp.err != nil {
|
||||
t.Fatalf("got err %q; want no err", resp.err)
|
||||
}
|
||||
if resp.status != tt.respStatus {
|
||||
t.Fatalf("got status %v; want %v", resp.status, tt.respStatus)
|
||||
}
|
||||
if !bytes.Equal(resp.payload, tt.respPayload) {
|
||||
t.Errorf("got payload %v; want %v", resp.payload, tt.respPayload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/internal/testenv"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -161,3 +163,87 @@ func TestSSHCLIKeyExchanges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCLIControlClientConn(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skipf("always fails on Windows, see #64403")
|
||||
}
|
||||
sshCLI := sshClient(t)
|
||||
keyFiles := map[string][]byte{
|
||||
"rsa": testdata.PEMBytes["rsa"],
|
||||
"rsa.pub": ssh.MarshalAuthorizedKey(testPublicKeys["rsa"]),
|
||||
}
|
||||
keyPrivPath := setupSSHCLIKeys(t, keyFiles, "rsa")
|
||||
|
||||
config := &ssh.ServerConfig{
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
if conn.User() == "testcontrolproxy" && bytes.Equal(key.Marshal(), testPublicKeys["rsa"].Marshal()) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("pubkey for %q not acceptable", conn.User())
|
||||
},
|
||||
}
|
||||
config.AddHostKey(testSigners["rsa"])
|
||||
|
||||
server, err := newTestServer(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to start test server: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
port, err := server.port()
|
||||
if err != nil {
|
||||
t.Fatalf("unable to get server port: %v", err)
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "controlSocket")
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create temp dir for control socket: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
csPath := filepath.Join(dir, "c")
|
||||
cmd := testenv.Command(t, sshCLI, "-vvv", "-i", keyPrivPath, "-o", "StrictHostKeyChecking=no",
|
||||
"-p", port, "-o", "ControlPath="+csPath, "-o", "ControlMaster=yes", "-N", "testcontrolproxy@127.0.0.1")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("control socket master start failed, error: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
if t.Failed() {
|
||||
t.Logf("OpenSSH output:\n\n%s", cmd.Stdout)
|
||||
}
|
||||
}()
|
||||
for i := range 10 {
|
||||
if _, err := os.Stat(csPath); err == nil {
|
||||
break
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("unable to stat control socket: %v", err)
|
||||
}
|
||||
time.Sleep((1 << i) * 5 * time.Millisecond)
|
||||
}
|
||||
|
||||
conn, err := net.Dial("unix", csPath)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to open control socket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
cc, chans, reqs, err := ssh.NewControlClientConn(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create client: %v", err)
|
||||
}
|
||||
client := ssh.NewClient(cc, chans, reqs)
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create session: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
out, err := session.CombinedOutput("true")
|
||||
if err != nil {
|
||||
t.Fatalf("command execution failed, error: %v, command output %q", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user