Skip to content

Commit

Permalink
feat: add Unix forwarding server implementations
Browse files Browse the repository at this point in the history
Adds optional (disabled by default) implementations of local->remote and
remote->local Unix forwarding through OpenSSH's protocol extensions:

- streamlocal-forward@openssh.com
    - cancel-streamlocal-forward@openssh.com
    - forwarded-streamlocal@openssh.com
- direct-streamlocal@openssh.com

Adds tests for Unix forwarding, reverse Unix forwarding and reverse TCP
forwarding.
  • Loading branch information
deansheather committed Jan 6, 2023
1 parent db09465 commit 836adc0
Show file tree
Hide file tree
Showing 9 changed files with 579 additions and 30 deletions.
2 changes: 1 addition & 1 deletion options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func TestPasswordAuth(t *testing.T) {

func TestPasswordAuthBadPass(t *testing.T) {
t.Parallel()
l := newLocalListener()
l := newLocalTCPListener()
srv := &Server{Handler: func(s Session) {}}
srv.SetOption(PasswordAuth(func(ctx Context, password string) bool {
return false
Expand Down
2 changes: 2 additions & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ type Server struct {
PtyCallback PtyCallback // callback for allowing PTY sessions, allows all if nil
ConnCallback ConnCallback // optional callback for wrapping net.Conn before handling
LocalPortForwardingCallback LocalPortForwardingCallback // callback for allowing local port forwarding, denies all if nil
LocalUnixForwardingCallback LocalUnixForwardingCallback // callback for allowing local unix forwarding (direct-streamlocal@openssh.com), denies all if nil
ReversePortForwardingCallback ReversePortForwardingCallback // callback for allowing reverse port forwarding, denies all if nil
ReverseUnixForwardingCallback ReverseUnixForwardingCallback // callback for allowing reverse unix forwarding (streamlocal-forward@openssh.com), denies all if nil
ServerConfigCallback ServerConfigCallback // callback for configuring detailed SSH options
SessionRequestCallback SessionRequestCallback // callback for allowing or denying SSH sessions

Expand Down
4 changes: 2 additions & 2 deletions server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestAddHostKey(t *testing.T) {
}

func TestServerShutdown(t *testing.T) {
l := newLocalListener()
l := newLocalTCPListener()
testBytes := []byte("Hello world\n")
s := &Server{
Handler: func(s Session) {
Expand Down Expand Up @@ -80,7 +80,7 @@ func TestServerShutdown(t *testing.T) {
}

func TestServerClose(t *testing.T) {
l := newLocalListener()
l := newLocalTCPListener()
s := &Server{
Handler: func(s Session) {
time.Sleep(5 * time.Second)
Expand Down
19 changes: 15 additions & 4 deletions session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,25 @@ func (srv *Server) serveOnce(l net.Listener) error {
return e
}
srv.ChannelHandlers = map[string]ChannelHandler{
"session": DefaultSessionHandler,
"direct-tcpip": DirectTCPIPHandler,
"session": DefaultSessionHandler,
"direct-tcpip": DirectTCPIPHandler,
"direct-streamlocal@openssh.com": DirectStreamLocalHandler,
}

forwardedTCPHandler := &ForwardedTCPHandler{}
forwardedUnixHandler := &ForwardedUnixHandler{}
srv.RequestHandlers = map[string]RequestHandler{
"tcpip-forward": forwardedTCPHandler.HandleSSHRequest,
"cancel-tcpip-forward": forwardedTCPHandler.HandleSSHRequest,
"streamlocal-forward@openssh.com": forwardedUnixHandler.HandleSSHRequest,
"cancel-streamlocal-forward@openssh.com": forwardedUnixHandler.HandleSSHRequest,
}

srv.HandleConn(conn)
return nil
}

func newLocalListener() net.Listener {
func newLocalTCPListener() net.Listener {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
if l, err = net.Listen("tcp6", "[::1]:0"); err != nil {
Expand Down Expand Up @@ -64,7 +75,7 @@ func newClientSession(t *testing.T, addr string, config *gossh.ClientConfig) (*g
}

func newTestSession(t *testing.T, srv *Server, cfg *gossh.ClientConfig) (*gossh.Session, *gossh.Client, func()) {
l := newLocalListener()
l := newLocalTCPListener()
go srv.serveOnce(l)
return newClientSession(t, l.Addr().String(), cfg)
}
Expand Down
8 changes: 8 additions & 0 deletions ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,17 @@ type ConnCallback func(ctx Context, conn net.Conn) net.Conn
// LocalPortForwardingCallback is a hook for allowing port forwarding
type LocalPortForwardingCallback func(ctx Context, destinationHost string, destinationPort uint32) bool

// LocalUnixForwardingCallback is a hook for allowing unix forwarding
// (direct-streamlocal@openssh.com)
type LocalUnixForwardingCallback func(ctx Context, socketPath string) bool

// ReversePortForwardingCallback is a hook for allowing reverse port forwarding
type ReversePortForwardingCallback func(ctx Context, bindHost string, bindPort uint32) bool

// ReverseUnixForwardingCallback is a hook for allowing reverse unix forwarding
// (streamlocal-forward@openssh.com).
type ReverseUnixForwardingCallback func(ctx Context, socketPath string) bool

// ServerConfigCallback is a hook for creating custom default server configs
type ServerConfigCallback func(ctx Context) *gossh.ServerConfig

Expand Down
204 changes: 204 additions & 0 deletions streamlocal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package ssh

import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"sync"

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

const (
forwardedUnixChannelType = "forwarded-streamlocal@openssh.com"
)

// directStreamLocalChannelData data struct as specified in OpenSSH's protocol
// extensions document, Section 2.4.
// https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL?annotate=HEAD
type directStreamLocalChannelData struct {
SocketPath string

Reserved1 string
Reserved2 uint32
}

// DirectStreamLocalHandler provides Unix forwarding from client -> server. It
// can be enabled by adding it to the server's ChannelHandlers under
// `direct-streamlocal@openssh.com`.
//
// Unix socket support on Windows is not widely available, so this handler may
// not work on all Windows installations and is not tested on Windows.
func DirectStreamLocalHandler(srv *Server, _ *gossh.ServerConn, newChan gossh.NewChannel, ctx Context) {
var d directStreamLocalChannelData
err := gossh.Unmarshal(newChan.ExtraData(), &d)
if err != nil {
_ = newChan.Reject(gossh.ConnectionFailed, "error parsing direct-streamlocal data: "+err.Error())
return
}

if srv.LocalUnixForwardingCallback == nil || !srv.LocalUnixForwardingCallback(ctx, d.SocketPath) {
newChan.Reject(gossh.Prohibited, "unix forwarding is disabled")
return
}

var dialer net.Dialer
dconn, err := dialer.DialContext(ctx, "unix", d.SocketPath)
if err != nil {
_ = newChan.Reject(gossh.ConnectionFailed, fmt.Sprintf("dial unix socket %q: %+v", d.SocketPath, err.Error()))
return
}

ch, reqs, err := newChan.Accept()
if err != nil {
_ = dconn.Close()
return
}
go gossh.DiscardRequests(reqs)

bicopy(ctx, ch, dconn)
}

// remoteUnixForwardRequest describes the extra data sent in a
// streamlocal-forward@openssh.com containing the socket path to bind to.
type remoteUnixForwardRequest struct {
SocketPath string
}

// remoteUnixForwardChannelData describes the data sent as the payload in the new
// channel request when a Unix connection is accepted by the listener.
type remoteUnixForwardChannelData struct {
SocketPath string
Reserved uint32
}

// ForwardedUnixHandler can be enabled by creating a ForwardedUnixHandler and
// adding the HandleSSHRequest callback to the server's RequestHandlers under
// `streamlocal-forward@openssh.com` and
// `cancel-streamlocal-forward@openssh.com`
//
// Unix socket support on Windows is not widely available, so this handler may
// not work on all Windows installations and is not tested on Windows.
type ForwardedUnixHandler struct {
sync.Mutex
forwards map[string]net.Listener
}

func (h *ForwardedUnixHandler) HandleSSHRequest(ctx Context, srv *Server, req *gossh.Request) (bool, []byte) {
h.Lock()
if h.forwards == nil {
h.forwards = make(map[string]net.Listener)
}
h.Unlock()
conn, ok := ctx.Value(ContextKeyConn).(*gossh.ServerConn)
if !ok {
// TODO: log cast failure
return false, nil
}

switch req.Type {
case "streamlocal-forward@openssh.com":
var reqPayload remoteUnixForwardRequest
err := gossh.Unmarshal(req.Payload, &reqPayload)
if err != nil {
// TODO: log parse failure
return false, nil
}

if srv.ReverseUnixForwardingCallback == nil || !srv.ReverseUnixForwardingCallback(ctx, reqPayload.SocketPath) {
return false, []byte("unix forwarding is disabled")
}

addr := reqPayload.SocketPath
h.Lock()
_, ok := h.forwards[addr]
h.Unlock()
if ok {
// TODO: log failure
return false, nil
}

// Create socket parent dir if not exists.
parentDir := filepath.Dir(addr)
err = os.MkdirAll(parentDir, 0700)
if err != nil {
// TODO: log mkdir failure
return false, nil
}

ln, err := net.Listen("unix", addr)
if err != nil {
// TODO: log unix listen failure
return false, nil
}

// The listener needs to successfully start before it can be added to
// the map, so we don't have to worry about checking for an existing
// listener as you can't listen on the same socket twice.
//
// This is also what the TCP version of this code does.
h.Lock()
h.forwards[addr] = ln
h.Unlock()

ctx, cancel := context.WithCancel(ctx)
go func() {
<-ctx.Done()
_ = ln.Close()
}()
go func() {
defer cancel()

for {
c, err := ln.Accept()
if err != nil {
// closed below
break
}
payload := gossh.Marshal(&remoteUnixForwardChannelData{
SocketPath: addr,
})

go func() {
ch, reqs, err := conn.OpenChannel(forwardedUnixChannelType, payload)
if err != nil {
_ = c.Close()
return
}
go gossh.DiscardRequests(reqs)
bicopy(ctx, ch, c)
}()
}

h.Lock()
ln2, ok := h.forwards[addr]
if ok && ln2 == ln {
delete(h.forwards, addr)
}
h.Unlock()
_ = ln.Close()
}()

return true, nil

case "cancel-streamlocal-forward@openssh.com":
var reqPayload remoteUnixForwardRequest
err := gossh.Unmarshal(req.Payload, &reqPayload)
if err != nil {
// TODO: log parse failure
return false, nil
}
h.Lock()
ln, ok := h.forwards[reqPayload.SocketPath]
h.Unlock()
if ok {
_ = ln.Close()
}
return true, nil

default:
return false, nil
}
}
Loading

0 comments on commit 836adc0

Please sign in to comment.