From 6c183ffac44acfaab9894d8638936b4c42dad0ce Mon Sep 17 00:00:00 2001 From: Ryo Ota Date: Fri, 11 Aug 2023 09:02:22 +0900 Subject: [PATCH 1/3] support Unix domain socket local forwarding --- cmd/root.go | 24 +++++++++++++---------- server.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 1a89961..ae5d21b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,10 +22,11 @@ type flagType struct { sshShell string sshUsers []string - allowTcpipForward bool - allowDirectTcpip bool - allowExecute bool - allowSftp bool + allowTcpipForward bool + allowDirectTcpip bool + allowExecute bool + allowSftp bool + allowDirectStreamlocal bool } type permissionFlagType = struct { @@ -49,6 +50,7 @@ func RootCmd() *cobra.Command { {name: "direct-tcpip", flagPtr: &flag.allowDirectTcpip}, {name: "execute", flagPtr: &flag.allowExecute}, {name: "sftp", flagPtr: &flag.allowSftp}, + {name: "direct-streamlocal", flagPtr: &flag.allowDirectStreamlocal}, } rootCmd := cobra.Command{ Use: os.Args[0], @@ -64,7 +66,7 @@ func RootCmd() *cobra.Command { rootCmd.PersistentFlags().StringVarP(&flag.sshHost, "host", "", "", "SSH server host (e.g. 127.0.0.1)") rootCmd.PersistentFlags().Uint16VarP(&flag.sshPort, "port", "p", 2222, "SSH server port") // NOTE: long name 'unix-socket' is from curl (ref: https://curl.se/docs/manpage.html) - rootCmd.PersistentFlags().StringVarP(&flag.sshUnixSocket, "unix-socket", "", "", "Unix-domain socket") + rootCmd.PersistentFlags().StringVarP(&flag.sshUnixSocket, "unix-socket", "", "", "Unix domain socket") rootCmd.PersistentFlags().StringVarP(&flag.sshShell, "shell", "", "", "Shell") //rootCmd.PersistentFlags().StringVar(&flag.dnsServer, "dns-server", "", "DNS server (e.g. 1.1.1.1:53)") rootCmd.PersistentFlags().StringArrayVarP(&flag.sshUsers, "user", "", nil, `SSH user name (e.g. "john:mypassword")`) @@ -74,6 +76,7 @@ func RootCmd() *cobra.Command { rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectTcpip, "allow-direct-tcpip", "", false, "client can use local forwarding and SOCKS proxy") rootCmd.PersistentFlags().BoolVarP(&flag.allowExecute, "allow-execute", "", false, "client can use shell/interactive shell") rootCmd.PersistentFlags().BoolVarP(&flag.allowSftp, "allow-sftp", "", false, "client can use SFTP and SSHFS") + rootCmd.PersistentFlags().BoolVarP(&flag.allowDirectStreamlocal, "allow-direct-streamlocal", "", false, "client can use Unix domain socket local forwarding") return &rootCmd } @@ -99,11 +102,12 @@ func rootRunEWithExtra(cmd *cobra.Command, args []string, flag *flagType, allPer } sshServer := &handy_sshd.Server{ - Logger: logger, - AllowTcpipForward: flag.allowTcpipForward, - AllowDirectTcpip: flag.allowDirectTcpip, - AllowExecute: flag.allowExecute, - AllowSftp: flag.allowSftp, + Logger: logger, + AllowTcpipForward: flag.allowTcpipForward, + AllowDirectTcpip: flag.allowDirectTcpip, + AllowExecute: flag.allowExecute, + AllowSftp: flag.allowSftp, + AllowDirectStreamlocal: flag.allowDirectStreamlocal, } var sshUsers []sshUser for _, u := range flag.sshUsers { diff --git a/server.go b/server.go index 2defe06..a6a0162 100644 --- a/server.go +++ b/server.go @@ -30,10 +30,11 @@ type Server struct { Logger *slog.Logger // Permissions - AllowTcpipForward bool - AllowDirectTcpip bool - AllowExecute bool // this should not be split into "allow-exec" and "allow-pty-req" for now because "pty-req" can be used not for shell execution. - AllowSftp bool + AllowTcpipForward bool + AllowDirectTcpip bool + AllowExecute bool // this should not be split into "allow-exec" and "allow-pty-req" for now because "pty-req" can be used not for shell execution. + AllowSftp bool + AllowDirectStreamlocal bool // TODO: DNS server ? } @@ -59,6 +60,12 @@ func (s *Server) handleChannel(shell string, newChannel ssh.NewChannel) { break } s.handleDirectTcpip(newChannel) + case "direct-streamlocal@openssh.com": + if !s.AllowDirectStreamlocal { + newChannel.Reject(ssh.Prohibited, "direct-streamlocal (Unix domain socket) not allowed") + break + } + s.handleDirectStreamlocal(newChannel) default: newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", newChannel.ChannelType())) } @@ -197,7 +204,7 @@ func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) { SourcePort uint32 } if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil { - s.Logger.Info("failed to parse message", "err", err) + s.Logger.Info("failed to parse direct-tcpip message", "err", err) return } channel, reqs, err := newChannel.Accept() @@ -227,6 +234,44 @@ func (s *Server) handleDirectTcpip(newChannel ssh.NewChannel) { return } +// client side: https://github.com/golang/crypto/blob/b4ddeeda5bc71549846db71ba23e83ecb26f36ed/ssh/streamlocal.go#L52 +func (s *Server) handleDirectStreamlocal(newChannel ssh.NewChannel) { + // https://github.com/openssh/openssh-portable/blob/f9f18006678d2eac8b0c5a5dddf17ab7c50d1e9f/PROTOCOL#L237 + var msg struct { + SocketPath string + Reserved0 string + Reserved1 uint32 + } + if err := ssh.Unmarshal(newChannel.ExtraData(), &msg); err != nil { + s.Logger.Info("failed to parse direct-streamlocal message", "err", err) + return + } + channel, reqs, err := newChannel.Accept() + if err != nil { + s.Logger.Info("failed to accept", "err", err) + return + } + go ssh.DiscardRequests(reqs) + conn, err := net.Dial("unix", msg.SocketPath) + if err != nil { + s.Logger.Info("failed to dial", "err", err) + channel.Close() + return + } + var closeOnce sync.Once + closer := func() { + channel.Close() + conn.Close() + } + go func() { + io.Copy(channel, conn) + closeOnce.Do(closer) + }() + io.Copy(conn, channel) + closeOnce.Do(closer) + return +} + // ======================= // parseDims extracts terminal dimensions (width x height) from the provided buffer. From 36b1ade1c0c9c4d07ce3cae94a1bf260b8c4b2e2 Mon Sep 17 00:00:00 2001 From: Ryo Ota Date: Fri, 11 Aug 2023 09:33:24 +0900 Subject: [PATCH 2/3] test: direct-streamlocal --- cmd/root_test.go | 34 ++++++++++++++++++++++++++++++ cmd/test_util_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 ++ 4 files changed, 85 insertions(+) diff --git a/cmd/root_test.go b/cmd/root_test.go index 1865587..5eeadf5 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -60,6 +60,7 @@ func TestAllPermissionsAllowed(t *testing.T) { assertExec(t, client) assertPtyTerminal(t, client) assertSftp(t, client) + assertUnixLocalPortForwarding(t, client) } func TestEmptyPassword(t *testing.T) { @@ -166,6 +167,7 @@ func TestAllowExecute(t *testing.T) { assertExec(t, client) assertPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixLocalPortForwarding(t, client) } func TestAllowTcpipForward(t *testing.T) { @@ -195,6 +197,7 @@ func TestAllowTcpipForward(t *testing.T) { assertNoExec(t, client) assertNoPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixLocalPortForwarding(t, client) } func TestAllowDirectTcpip(t *testing.T) { @@ -224,6 +227,37 @@ func TestAllowDirectTcpip(t *testing.T) { assertNoExec(t, client) assertNoPtyTerminal(t, client) assertNoSftp(t, client) + assertNoUnixLocalPortForwarding(t, client) +} + +func TestAllowDirectStreamlocal(t *testing.T) { + rootCmd := RootCmd() + port := getAvailableTcpPort() + rootCmd.SetArgs([]string{"--port", strconv.Itoa(port), "--user", "john:mypassword", "--allow-direct-streamlocal"}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + var stderrBuf bytes.Buffer + rootCmd.SetErr(&stderrBuf) + rootCmd.ExecuteContext(ctx) + }() + waitTCPServer(port) + sshClientConfig := &ssh.ClientConfig{ + User: "john", + Auth: []ssh.AuthMethod{ssh.Password("mypassword")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + client, err := ssh.Dial("tcp", address, sshClientConfig) + assert.NoError(t, err) + defer client.Close() + assert.NoError(t, err) + assertNoRemotePortForwarding(t, client) + assertNoLocalPortForwarding(t, client) + assertNoExec(t, client) + assertNoPtyTerminal(t, client) + assertNoSftp(t, client) + assertUnixLocalPortForwarding(t, client) } func TestAllowSftp(t *testing.T) { diff --git a/cmd/test_util_test.go b/cmd/test_util_test.go index b801bf4..b870aaa 100644 --- a/cmd/test_util_test.go +++ b/cmd/test_util_test.go @@ -2,12 +2,15 @@ package cmd import ( "bytes" + "github.com/google/uuid" "github.com/pkg/sftp" "github.com/stretchr/testify/assert" "golang.org/x/crypto/ssh" "io" "net" + "os" "os/exec" + "path" "strconv" "testing" "time" @@ -135,6 +138,51 @@ func assertNoLocalPortForwarding(t *testing.T, client *ssh.Client) { assert.Equal(t, "ssh: rejected: administratively prohibited (direct-tcpip not allowed)", err.Error()) } +func assertUnixLocalPortForwarding(t *testing.T, client *ssh.Client) { + remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String()) + acceptedConnChan := make(chan net.Conn) + { + ln, err := net.Listen("unix", remoteUnixSocket) + assert.NoError(t, err) + defer os.Remove(remoteUnixSocket) + go func() { + conn, err := ln.Accept() + assert.NoError(t, err) + acceptedConnChan <- conn + }() + } + conn, err := client.Dial("unix", remoteUnixSocket) + assert.NoError(t, err) + defer conn.Close() + acceptedConn := <-acceptedConnChan + defer acceptedConn.Close() + { + localToRemote := [3]byte{1, 2, 3} + _, err = conn.Write(localToRemote[:]) + assert.NoError(t, err) + var buf [len(localToRemote)]byte + _, err = io.ReadFull(acceptedConn, buf[:]) + assert.NoError(t, err) + assert.Equal(t, buf, localToRemote) + } + { + remoteToLocal := [4]byte{10, 20, 30, 40} + _, err = acceptedConn.Write(remoteToLocal[:]) + assert.NoError(t, err) + var buf [len(remoteToLocal)]byte + _, err = io.ReadFull(conn, buf[:]) + assert.NoError(t, err) + assert.Equal(t, buf, remoteToLocal) + } +} + +func assertNoUnixLocalPortForwarding(t *testing.T, client *ssh.Client) { + remoteUnixSocket := path.Join(os.TempDir(), "test-unix-socket-"+uuid.New().String()) + _, err := client.Dial("unix", remoteUnixSocket) + assert.Error(t, err) + assert.Equal(t, "ssh: rejected: administratively prohibited (direct-streamlocal (Unix domain socket) not allowed)", err.Error()) +} + func assertRemotePortForwarding(t *testing.T, client *ssh.Client) { remotePort := getAvailableTcpPort() ln, err := client.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort))) diff --git a/go.mod b/go.mod index d7e0a84..de21a8d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/creack/pty v1.1.18 + github.com/google/uuid v1.3.0 github.com/mattn/go-shellwords v1.0.12 github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.5 diff --git a/go.sum b/go.sum index b17c2a7..c0e1b80 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= From 7b324d52e6bc7c3299b6e998c4d9d214ca74533a Mon Sep 17 00:00:00 2001 From: Ryo Ota Date: Fri, 11 Aug 2023 09:44:31 +0900 Subject: [PATCH 3/3] docs: --allow-direct-streamlocal --- README.md | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 16017d8..6e47a8c 100644 --- a/README.md +++ b/README.md @@ -41,20 +41,29 @@ handy-sshd --unix-socket /tmp/my-unix-socket --user "john:" ``` ## Permissions -**All permissions are allowed when nothing is specified.** There are some permissions. +There are several permissions: +* --allow-direct-streamlocal * --allow-direct-tcpip * --allow-execute * --allow-sftp * --allow-tcpip-forward -Specifying `--allow-direct-tcpip` and `--allow-execute` for example allows only them. -The log shows "allowed: " and "NOT allowed: " permissions as follows. +**All permissions are allowed when nothing is specified.** The log shows "allowed: " and "NOT allowed: " permissions as follows: + +```console +$ handy-sshd --user "john:" +2023/08/11 09:42:05 INFO listening on :2222... +2023/08/11 09:42:05 INFO allowed: "tcpip-forward", "direct-tcpip", "execute", "sftp", "direct-streamlocal" +2023/08/11 09:42:05 INFO NOT allowed: none +``` + +For example, specifying `--allow-direct-tcpip` and `--allow-execute` allows only them: ```console $ handy-sshd --user "john:" --allow-direct-tcpip --allow-execute -2023/08/09 20:49:35 INFO listening on :2222... -2023/08/09 20:49:35 INFO allowed: "direct-tcpip", "execute" -2023/08/09 20:49:35 INFO NOT allowed: "tcpip-forward", "sftp" +2023/08/11 09:38:58 INFO listening on :2222... +2023/08/11 09:38:58 INFO allowed: "direct-tcpip", "execute" +2023/08/11 09:38:58 INFO NOT allowed: "tcpip-forward", "sftp", "direct-streamlocal" ``` ## --help @@ -66,15 +75,16 @@ Usage: handy-sshd [flags] Flags: - --allow-direct-tcpip client can use local forwarding and SOCKS proxy - --allow-execute client can use shell/interactive shell - --allow-sftp client can use SFTP and SSHFS - --allow-tcpip-forward client can use remote forwarding - -h, --help help for handy-sshd - --host string SSH server host (e.g. 127.0.0.1) - -p, --port uint16 SSH server port (default 2222) - --shell string Shell - --unix-socket string Unix-domain socket - --user stringArray SSH user name (e.g. "john:mypassword") - -v, --version show version + --allow-direct-streamlocal client can use Unix domain socket local forwarding + --allow-direct-tcpip client can use local forwarding and SOCKS proxy + --allow-execute client can use shell/interactive shell + --allow-sftp client can use SFTP and SSHFS + --allow-tcpip-forward client can use remote forwarding + -h, --help help for handy-sshd + --host string SSH server host (e.g. 127.0.0.1) + -p, --port uint16 SSH server port (default 2222) + --shell string Shell + --unix-socket string Unix domain socket + --user stringArray SSH user name (e.g. "john:mypassword") + -v, --version show version ```