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:
ding
2026-05-21 19:39:36 -07:00
committed by Nicola Murino
co-authored by Cyrus Katrak
parent d59570d1a3
commit d2fe592df1
5 changed files with 551 additions and 1 deletions
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+275
View File
@@ -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)
}
})
}
}
+86
View File
@@ -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))
}
}